@steerprotocol/liquidity-meter 1.0.0 → 2.0.4

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/depth.js ADDED
@@ -0,0 +1,492 @@
1
+ /* eslint-disable no-bitwise */
2
+
3
+ // Market-depth bands for a Uniswap v3 pool snapshot.
4
+ // Dependency-free, uses BigInt for exact fixed-point arithmetic (Q64.96).
5
+
6
+ /////////////////////////// Fixed-point + constants ///////////////////////////
7
+
8
+ const Q32 = 1n << 32n;
9
+ const Q96 = 1n << 96n;
10
+ const MAX_UINT256 = (1n << 256n) - 1n;
11
+
12
+ const MIN_TICK = -887272;
13
+ const MAX_TICK = 887272;
14
+ const MIN_SQRT_RATIO = 4295128739n; // ~ TickMath.MIN_SQRT_RATIO
15
+ const MAX_SQRT_RATIO =
16
+ 1461446703485210103287273052203988822378723970342n; // ~ TickMath.MAX_SQRT_RATIO
17
+
18
+ function mulDiv(a, b, d) {
19
+ return (a * b) / d;
20
+ }
21
+ function mulDivRoundingUp(a, b, d) {
22
+ const prod = a * b;
23
+ const q = prod / d;
24
+ return prod % d === 0n ? q : q + 1n;
25
+ }
26
+
27
+ //////////////////////////// TickMath (v3 exact) //////////////////////////////
28
+
29
+ // getSqrtRatioAtTick — direct port of UniswapV3 TickMath.getSqrtRatioAtTick
30
+ // Returns Q64.96 sqrt price at the LOWER boundary of `tick`.
31
+ export function getSqrtRatioAtTick(tick) {
32
+ if (tick < MIN_TICK || tick > MAX_TICK) throw new Error("TICK_OUT_OF_RANGE");
33
+
34
+ let absTick = tick < 0 ? -tick : tick;
35
+
36
+ // 1.0001^(2^i) constants in Q128.128
37
+ let ratio =
38
+ (absTick & 0x1) !== 0
39
+ ? 0xfffcb933bd6fad37aa2d162d1a594001n
40
+ : 0x100000000000000000000000000000000n;
41
+ if ((absTick & 0x2) !== 0)
42
+ ratio = (ratio * 0xfff97272373d413259a46990580e213an) >> 128n;
43
+ if ((absTick & 0x4) !== 0)
44
+ ratio = (ratio * 0xfff2e50f5f656932ef12357cf3c7fdccn) >> 128n;
45
+ if ((absTick & 0x8) !== 0)
46
+ ratio = (ratio * 0xffe5caca7e10e4e61c3624eaa0941cd0n) >> 128n;
47
+ if ((absTick & 0x10) !== 0)
48
+ ratio = (ratio * 0xffcb9843d60f6159c9db58835c926644n) >> 128n;
49
+ if ((absTick & 0x20) !== 0)
50
+ ratio = (ratio * 0xff973b41fa98c081472e6896dfb254c0n) >> 128n;
51
+ if ((absTick & 0x40) !== 0)
52
+ ratio = (ratio * 0xff2ea16466c96a3843ec78b326b52861n) >> 128n;
53
+ if ((absTick & 0x80) !== 0)
54
+ ratio = (ratio * 0xfe5dee046a99a2a811c461f1969c3053n) >> 128n;
55
+ if ((absTick & 0x100) !== 0)
56
+ ratio = (ratio * 0xfcbe86c7900a88aedcffc83b479aa3a4n) >> 128n;
57
+ if ((absTick & 0x200) !== 0)
58
+ ratio = (ratio * 0xf987a7253ac413176f2b074cf7815e54n) >> 128n;
59
+ if ((absTick & 0x400) !== 0)
60
+ ratio = (ratio * 0xf3392b0822b70005940c7a398e4b70f3n) >> 128n;
61
+ if ((absTick & 0x800) !== 0)
62
+ ratio = (ratio * 0xe7159475a2c29b7443b29c7fa6e889d9n) >> 128n;
63
+ if ((absTick & 0x1000) !== 0)
64
+ ratio = (ratio * 0xd097f3bdfd2022b8845ad8f792aa5825n) >> 128n;
65
+ if ((absTick & 0x2000) !== 0)
66
+ ratio = (ratio * 0xa9f746462d870fdf8a65dc1f90e061e5n) >> 128n;
67
+ if ((absTick & 0x4000) !== 0)
68
+ ratio = (ratio * 0x70d869a156d2a1b890bb3df62baf32f7n) >> 128n;
69
+ if ((absTick & 0x8000) !== 0)
70
+ ratio = (ratio * 0x31be135f97d08fd981231505542fcfa6n) >> 128n;
71
+ if ((absTick & 0x10000) !== 0)
72
+ ratio = (ratio * 0x9aa508b5b7a84e1c677de54f3e99bc9n) >> 128n;
73
+ if ((absTick & 0x20000) !== 0)
74
+ ratio = (ratio * 0x5d6af8dedb81196699c329225ee604n) >> 128n;
75
+ if ((absTick & 0x40000) !== 0)
76
+ ratio = (ratio * 0x2216e584f5fa1ea926041bedfe98n) >> 128n;
77
+ if ((absTick & 0x80000) !== 0)
78
+ ratio = (ratio * 0x48a170391f7dc42444e8fa2n) >> 128n;
79
+
80
+ if (tick > 0) {
81
+ ratio = MAX_UINT256 / ratio;
82
+ }
83
+
84
+ // round up to guarantee that getTickAtSqrtRatio(sqrtRatioAtTick(tick)) == tick
85
+ const sqrtPriceX96 = (ratio >> 32n) + (ratio % Q32 === 0n ? 0n : 1n);
86
+ return sqrtPriceX96;
87
+ }
88
+
89
+ //////////////////////////// Amount delta helpers /////////////////////////////
90
+
91
+ // Token1 amount for price move from sqrtA -> sqrtB (A < B), no fee. Q64.96
92
+ // amount1 = L * (sqrtB - sqrtA) / Q96
93
+ function amount1DeltaUp(sqrtA, sqrtB, liquidity, roundUp = true) {
94
+ if (sqrtB < sqrtA) [sqrtA, sqrtB] = [sqrtB, sqrtA];
95
+ const num = liquidity * (sqrtB - sqrtA);
96
+ return roundUp ? mulDivRoundingUp(num, 1n, Q96) : mulDiv(num, 1n, Q96);
97
+ }
98
+
99
+ // Token0 amount for price move from sqrtA -> sqrtB (A < B), no fee.
100
+ // amount0 = L * (sqrtB - sqrtA) * Q96 / (sqrtB * sqrtA)
101
+ // For downward price move (sell), call with (lower, upper) = (target, current).
102
+ function amount0DeltaDown(sqrtA, sqrtB, liquidity, roundUp = true) {
103
+ if (sqrtB < sqrtA) [sqrtA, sqrtB] = [sqrtB, sqrtA];
104
+ const num = liquidity * (sqrtB - sqrtA) * Q96;
105
+ const den = sqrtB * sqrtA;
106
+ return roundUp ? mulDivRoundingUp(num, 1n, den) : mulDiv(num, 1n, den);
107
+ }
108
+
109
+ // Gross-up an input amount to include Uniswap fee (feePips in 1e6 units).
110
+ function grossUpForFee(amountIn, feePips) {
111
+ if (!feePips) return amountIn;
112
+ const feeDen = 1_000_000n;
113
+ const den = feeDen - BigInt(feePips);
114
+ return mulDivRoundingUp(amountIn, feeDen, den);
115
+ }
116
+
117
+ //////////////////////////// Utilities ////////////////////////////////////////
118
+
119
+ function toUSDFromRaw(amount, decimals, usd) {
120
+ if (!usd || usd <= 0) return 0;
121
+ const scale = 10n ** BigInt(Math.max(0, decimals));
122
+ const usdMicros = BigInt(Math.round(usd * 1e6));
123
+ const micros = (amount * usdMicros) / scale; // BigInt of USD microdollars
124
+ return Number(micros) / 1e6;
125
+ }
126
+
127
+ function clamp(val, lo, hi) {
128
+ return Math.max(lo, Math.min(hi, val));
129
+ }
130
+
131
+ // Binary search helpers on pre-sorted initialized ticks
132
+ function firstTickIndexGreaterThan(ticks, t) {
133
+ let lo = 0,
134
+ hi = ticks.length;
135
+ while (lo < hi) {
136
+ const mid = (lo + hi) >> 1;
137
+ if (ticks[mid].index <= t) lo = mid + 1;
138
+ else hi = mid;
139
+ }
140
+ return lo; // first index with ticks[i].index > t (may be ticks.length)
141
+ }
142
+ function lastTickIndexAtOrBelow(ticks, t) {
143
+ const i = firstTickIndexGreaterThan(ticks, t) - 1;
144
+ return i;
145
+ }
146
+
147
+ //////////////////////////// Core: integrate to targets ///////////////////////
148
+
149
+ // Convert a percent move (e.g., +2%) to an approximate tick delta.
150
+ // 1 tick ~= 0.01% in price -> but we use exact log(1.0001)
151
+ function percentToTickDelta(pct) {
152
+ const r = 1 + pct / 100; // price multiplier
153
+ const delta = Math.log(r) / Math.log(1.0001);
154
+ // round toward nearest integer (small effect; bucketed charts are tolerant)
155
+ return Math.max(1, Math.round(delta));
156
+ }
157
+
158
+ // Integrate upward (price ↑): token1 in to move sqrtPrice to target
159
+ function integrateUp(
160
+ sqrtStart,
161
+ currentTick,
162
+ Lstart,
163
+ targetTick,
164
+ feePips,
165
+ ticks
166
+ ) {
167
+ let s = sqrtStart;
168
+ let L = Lstart;
169
+ let tick = currentTick;
170
+
171
+ let i = firstTickIndexGreaterThan(ticks, tick); // first initialized tick above current
172
+ let targetSqrt = getSqrtRatioAtTick(clamp(targetTick, MIN_TICK, MAX_TICK));
173
+ let acc = 0n;
174
+
175
+ while (s < targetSqrt) {
176
+ const nextTick = i < 0 || i >= ticks.length ? null : ticks[i];
177
+ const sqrtAtNext = nextTick ? getSqrtRatioAtTick(nextTick.index) : MAX_SQRT_RATIO;
178
+ const bound = sqrtAtNext < targetSqrt ? sqrtAtNext : targetSqrt;
179
+
180
+ // token1 in over [s, bound]
181
+ const dy = amount1DeltaUp(s, bound, L, true);
182
+ const dyGross = grossUpForFee(dy, feePips);
183
+ acc += dyGross;
184
+ s = bound;
185
+
186
+ if (s === sqrtAtNext && nextTick) {
187
+ // crossing upward → add liquidityNet
188
+ L = L + nextTick.liquidityNet;
189
+ tick = nextTick.index;
190
+ i += 1;
191
+ } else {
192
+ break; // hit target
193
+ }
194
+ }
195
+
196
+ return { token1In: acc, finalSqrt: s, finalTick: tick, finalL: L };
197
+ }
198
+
199
+ // Integrate downward (price ↓): token0 in to move sqrtPrice to target
200
+ function integrateDown(
201
+ sqrtStart,
202
+ currentTick,
203
+ Lstart,
204
+ targetTick,
205
+ feePips,
206
+ ticks
207
+ ) {
208
+ let s = sqrtStart;
209
+ let L = Lstart;
210
+ let tick = currentTick;
211
+
212
+ let j = lastTickIndexAtOrBelow(ticks, tick); // initialized tick at/below current
213
+ let targetSqrt = getSqrtRatioAtTick(clamp(targetTick, MIN_TICK, MAX_TICK));
214
+ let acc = 0n;
215
+
216
+ while (s > targetSqrt) {
217
+ const prevTick = j >= 0 ? ticks[j] : null;
218
+ const sqrtAtPrev = prevTick ? getSqrtRatioAtTick(prevTick.index) : MIN_SQRT_RATIO;
219
+ const bound = sqrtAtPrev > targetSqrt ? sqrtAtPrev : targetSqrt;
220
+
221
+ // token0 in over [bound, s] (note lower, upper)
222
+ const dx = amount0DeltaDown(bound, s, L, true);
223
+ const dxGross = grossUpForFee(dx, feePips);
224
+ acc += dxGross;
225
+ s = bound;
226
+
227
+ if (s === sqrtAtPrev && prevTick) {
228
+ // crossing downward → subtract liquidityNet
229
+ L = L - prevTick.liquidityNet;
230
+ tick = prevTick.index - 1; // inside the lower tick now
231
+ j -= 1;
232
+ } else {
233
+ break; // hit target
234
+ }
235
+ }
236
+ return { token0In: acc, finalSqrt: s, finalTick: tick, finalL: L };
237
+ }
238
+
239
+ //////////////////////////// Public API //////////////////////////////////////
240
+
241
+ // Compute stacked depth bands for ±{1,2,5,10}% (defaults) at a snapshot.
242
+ export function computeMarketDepthBands(params) {
243
+ const {
244
+ sqrtPriceX96,
245
+ tick,
246
+ liquidity,
247
+ feePips = 0,
248
+ ticks,
249
+ token0,
250
+ token1,
251
+ percentBuckets = [1, 2, 5, 10],
252
+ } = params;
253
+
254
+ // safety: ensure ticks sorted asc
255
+ const sortedTicks = [...ticks].sort((a, b) => a.index - b.index);
256
+
257
+ // Build target ticks for each bucket
258
+ const upTargets = percentBuckets.map((p) =>
259
+ clamp(tick + percentToTickDelta(p), MIN_TICK, MAX_TICK)
260
+ );
261
+ const downTargets = percentBuckets.map((p) =>
262
+ clamp(tick - percentToTickDelta(p), MIN_TICK, MAX_TICK)
263
+ );
264
+
265
+ // Integrate cumulatively to each target
266
+ const buyCumToken1 = [];
267
+ const sellCumToken0 = [];
268
+
269
+ // Upward cumulative
270
+ let sU = sqrtPriceX96;
271
+ let tU = tick;
272
+ let LU = liquidity;
273
+ for (const tgt of upTargets) {
274
+ const { token1In, finalSqrt, finalTick, finalL } = integrateUp(
275
+ sU,
276
+ tU,
277
+ LU,
278
+ tgt,
279
+ feePips,
280
+ sortedTicks
281
+ );
282
+ const cum = (buyCumToken1.length ? buyCumToken1[buyCumToken1.length - 1] : 0n) + token1In;
283
+ buyCumToken1.push(cum);
284
+ sU = finalSqrt;
285
+ tU = finalTick;
286
+ LU = finalL;
287
+ }
288
+
289
+ // Downward cumulative
290
+ let sD = sqrtPriceX96;
291
+ let tD = tick;
292
+ let LD = liquidity;
293
+ for (const tgt of downTargets) {
294
+ const { token0In, finalSqrt, finalTick, finalL } = integrateDown(
295
+ sD,
296
+ tD,
297
+ LD,
298
+ tgt,
299
+ feePips,
300
+ sortedTicks
301
+ );
302
+ const cum = (sellCumToken0.length ? sellCumToken0[sellCumToken0.length - 1] : 0n) + token0In;
303
+ sellCumToken0.push(cum);
304
+ sD = finalSqrt;
305
+ tD = finalTick;
306
+ LD = finalL;
307
+ }
308
+
309
+ // Convert to USD notionals using input token for each side
310
+ const buyCumUSD = buyCumToken1.map((a) =>
311
+ toUSDFromRaw(a, token1.decimals, token1.usdPrice)
312
+ );
313
+ const sellCumUSD = sellCumToken0.map((a) =>
314
+ toUSDFromRaw(a, token0.decimals, token0.usdPrice)
315
+ );
316
+
317
+ // Stacked bands (diffs of cumulative)
318
+ const toBands = (cum) => [
319
+ cum[0],
320
+ cum[1] - cum[0],
321
+ cum[2] - cum[1],
322
+ cum[3] - cum[2],
323
+ ];
324
+
325
+ const buyBandsUSD = toBands(buyCumUSD);
326
+ const sellBandsUSD = toBands(sellCumUSD);
327
+
328
+ // Headline +/− 2%
329
+ const idx2 = percentBuckets.findIndex((p) => p === 2);
330
+ const depthPlus2USD = idx2 >= 0 ? buyCumUSD[idx2] : NaN;
331
+ const depthMinus2USD = idx2 >= 0 ? sellCumUSD[idx2] : NaN;
332
+
333
+ return {
334
+ buyCumUSD,
335
+ sellCumUSD,
336
+ buyBandsUSD,
337
+ sellBandsUSD,
338
+ depthPlus2USD,
339
+ depthMinus2USD,
340
+ buyCumToken1,
341
+ sellCumToken0,
342
+ };
343
+ }
344
+
345
+ export const DepthInternals = {
346
+ MIN_TICK,
347
+ MAX_TICK,
348
+ };
349
+
350
+ //////////////////////////// Price impact by size /////////////////////////////
351
+
352
+ function integrateUpByAmount(
353
+ sqrtStart,
354
+ currentTick,
355
+ Lstart,
356
+ budgetGross, // token1 gross in
357
+ feePips,
358
+ ticks
359
+ ) {
360
+ let s = sqrtStart;
361
+ let L = Lstart;
362
+ let tick = currentTick;
363
+ let i = firstTickIndexGreaterThan(ticks, tick);
364
+ const feeDen = 1_000_000n;
365
+ const den = feeDen - BigInt(feePips || 0);
366
+
367
+ while (budgetGross > 0n && s < MAX_SQRT_RATIO) {
368
+ const nextTick = i < 0 || i >= ticks.length ? null : ticks[i];
369
+ const sqrtAtNext = nextTick ? getSqrtRatioAtTick(nextTick.index) : MAX_SQRT_RATIO;
370
+
371
+ const dyNetToNext = amount1DeltaUp(s, sqrtAtNext, L, true);
372
+ const dyGrossToNext = grossUpForFee(dyNetToNext, feePips || 0);
373
+
374
+ if (budgetGross < dyGrossToNext) {
375
+ // Partial step within this segment. Compute net from gross, then delta sqrt.
376
+ const netIn = mulDiv(budgetGross, den, feeDen);
377
+ const deltaS = mulDivRoundingUp(netIn, Q96, L);
378
+ s = s + deltaS;
379
+ budgetGross = 0n;
380
+ break;
381
+ } else {
382
+ budgetGross -= dyGrossToNext;
383
+ s = sqrtAtNext;
384
+ if (nextTick) {
385
+ L = L + nextTick.liquidityNet;
386
+ tick = nextTick.index;
387
+ i += 1;
388
+ }
389
+ }
390
+ }
391
+ return { finalSqrt: s, finalTick: tick, finalL: L };
392
+ }
393
+
394
+ function integrateDownByAmount(
395
+ sqrtStart,
396
+ currentTick,
397
+ Lstart,
398
+ budgetGross, // token0 gross in
399
+ feePips,
400
+ ticks
401
+ ) {
402
+ let s = sqrtStart;
403
+ let L = Lstart;
404
+ let tick = currentTick;
405
+ let j = lastTickIndexAtOrBelow(ticks, tick);
406
+ const feeDen = 1_000_000n;
407
+ const den = feeDen - BigInt(feePips || 0);
408
+
409
+ while (budgetGross > 0n && s > MIN_SQRT_RATIO) {
410
+ const prevTick = j >= 0 ? ticks[j] : null;
411
+ const sqrtAtPrev = prevTick ? getSqrtRatioAtTick(prevTick.index) : MIN_SQRT_RATIO;
412
+
413
+ const dxNetToPrev = amount0DeltaDown(sqrtAtPrev, s, L, true);
414
+ const dxGrossToPrev = grossUpForFee(dxNetToPrev, feePips || 0);
415
+
416
+ if (budgetGross < dxGrossToPrev) {
417
+ // Partial step within this segment. Solve bound:
418
+ // bound = s * L*Q96 / (L*Q96 + netIn*s)
419
+ const netIn = mulDiv(budgetGross, den, feeDen);
420
+ const LQ = L * Q96;
421
+ const num = LQ * s;
422
+ const den2 = LQ + netIn * s;
423
+ const bound = den2 === 0n ? MIN_SQRT_RATIO : mulDiv(num, 1n, den2);
424
+ s = bound;
425
+ budgetGross = 0n;
426
+ break;
427
+ } else {
428
+ budgetGross -= dxGrossToPrev;
429
+ s = sqrtAtPrev;
430
+ if (prevTick) {
431
+ L = L - prevTick.liquidityNet;
432
+ tick = prevTick.index - 1;
433
+ j -= 1;
434
+ }
435
+ }
436
+ }
437
+ return { finalSqrt: s, finalTick: tick, finalL: L };
438
+ }
439
+
440
+ function priceImpactPercent(startSqrt, endSqrt) {
441
+ // percent = ((b^2 - a^2)/a^2) * 100 = ((b-a)(b+a)/a^2)*100
442
+ const a = startSqrt;
443
+ const b = endSqrt;
444
+ const scale = 1_000_000n; // 1e6 -> return percent with 6 decimals as float
445
+ const numRaw = (b - a) * (b + a) * 100n * scale;
446
+ const den = a * a;
447
+ // signed rounding toward nearest
448
+ let val;
449
+ if (numRaw >= 0n) val = (numRaw + den / 2n) / den;
450
+ else val = -((-numRaw + den / 2n) / den);
451
+ return Number(val) / Number(scale);
452
+ }
453
+
454
+ export function computePriceImpactsBySizes({
455
+ sqrtPriceX96,
456
+ tick,
457
+ liquidity,
458
+ feePips = 0,
459
+ ticks,
460
+ buySizesToken1, // bigint[] gross token1 (buy/up)
461
+ sellSizesToken0, // bigint[] gross token0 (sell/down)
462
+ }) {
463
+ const sortedTicks = [...ticks].sort((a, b) => a.index - b.index);
464
+
465
+ const buyPct = [];
466
+ for (const size of buySizesToken1) {
467
+ const { finalSqrt } = integrateUpByAmount(
468
+ sqrtPriceX96,
469
+ tick,
470
+ liquidity,
471
+ size,
472
+ feePips,
473
+ sortedTicks
474
+ );
475
+ buyPct.push(priceImpactPercent(sqrtPriceX96, finalSqrt));
476
+ }
477
+
478
+ const sellPct = [];
479
+ for (const size of sellSizesToken0) {
480
+ const { finalSqrt } = integrateDownByAmount(
481
+ sqrtPriceX96,
482
+ tick,
483
+ liquidity,
484
+ size,
485
+ feePips,
486
+ sortedTicks
487
+ );
488
+ sellPct.push(priceImpactPercent(sqrtPriceX96, finalSqrt));
489
+ }
490
+
491
+ return { buyPct, sellPct };
492
+ }
package/src/index.js ADDED
@@ -0,0 +1,6 @@
1
+ export {
2
+ computeMarketDepthBands,
3
+ computePriceImpactsBySizes,
4
+ getSqrtRatioAtTick,
5
+ DepthInternals,
6
+ } from './depth.js';
package/src/prices.js ADDED
@@ -0,0 +1,188 @@
1
+ import axios from 'axios';
2
+ import { createPublicClient, http } from 'viem';
3
+
4
+ function norm(addr) {
5
+ return addr.toLowerCase();
6
+ }
7
+
8
+ function dexSlugFromChainId(chainId) {
9
+ switch (Number(chainId)) {
10
+ case 1: return 'ethereum';
11
+ case 10: return 'optimism';
12
+ case 56: return 'bsc';
13
+ case 137: return 'polygon';
14
+ case 8453: return 'base';
15
+ case 42161: return 'arbitrum';
16
+ case 43114: return 'avalanche';
17
+ case 100: return 'gnosis';
18
+ case 324: return 'zksync';
19
+ case 1101: return 'polygon-zkevm';
20
+ default: return undefined;
21
+ }
22
+ }
23
+
24
+ export async function fetchPricesLlama(addresses) {
25
+ const uniq = Array.from(new Set(addresses.map(norm)));
26
+ if (!uniq.length) return {};
27
+ const coinsParam = uniq.map((a) => `ethereum:${a}`).join(',');
28
+ const url = `https://coins.llama.fi/prices/current/${coinsParam}`;
29
+ const res = await axios.get(url, { timeout: 8000, maxRedirects: 3 });
30
+ const coins = res.data?.coins || {};
31
+ const out = {};
32
+ for (const a of uniq) {
33
+ const key = `ethereum:${a}`;
34
+ const p = coins[key]?.price;
35
+ if (typeof p === 'number' && Number.isFinite(p)) out[a] = p;
36
+ }
37
+ return out;
38
+ }
39
+
40
+ export async function fetchPricesCoingecko(addresses, apiKey) {
41
+ const uniq = Array.from(new Set(addresses.map(norm)));
42
+ if (!uniq.length) return {};
43
+ const url = 'https://api.coingecko.com/api/v3/simple/token_price/ethereum';
44
+ const params = {
45
+ contract_addresses: uniq.join(','),
46
+ vs_currencies: 'usd',
47
+ };
48
+ const headers = apiKey ? { 'x-cg-pro-api-key': apiKey } : undefined;
49
+ const res = await axios.get(url, { params, headers, timeout: 8000, maxRedirects: 3 });
50
+ const body = res.data || {};
51
+ const out = {};
52
+ for (const a of uniq) {
53
+ const ent = body[a];
54
+ const p = ent?.usd;
55
+ if (typeof p === 'number' && Number.isFinite(p)) out[a] = p;
56
+ }
57
+ return out;
58
+ }
59
+
60
+ export async function fetchTokenUSDPrices({ addresses, source = 'auto', rpcUrl, chainId, debug = false, reserveLimit = 100 }) {
61
+ const uniq = Array.from(new Set(addresses.map(norm)));
62
+ const byAddress = {};
63
+ const coingeckoKey = process.env.COINGECKO_API_KEY;
64
+ const envRpc = rpcUrl || process.env.RPC_URL;
65
+
66
+ async function resolveChainId(rpcUrl) {
67
+ try {
68
+ if (!rpcUrl) return undefined;
69
+ const client = createPublicClient({ transport: http(rpcUrl) });
70
+ return await client.getChainId();
71
+ } catch (_) {
72
+ return undefined;
73
+ }
74
+ }
75
+
76
+ async function fetchPricesReserve({ addresses, chainId, limit = 100 }) {
77
+ if (!chainId) return {};
78
+ const url = `https://api.reserve.org/discover/dtf`;
79
+ const params = { chainId, limit };
80
+ const res = await axios.get(url, { params, timeout: 8000, maxRedirects: 3 });
81
+ const arr = Array.isArray(res.data) ? res.data : [];
82
+ const out = {};
83
+ const set = new Set(addresses.map(norm));
84
+ for (const ent of arr) {
85
+ const addr = norm(ent.address || '');
86
+ if (set.has(addr)) {
87
+ const p = Number(ent.price);
88
+ if (Number.isFinite(p)) out[addr] = p;
89
+ }
90
+ }
91
+ if (debug) {
92
+ // eslint-disable-next-line no-console
93
+ console.error(`[prices] reserve: chainId=${chainId} limit=${limit} returned=${arr.length} matched=${Object.keys(out).length}`);
94
+ }
95
+ return out;
96
+ }
97
+
98
+ async function fetchPricesDexscreener({ addresses, chainId }) {
99
+ if (!chainId) return {};
100
+ const slug = dexSlugFromChainId(chainId);
101
+ if (!slug) return {};
102
+ const out = {};
103
+ for (const addr of addresses.map(norm)) {
104
+ try {
105
+ const url = `https://api.dexscreener.com/token-pairs/v1/${slug}/${addr}`;
106
+ const res = await axios.get(url, { timeout: 8000, maxRedirects: 3 });
107
+ const arr = Array.isArray(res.data) ? res.data : [];
108
+ // choose entry where token is baseToken; pick highest liquidity.usd
109
+ let best = null;
110
+ for (const ent of arr) {
111
+ const base = norm(ent?.baseToken?.address || '');
112
+ if (base !== addr) continue;
113
+ const liq = Number(ent?.liquidity?.usd ?? 0);
114
+ if (!best || liq > (Number(best?.liquidity?.usd ?? 0))) best = ent;
115
+ }
116
+ if (best) {
117
+ const p = Number(best.priceUsd);
118
+ if (Number.isFinite(p)) out[addr] = p;
119
+ if (debug) {
120
+ // eslint-disable-next-line no-console
121
+ console.error(`[prices] dexscreener: addr=${addr} pairs=${arr.length} bestLiq=${best?.liquidity?.usd ?? 0} priceUsd=${best?.priceUsd ?? 'n/a'}`);
122
+ }
123
+ } else if (debug) {
124
+ // eslint-disable-next-line no-console
125
+ console.error(`[prices] dexscreener: addr=${addr} no baseToken match in ${arr.length} pairs`);
126
+ }
127
+ } catch (e) {
128
+ if (debug) {
129
+ // eslint-disable-next-line no-console
130
+ console.error(`[prices] dexscreener error for ${addr}: ${e?.message || e}`);
131
+ }
132
+ }
133
+ }
134
+ return out;
135
+ }
136
+
137
+ async function tryLlama() {
138
+ try {
139
+ const p = await fetchPricesLlama(uniq);
140
+ for (const k of Object.keys(p)) if (byAddress[k] == null) byAddress[k] = { price: p[k], source: 'llama' };
141
+ return Object.keys(p).length > 0;
142
+ } catch (_) { return false; }
143
+ }
144
+ async function tryReserve(chainId) {
145
+ try {
146
+ if (!chainId) return false;
147
+ const p = await fetchPricesReserve({ addresses: uniq, chainId, limit: reserveLimit });
148
+ for (const k of Object.keys(p)) if (byAddress[k] == null) byAddress[k] = { price: p[k], source: 'reserve' };
149
+ return Object.keys(p).length > 0;
150
+ } catch (_) { return false; }
151
+ }
152
+ async function tryDex(chainId) {
153
+ try {
154
+ if (!chainId) return false;
155
+ const p = await fetchPricesDexscreener({ addresses: uniq, chainId });
156
+ for (const k of Object.keys(p)) if (byAddress[k] == null) byAddress[k] = { price: p[k], source: 'dexscreener' };
157
+ return Object.keys(p).length > 0;
158
+ } catch (_) { return false; }
159
+ }
160
+ async function tryCG() {
161
+ try {
162
+ const p = await fetchPricesCoingecko(uniq, coingeckoKey);
163
+ for (const k of Object.keys(p)) if (byAddress[k] == null) byAddress[k] = { price: p[k], source: 'coingecko' };
164
+ return Object.keys(p).length > 0;
165
+ } catch (_) { return false; }
166
+ }
167
+
168
+ // Resolve chainId if needed for reserve/dexscreener
169
+ let cid = chainId;
170
+ if (!cid && (source === 'reserve' || source === 'dexscreener' || source === 'auto')) cid = await resolveChainId(envRpc);
171
+ if (debug) {
172
+ // eslint-disable-next-line no-console
173
+ console.error(`[prices] source=${source} rpc=${envRpc ? 'yes' : 'no'} chainId=${cid ?? 'n/a'} addresses=${uniq.length}`);
174
+ }
175
+
176
+ if (source === 'reserve') await tryReserve(cid);
177
+ else if (source === 'dexscreener') await tryDex(cid);
178
+ else if (source === 'llama') await tryLlama();
179
+ else if (source === 'coingecko') await tryCG();
180
+ else {
181
+ // auto: try reserve (if chainId), then dexscreener (if chainId), then llama, then CG
182
+ await tryReserve(cid);
183
+ await tryDex(cid);
184
+ await tryLlama();
185
+ await tryCG();
186
+ }
187
+ return { byAddress };
188
+ }