@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/README.md
CHANGED
|
@@ -1,3 +1,27 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @le-space/rates
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The euro rate of a day, one definition for Le Space's tools – [Belege](https://github.com/Le-Space/belege) and the [Eigenbeleg](https://github.com/Le-Space/eigenbeleg-agent) API and web UI (decided 2026-11-06, [eigenbeleg-agent#6](https://github.com/Le-Space/eigenbeleg-agent/issues/6)):
|
|
4
|
+
|
|
5
|
+
- **a crypto asset: its price at 00:00 UTC of the day** – CoinGecko's daily snapshot; failing that, Kraken's daily candle, its open; before Kraken's candles, Kraken's first trade at or after midnight. With `prefer: 'kraken'` the order turns.
|
|
6
|
+
- **a currency:** the ECB reference rate of the day or the last before it, inverted; RUB after 2022-03-01 from the Bank of Russia, KZT from the National Bank of Kazakhstan, other currencies the Bank of Russia lists as a cross rate through the ruble (marked `cbr-cross`).
|
|
7
|
+
- **Aleph Cloud credits:** priced in USD by Aleph, so no rate of the day: the per-credit price of the last purchase on or before the day, else Aleph's list price (1 USD per 1,000,000 credits), at that day's ECB rate (`aleph-credits`: `alephCreditPrice`, `alephCreditsInEur`).
|
|
8
|
+
- **a token nobody prices:** its deepest Uniswap pool (V2, V3, V4) at the booking's block, against ETH or USDC, through an Alchemy key (`dex-rate`).
|
|
9
|
+
|
|
10
|
+
Every answer says its source and moment (`{ rate, usdRate, source, at }`), so a document can name them. Rates are decimal strings, never floats.
|
|
11
|
+
|
|
12
|
+
```js
|
|
13
|
+
import { createRateService, createDexRates } from '@le-space/rates';
|
|
14
|
+
|
|
15
|
+
const rates = createRateService({
|
|
16
|
+
fetch, // the caller's; a browser needs a proxy for CORS
|
|
17
|
+
coingeckoKey: async () => key ?? null, // optional demo key
|
|
18
|
+
dex: createDexRates({ fetch, alchemyKey: async () => alchemy ?? null }) // optional
|
|
19
|
+
});
|
|
20
|
+
await rates.rate('AKT', '2026-09-01'); // { rate: '3.5', source: 'coingecko', at: '2026-09-01T00:00:00Z', … }
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pnpm --filter @le-space/rates test # Node, and a scenario in Chromium
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Moved with its history from Belege's `bridge/src/rates.js` and `dex-rate.js`. AGPL-3.0-or-later.
|
package/package.json
CHANGED
|
@@ -1,6 +1,42 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@le-space/rates",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "The euro rate of a day for Le Space's tools: the price at 00:00 UTC (CoinGecko, Kraken), the ECB reference rate (Bank of Russia, National Bank of Kazakhstan where it has none), a token's DEX pool at a block. Every answer names its source. Browser and Node; fetch and keys handed in.",
|
|
5
|
+
"license": "AGPL-3.0-or-later",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/Le-Space/belege.git",
|
|
10
|
+
"directory": "packages/rates"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/Le-Space/belege/tree/main/packages/rates#readme",
|
|
13
|
+
"sideEffects": false,
|
|
14
|
+
"exports": {
|
|
15
|
+
".": "./src/index.js",
|
|
16
|
+
"./rates": "./src/rates.js",
|
|
17
|
+
"./dex-rate": "./src/dex-rate.js",
|
|
18
|
+
"./aleph-credits": "./src/aleph-credits.js"
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"src"
|
|
22
|
+
],
|
|
23
|
+
"engines": {
|
|
24
|
+
"node": ">=22"
|
|
25
|
+
},
|
|
26
|
+
"publishConfig": {
|
|
27
|
+
"access": "public",
|
|
28
|
+
"provenance": true
|
|
29
|
+
},
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"@noble/hashes": "2.4.0",
|
|
32
|
+
"@le-space/ledger": "^0.2.0"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"prettier": "3.6.2",
|
|
36
|
+
"@le-space/browser-test": "0.1.0"
|
|
37
|
+
},
|
|
38
|
+
"scripts": {
|
|
39
|
+
"test": "node --test --test-reporter=spec \"test/*.test.js\"",
|
|
40
|
+
"lint": "prettier --check src test"
|
|
41
|
+
}
|
|
6
42
|
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// The euro value of Aleph Cloud credits (Le-Space/belege#113). Credits are
|
|
3
|
+
// no market asset with a rate of the day: Aleph sells them for USD. So a
|
|
4
|
+
// day's consumption is valued at the per-credit price of the last purchase
|
|
5
|
+
// on or before that day, or at Aleph's list price of 1 USD per 1,000,000
|
|
6
|
+
// credits when the account never bought any (credits moved in from another
|
|
7
|
+
// account) – then converted at that day's ECB reference rate
|
|
8
|
+
// (createRateService().rate('USD', day)). A choice for the tax adviser; the
|
|
9
|
+
// statement names it.
|
|
10
|
+
|
|
11
|
+
import { multiply, normalize } from '@le-space/ledger/decimal';
|
|
12
|
+
|
|
13
|
+
/** Aleph's list price, USD per credit. */
|
|
14
|
+
export const ALEPH_LIST_PRICE_USD = '0.000001';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The per-credit price in force on each day, from an account's purchases.
|
|
18
|
+
*
|
|
19
|
+
* @param {{ time: string, price: string | number | null | undefined }[]} purchases any order; a
|
|
20
|
+
* purchase without a price (a transfer in) does not count
|
|
21
|
+
* @returns {(date: string) => { usdPerCredit: string, source: 'purchase' | 'list' }} date YYYY-MM-DD, UTC
|
|
22
|
+
*/
|
|
23
|
+
export function alephCreditPrice(purchases) {
|
|
24
|
+
const priced = purchases
|
|
25
|
+
.filter((p) => p?.price && Number(p.price) > 0)
|
|
26
|
+
.map((p) => ({ time: String(p.time), price: String(p.price) }))
|
|
27
|
+
.sort((a, b) => (a.time < b.time ? -1 : 1));
|
|
28
|
+
return (date) => {
|
|
29
|
+
let found = null;
|
|
30
|
+
for (const p of priced) if (p.time.slice(0, 10) <= date) found = p.price;
|
|
31
|
+
return found
|
|
32
|
+
? { usdPerCredit: found, source: 'purchase' }
|
|
33
|
+
: { usdPerCredit: ALEPH_LIST_PRICE_USD, source: 'list' };
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Credits → euros: credits × USD per credit × EUR per USD, rounded half away
|
|
39
|
+
* from zero to cents. Exact, no float in between.
|
|
40
|
+
*
|
|
41
|
+
* @param {string | number} credits
|
|
42
|
+
* @param {string} usdPerCredit
|
|
43
|
+
* @param {string} eurPerUsd
|
|
44
|
+
* @returns {string} EUR, two decimals at most
|
|
45
|
+
*/
|
|
46
|
+
export function alephCreditsInEur(credits, usdPerCredit, eurPerUsd) {
|
|
47
|
+
const usd = multiply(plain(credits), plain(usdPerCredit), 12);
|
|
48
|
+
return multiply(usd, plain(eurPerUsd), 2);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* A number as a plain decimal string (no exponent), at most 12 decimals.
|
|
53
|
+
*
|
|
54
|
+
* @param {string | number} v
|
|
55
|
+
*/
|
|
56
|
+
export function plain(v) {
|
|
57
|
+
if (typeof v === 'string' && /^-?\d+(\.\d+)?$/.test(v)) return normalize(v);
|
|
58
|
+
const n = Number(v);
|
|
59
|
+
if (!Number.isFinite(n)) throw new Error(`not a number: ${v}`);
|
|
60
|
+
return normalize(n.toFixed(12));
|
|
61
|
+
}
|
package/src/dex-rate.js
ADDED
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// A token's rate from a DEX pool at the block of its booking (issue #163), for
|
|
3
|
+
// tokens CoinGecko does not price on that day. Read on chain through the
|
|
4
|
+
// caller's Alchemy key (Belege: `pnpm setup:alchemy`), so it needs no further service; without
|
|
5
|
+
// a key there is no pool rate.
|
|
6
|
+
//
|
|
7
|
+
// How:
|
|
8
|
+
// 1. the token's pools against ETH and against USDC:
|
|
9
|
+
// - Uniswap V2 (`getPair`) and V3 (`getPool`, 0.05 %, 0.3 %, 1 %) against
|
|
10
|
+
// WETH and USDC, from the factories below;
|
|
11
|
+
// - Uniswap V4 (one PoolManager for all pools, read through its
|
|
12
|
+
// `StateView`): the pool ids of the token against native ETH
|
|
13
|
+
// (`address(0)`) and USDC at the usual fee tiers, without hooks – a pool
|
|
14
|
+
// with hooks has an id nobody can guess and is not found;
|
|
15
|
+
// 2. how deep each pool is on its quote side at the booking's block: V2 the
|
|
16
|
+
// quote token it holds (`balanceOf`); V3 and V4 the reserve their active
|
|
17
|
+
// liquidity amounts to at the current price (`liquidity`, the square
|
|
18
|
+
// root price) – what moving the price costs, and alike for both, where a
|
|
19
|
+
// V3 pool's balance would count liquidity far from the price and V4 has
|
|
20
|
+
// no balance of its own – in euros, by the quote's rate of the day; the
|
|
21
|
+
// deepest counts, and only when it holds at least `minEur` (a thin
|
|
22
|
+
// pool's price is easily bent);
|
|
23
|
+
// 3. its price at that block: V2 `getReserves`, V3 `slot0`, V4 `getSlot0`;
|
|
24
|
+
// 4. times the quote's euro rate of the day (rates.js).
|
|
25
|
+
// Every call is an `eth_call` at that block, so the result can be recomputed
|
|
26
|
+
// by anyone: pool (or V4 pool id) and block go with the rate.
|
|
27
|
+
//
|
|
28
|
+
// Addresses checked against the chains: V2/V3 on 2026-09-28 (`getPair` /
|
|
29
|
+
// `getPool` of USDC/WETH give the well-known pools), V4's StateView on
|
|
30
|
+
// 2026-09-29 (the ETH/USDC pools at 0.05 % and 0.3 % give ~2,700 USDC per ETH).
|
|
31
|
+
|
|
32
|
+
import { keccak_256 } from '@noble/hashes/sha3.js';
|
|
33
|
+
import { fromHex, toHex, utf8 } from '@le-space/ledger/bytes';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @typedef {object} DexChain
|
|
37
|
+
* @property {string} network Alchemy's network, the host's first label
|
|
38
|
+
* @property {string} weth
|
|
39
|
+
* @property {string} usdc
|
|
40
|
+
* @property {string} v2Factory Uniswap V2
|
|
41
|
+
* @property {string} v3Factory Uniswap V3
|
|
42
|
+
* @property {string} v4StateView Uniswap V4's read-only view of its PoolManager
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
/** @type {Readonly<Record<string, DexChain>>} */
|
|
46
|
+
export const DEX_CHAINS = Object.freeze({
|
|
47
|
+
ethereum: {
|
|
48
|
+
network: 'eth-mainnet',
|
|
49
|
+
weth: '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2',
|
|
50
|
+
usdc: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48',
|
|
51
|
+
v2Factory: '0x5c69bee701ef814a2b6a3edd4b1652cb9cc5aa6f',
|
|
52
|
+
v3Factory: '0x1f98431c8ad98523631ae4a59f267346ea31f984',
|
|
53
|
+
v4StateView: '0x7ffe42c4a5deea5b0fec41c94c136cf115597227'
|
|
54
|
+
},
|
|
55
|
+
base: {
|
|
56
|
+
network: 'base-mainnet',
|
|
57
|
+
weth: '0x4200000000000000000000000000000000000006',
|
|
58
|
+
usdc: '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913',
|
|
59
|
+
v2Factory: '0x8909dc15e40173ff4699343b6eb8132c65e18ec6',
|
|
60
|
+
v3Factory: '0x33128a8fc17869897dce68ed026d694621f6fdfd',
|
|
61
|
+
v4StateView: '0xa3c0c9b65bad0b08107aa264b0f3db444b867a71'
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
/** Uniswap V3's fee tiers, in hundredths of a basis point. */
|
|
66
|
+
const V3_FEES = [500, 3000, 10000];
|
|
67
|
+
/** Uniswap V4's usual fee tiers with their tick spacing. */
|
|
68
|
+
const V4_TIERS = [
|
|
69
|
+
[100, 1],
|
|
70
|
+
[500, 10],
|
|
71
|
+
[3000, 60],
|
|
72
|
+
[10000, 200]
|
|
73
|
+
];
|
|
74
|
+
/** The least a pool must hold on its quote side, in euros, for its price to count. */
|
|
75
|
+
export const MIN_POOL_EUR = 3000;
|
|
76
|
+
|
|
77
|
+
/** @param {string} signature */
|
|
78
|
+
const selectorOf = (signature) => `0x${toHex(keccak_256(utf8(signature))).slice(0, 8)}`;
|
|
79
|
+
|
|
80
|
+
const SELECTOR = {
|
|
81
|
+
getPair: '0xe6a43905',
|
|
82
|
+
getPool: '0x1698ee82',
|
|
83
|
+
balanceOf: '0x70a08231',
|
|
84
|
+
token0: '0x0dfe1681',
|
|
85
|
+
getReserves: '0x0902f1ac',
|
|
86
|
+
slot0: '0x3850c7bd',
|
|
87
|
+
liquidity: '0x1a686502',
|
|
88
|
+
getSlot0: selectorOf('getSlot0(bytes32)'),
|
|
89
|
+
getLiquidity: selectorOf('getLiquidity(bytes32)')
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const ADDRESS = /^0x[0-9a-f]{40}$/;
|
|
93
|
+
const ZERO = `0x${'0'.repeat(40)}`;
|
|
94
|
+
|
|
95
|
+
/** @param {string} a */
|
|
96
|
+
const word = (a) => a.replace(/^0x/, '').padStart(64, '0');
|
|
97
|
+
/** @param {bigint} n */
|
|
98
|
+
const uint = (n) => n.toString(16).padStart(64, '0');
|
|
99
|
+
/** The n-th 32-byte word of an ABI answer. @param {string} hex @param {number} n */
|
|
100
|
+
const nth = (hex, n) => BigInt(`0x${hex.replace(/^0x/, '').slice(n * 64, (n + 1) * 64) || '0'}`);
|
|
101
|
+
/** An address in an ABI answer's first word. @param {string} hex */
|
|
102
|
+
const addressOf = (hex) => `0x${hex.replace(/^0x/, '').slice(24, 64)}`.toLowerCase();
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* A Uniswap V4 pool's id: keccak256 of its key (currency0, currency1, fee,
|
|
106
|
+
* tickSpacing, hooks), the currencies sorted by address.
|
|
107
|
+
*
|
|
108
|
+
* @param {string} a
|
|
109
|
+
* @param {string} b
|
|
110
|
+
* @param {number} fee
|
|
111
|
+
* @param {number} tickSpacing
|
|
112
|
+
* @param {string} [hooks]
|
|
113
|
+
*/
|
|
114
|
+
export function v4PoolId(a, b, fee, tickSpacing, hooks = ZERO) {
|
|
115
|
+
const [c0, c1] = BigInt(a) < BigInt(b) ? [a, b] : [b, a];
|
|
116
|
+
const key = word(c0) + word(c1) + uint(BigInt(fee)) + uint(BigInt(tickSpacing)) + word(hooks);
|
|
117
|
+
return `0x${toHex(keccak_256(fromHex(key)))}`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* `value / 10^scale` as a decimal string without trailing zeros.
|
|
122
|
+
*
|
|
123
|
+
* @param {bigint} value
|
|
124
|
+
* @param {number} scale
|
|
125
|
+
*/
|
|
126
|
+
export function decimalOf(value, scale) {
|
|
127
|
+
const s = value.toString().padStart(scale + 1, '0');
|
|
128
|
+
const out = `${s.slice(0, -scale)}.${s.slice(-scale)}`;
|
|
129
|
+
return out.replace(/0+$/, '').replace(/\.$/, '');
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** A decimal string as `[value, scale]`. @param {string} d */
|
|
133
|
+
function scaled(d) {
|
|
134
|
+
const [int, frac = ''] = d.split('.');
|
|
135
|
+
return [BigInt(int + frac), frac.length];
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** A decimal string scaled by 10^18, cut after 18 places. @param {string} d */
|
|
139
|
+
const wei = (d) => {
|
|
140
|
+
const [int, frac = ''] = d.split('.');
|
|
141
|
+
return BigInt(int + frac.slice(0, 18).padEnd(18, '0'));
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* @typedef {{ version: 2, tokenIsToken0: boolean, reserve0: bigint, reserve1: bigint } | { version: 3 | 4, tokenIsToken0: boolean, sqrtPriceX96: bigint }} PoolState
|
|
146
|
+
*/
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The quote per whole token at a pool's price, scaled by 10^18.
|
|
150
|
+
*
|
|
151
|
+
* @param {PoolState} pool
|
|
152
|
+
* @param {number} decimals the token's
|
|
153
|
+
* @param {number} [quoteDecimals] the quote's (WETH/ETH 18, USDC 6)
|
|
154
|
+
* @returns {bigint}
|
|
155
|
+
*/
|
|
156
|
+
export function quotePerToken(pool, decimals, quoteDecimals = 18) {
|
|
157
|
+
const up = 10n ** BigInt(decimals) * 10n ** 18n;
|
|
158
|
+
const down = 10n ** BigInt(quoteDecimals);
|
|
159
|
+
if (pool.version === 2) {
|
|
160
|
+
const [token, quote] = pool.tokenIsToken0
|
|
161
|
+
? [pool.reserve0, pool.reserve1]
|
|
162
|
+
: [pool.reserve1, pool.reserve0];
|
|
163
|
+
if (token === 0n) return 0n;
|
|
164
|
+
return (quote * up) / (token * down);
|
|
165
|
+
}
|
|
166
|
+
// V3/V4: price = token1 per token0 in raw units = sqrtPriceX96² / 2^192.
|
|
167
|
+
const p2 = pool.sqrtPriceX96 * pool.sqrtPriceX96;
|
|
168
|
+
const q192 = 1n << 192n;
|
|
169
|
+
if (p2 === 0n) return 0n;
|
|
170
|
+
return pool.tokenIsToken0 ? (p2 * up) / (q192 * down) : (q192 * up) / (p2 * down);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** ETH per whole token, in wei (a WETH-quoted pool). @param {PoolState} pool @param {number} decimals */
|
|
174
|
+
export const ethPerToken = (pool, decimals) => quotePerToken(pool, decimals, 18);
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* A V3/V4 pool's virtual reserve of one side in raw units, from its active
|
|
178
|
+
* liquidity: token0 = L·2^96/√P, token1 = L·√P/2^96.
|
|
179
|
+
*
|
|
180
|
+
* @param {bigint} liquidity
|
|
181
|
+
* @param {bigint} sqrtPriceX96
|
|
182
|
+
* @param {boolean} ofToken0
|
|
183
|
+
*/
|
|
184
|
+
export function virtualReserve(liquidity, sqrtPriceX96, ofToken0) {
|
|
185
|
+
if (sqrtPriceX96 === 0n) return 0n;
|
|
186
|
+
return ofToken0 ? (liquidity << 96n) / sqrtPriceX96 : (liquidity * sqrtPriceX96) >> 96n;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* @param {object} options
|
|
191
|
+
* @param {typeof fetch} [options.fetch]
|
|
192
|
+
* @param {() => Promise<string | null>} options.alchemyKey
|
|
193
|
+
* @param {(network: string) => string} [options.alchemyBaseUrl]
|
|
194
|
+
* @param {number} [options.minEur]
|
|
195
|
+
*/
|
|
196
|
+
export function createDexRates({
|
|
197
|
+
fetch: f = fetch,
|
|
198
|
+
alchemyKey,
|
|
199
|
+
alchemyBaseUrl = (network) => `https://${network}.g.alchemy.com/v2`,
|
|
200
|
+
minEur = MIN_POOL_EUR
|
|
201
|
+
}) {
|
|
202
|
+
/**
|
|
203
|
+
* The token's price in a quote at a block, from its deepest pool; null when
|
|
204
|
+
* there is no pool, no key, or none deep enough.
|
|
205
|
+
*
|
|
206
|
+
* @param {{ chain: string, contract: string, block: number, decimals: number, quoteEur: (quote: 'ETH' | 'USDC') => Promise<string | null> }} params
|
|
207
|
+
* `quoteEur`: the quote's euro rate of the day, to weigh the pools
|
|
208
|
+
* @returns {Promise<{ price: string, quote: 'ETH' | 'USDC', pool: string, version: 2 | 3 | 4, block: number, at: string, depth: string } | null>}
|
|
209
|
+
*/
|
|
210
|
+
async function poolPrice({ chain, contract, block, decimals, quoteEur }) {
|
|
211
|
+
const dex = Object.hasOwn(DEX_CHAINS, chain) ? DEX_CHAINS[chain] : null;
|
|
212
|
+
const token = String(contract).toLowerCase();
|
|
213
|
+
if (!dex || !ADDRESS.test(token) || !Number.isSafeInteger(block) || block <= 0) return null;
|
|
214
|
+
if (!Number.isInteger(decimals) || decimals < 0 || decimals > 36) return null;
|
|
215
|
+
const key = await alchemyKey().catch(() => null);
|
|
216
|
+
if (typeof key !== 'string' || !/^[A-Za-z0-9_-]{8,128}$/.test(key)) return null;
|
|
217
|
+
const url = `${alchemyBaseUrl(dex.network)}/${key}`;
|
|
218
|
+
const tag = `0x${block.toString(16)}`;
|
|
219
|
+
|
|
220
|
+
/** @param {string} to @param {string} data @returns {Promise<string>} */
|
|
221
|
+
async function ethCall(to, data) {
|
|
222
|
+
let res;
|
|
223
|
+
try {
|
|
224
|
+
res = await f(url, {
|
|
225
|
+
method: 'POST',
|
|
226
|
+
headers: { 'content-type': 'application/json' },
|
|
227
|
+
body: JSON.stringify({
|
|
228
|
+
jsonrpc: '2.0',
|
|
229
|
+
id: 1,
|
|
230
|
+
method: 'eth_call',
|
|
231
|
+
params: [{ to, data }, tag]
|
|
232
|
+
})
|
|
233
|
+
});
|
|
234
|
+
} catch {
|
|
235
|
+
// The URL holds the key: the message names the host only.
|
|
236
|
+
throw new Error(`${dex.network}.g.alchemy.com is not reachable`);
|
|
237
|
+
}
|
|
238
|
+
const body = await res.json().catch(() => null);
|
|
239
|
+
if (!res.ok || body?.error || typeof body?.result !== 'string') {
|
|
240
|
+
throw new Error(`${dex.network}.g.alchemy.com refused eth_call`);
|
|
241
|
+
}
|
|
242
|
+
return body.result;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const quotes = /** @type {const} */ ([
|
|
246
|
+
{ symbol: 'ETH', erc20: dex.weth, v4: ZERO, decimals: 18 },
|
|
247
|
+
{ symbol: 'USDC', erc20: dex.usdc, v4: dex.usdc, decimals: 6 }
|
|
248
|
+
]);
|
|
249
|
+
/** @type {Map<string, bigint | null>} quote → EUR per whole unit, scaled by 10^18 */
|
|
250
|
+
const eurOf = new Map();
|
|
251
|
+
/** @param {'ETH' | 'USDC'} q */
|
|
252
|
+
const eurRate = async (q) => {
|
|
253
|
+
if (!eurOf.has(q)) {
|
|
254
|
+
const r = await quoteEur(q).catch(() => null);
|
|
255
|
+
eurOf.set(q, typeof r === 'string' && /^\d+(\.\d+)?$/.test(r) ? wei(r) : null);
|
|
256
|
+
}
|
|
257
|
+
return eurOf.get(q) ?? null;
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
// Each pool's depth first; its price only for the one that counts.
|
|
261
|
+
/** @type {{ pool: string, version: 2 | 3 | 4, quote: (typeof quotes)[number], depthRaw: bigint, state: () => Promise<PoolState> }[]} */
|
|
262
|
+
const found = [];
|
|
263
|
+
for (const q of quotes) {
|
|
264
|
+
const v2 = addressOf(
|
|
265
|
+
await ethCall(dex.v2Factory, SELECTOR.getPair + word(token) + word(q.erc20))
|
|
266
|
+
);
|
|
267
|
+
if (v2 !== ZERO) {
|
|
268
|
+
found.push({
|
|
269
|
+
pool: v2,
|
|
270
|
+
version: 2,
|
|
271
|
+
quote: q,
|
|
272
|
+
depthRaw: nth(await ethCall(q.erc20, SELECTOR.balanceOf + word(v2)), 0),
|
|
273
|
+
state: async () => {
|
|
274
|
+
const tokenIsToken0 = addressOf(await ethCall(v2, SELECTOR.token0)) === token;
|
|
275
|
+
const r = await ethCall(v2, SELECTOR.getReserves);
|
|
276
|
+
return { version: 2, tokenIsToken0, reserve0: nth(r, 0), reserve1: nth(r, 1) };
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
for (const fee of V3_FEES) {
|
|
281
|
+
const v3 = addressOf(
|
|
282
|
+
await ethCall(
|
|
283
|
+
dex.v3Factory,
|
|
284
|
+
SELECTOR.getPool + word(token) + word(q.erc20) + uint(BigInt(fee))
|
|
285
|
+
)
|
|
286
|
+
);
|
|
287
|
+
if (v3 === ZERO) continue;
|
|
288
|
+
const tokenIsToken0 = addressOf(await ethCall(v3, SELECTOR.token0)) === token;
|
|
289
|
+
const sqrt = nth(await ethCall(v3, SELECTOR.slot0), 0);
|
|
290
|
+
const liquidity = nth(await ethCall(v3, SELECTOR.liquidity), 0);
|
|
291
|
+
found.push({
|
|
292
|
+
pool: v3,
|
|
293
|
+
version: 3,
|
|
294
|
+
quote: q,
|
|
295
|
+
depthRaw: virtualReserve(liquidity, sqrt, !tokenIsToken0),
|
|
296
|
+
state: async () => ({ version: 3, tokenIsToken0, sqrtPriceX96: sqrt })
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
for (const [fee, spacing] of V4_TIERS) {
|
|
300
|
+
const id = v4PoolId(token, q.v4, fee, spacing);
|
|
301
|
+
const sqrt = nth(await ethCall(dex.v4StateView, SELECTOR.getSlot0 + id.slice(2)), 0);
|
|
302
|
+
// An empty slot: no such pool.
|
|
303
|
+
if (sqrt === 0n) continue;
|
|
304
|
+
const liquidity = nth(
|
|
305
|
+
await ethCall(dex.v4StateView, SELECTOR.getLiquidity + id.slice(2)),
|
|
306
|
+
0
|
|
307
|
+
);
|
|
308
|
+
const tokenIsToken0 = BigInt(token) < BigInt(q.v4);
|
|
309
|
+
found.push({
|
|
310
|
+
pool: id,
|
|
311
|
+
version: 4,
|
|
312
|
+
quote: q,
|
|
313
|
+
// The quote is the other currency.
|
|
314
|
+
depthRaw: virtualReserve(liquidity, sqrt, !tokenIsToken0),
|
|
315
|
+
state: async () => ({ version: 4, tokenIsToken0, sqrtPriceX96: sqrt })
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
let best = null;
|
|
321
|
+
let bestEur = 0n;
|
|
322
|
+
for (const c of found) {
|
|
323
|
+
const rate = await eurRate(c.quote.symbol);
|
|
324
|
+
if (rate === null) continue;
|
|
325
|
+
// Euros on the quote side, scaled by 10^18.
|
|
326
|
+
const eur = (c.depthRaw * rate) / 10n ** BigInt(c.quote.decimals);
|
|
327
|
+
if (eur > bestEur) {
|
|
328
|
+
best = c;
|
|
329
|
+
bestEur = eur;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
if (!best || bestEur < BigInt(minEur) * 10n ** 18n) return null;
|
|
333
|
+
const price = quotePerToken(await best.state(), decimals, best.quote.decimals);
|
|
334
|
+
if (price <= 0n) return null;
|
|
335
|
+
|
|
336
|
+
// When: the block's own time.
|
|
337
|
+
let at = '';
|
|
338
|
+
try {
|
|
339
|
+
const res = await f(url, {
|
|
340
|
+
method: 'POST',
|
|
341
|
+
headers: { 'content-type': 'application/json' },
|
|
342
|
+
body: JSON.stringify({
|
|
343
|
+
jsonrpc: '2.0',
|
|
344
|
+
id: 1,
|
|
345
|
+
method: 'eth_getBlockByNumber',
|
|
346
|
+
params: [tag, false]
|
|
347
|
+
})
|
|
348
|
+
});
|
|
349
|
+
const ts = Number(BigInt((await res.json())?.result?.timestamp ?? '0x0'));
|
|
350
|
+
if (ts > 0) at = new Date(ts * 1000).toISOString().replace(/\.000Z$/, 'Z');
|
|
351
|
+
} catch {
|
|
352
|
+
at = '';
|
|
353
|
+
}
|
|
354
|
+
return {
|
|
355
|
+
price: decimalOf(price, 18),
|
|
356
|
+
quote: best.quote.symbol,
|
|
357
|
+
pool: best.pool,
|
|
358
|
+
version: best.version,
|
|
359
|
+
block,
|
|
360
|
+
at,
|
|
361
|
+
depth: decimalOf(best.depthRaw, best.quote.decimals)
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
return { poolPrice };
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* EUR per token: ETH per token × EUR per ETH, as a decimal string with at
|
|
370
|
+
* most 18 decimals (a token can be worth a millionth of a cent).
|
|
371
|
+
*
|
|
372
|
+
* @param {string} ethPerTokenDecimal
|
|
373
|
+
* @param {string} eurPerEth
|
|
374
|
+
*/
|
|
375
|
+
export function multiplyRates(ethPerTokenDecimal, eurPerEth) {
|
|
376
|
+
const [a, as] = scaled(ethPerTokenDecimal);
|
|
377
|
+
const [b, bs] = scaled(eurPerEth);
|
|
378
|
+
const product = a * b; // scale as + bs
|
|
379
|
+
const scale = as + bs;
|
|
380
|
+
const keep = 18;
|
|
381
|
+
const value =
|
|
382
|
+
scale > keep
|
|
383
|
+
? (product + 5n * 10n ** BigInt(scale - keep - 1)) / 10n ** BigInt(scale - keep)
|
|
384
|
+
: product * 10n ** BigInt(keep - scale);
|
|
385
|
+
return decimalOf(value, keep);
|
|
386
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// @le-space/rates: the euro rate of a day – the price at 00:00 UTC
|
|
3
|
+
// (CoinGecko's daily snapshot, else Kraken's daily open, else Kraken's first
|
|
4
|
+
// trade at or after midnight), the ECB reference rate for currencies (Bank of
|
|
5
|
+
// Russia, National Bank of Kazakhstan where the ECB has none), and a token's
|
|
6
|
+
// DEX pool at the booking's block where nobody prices it. Every answer names
|
|
7
|
+
// its source and moment. `fetch` and the keys are handed in. Aleph Cloud
|
|
8
|
+
// credits, priced in USD by Aleph, have their own rule (aleph-credits.js).
|
|
9
|
+
export {
|
|
10
|
+
CBR_CROSS_CURRENCIES,
|
|
11
|
+
COINGECKO_PLATFORMS,
|
|
12
|
+
ECB_CURRENCIES,
|
|
13
|
+
RATE_SOURCES,
|
|
14
|
+
RateError,
|
|
15
|
+
createRateService,
|
|
16
|
+
invert,
|
|
17
|
+
toDecimal
|
|
18
|
+
} from './rates.js';
|
|
19
|
+
export {
|
|
20
|
+
DEX_CHAINS,
|
|
21
|
+
MIN_POOL_EUR,
|
|
22
|
+
createDexRates,
|
|
23
|
+
decimalOf,
|
|
24
|
+
ethPerToken,
|
|
25
|
+
multiplyRates,
|
|
26
|
+
quotePerToken,
|
|
27
|
+
v4PoolId,
|
|
28
|
+
virtualReserve
|
|
29
|
+
} from './dex-rate.js';
|
|
30
|
+
export {
|
|
31
|
+
ALEPH_LIST_PRICE_USD,
|
|
32
|
+
alephCreditPrice,
|
|
33
|
+
alephCreditsInEur,
|
|
34
|
+
plain
|
|
35
|
+
} from './aleph-credits.js';
|