@steerprotocol/liquidity-meter 2.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/README.md CHANGED
@@ -22,6 +22,15 @@ Install
22
22
  - As a package dependency:
23
23
  - `npm install @steerprotocol/liquidity-meter`
24
24
 
25
+ Release Workflow
26
+
27
+ - Releases are automated with `semantic-release` on pushes to `master` or `main`.
28
+ - Commit messages are validated with `commitlint` using the Conventional Commits spec.
29
+ - CI runs unit tests on Node.js 20 and 22, then enforces coverage thresholds on Node.js 22.
30
+ - Required repository secrets:
31
+ - `NPM_TOKEN` for npm publishing.
32
+ - GitHub releases use the default `GITHUB_TOKEN` provided by Actions.
33
+
25
34
  Library Usage
26
35
 
27
36
  ```js
@@ -4,12 +4,10 @@
4
4
 
5
5
  import fs from 'node:fs/promises';
6
6
  import path from 'node:path';
7
- import { execFile as _execFile } from 'node:child_process';
8
- import { promisify } from 'node:util';
9
- const execFile = promisify(_execFile);
10
- import { parseAbi, getAddress } from 'viem';
7
+ import { createPublicClient, parseAbi, getAddress, http as viemHttp } from 'viem';
11
8
  import {
12
9
  analyzePool,
10
+ findBlockNumbersForTimestamps,
13
11
  formatTimestampUTC,
14
12
  parseDurationArg,
15
13
  parsePercentBuckets,
@@ -263,9 +261,7 @@ async function runBatch(args) {
263
261
  if (!looksLikeAddress(t0 || '') || !looksLikeAddress(t1 || '') || !feeNum) return undefined;
264
262
  if (!rpcUrl) return undefined;
265
263
 
266
- const { createMemoryClient, http } = await import('tevm');
267
- const client = createMemoryClient({ fork: { transport: http(rpcUrl), blockTag: 'latest' }, loggingLevel: 'error' });
268
- if (client.tevmReady) await client.tevmReady();
264
+ const client = createPublicClient({ transport: viemHttp(rpcUrl) });
269
265
  try {
270
266
  const pool = await client.readContract({ address: UNISWAP_V3_FACTORY, abi: FACTORY_ABI, functionName: 'getPool', args: [getAddress(t0), getAddress(t1), feeNum] });
271
267
  if (pool && pool !== '0x0000000000000000000000000000000000000000') return pool;
@@ -275,27 +271,6 @@ async function runBatch(args) {
275
271
  return undefined;
276
272
  }
277
273
 
278
- // base args to pass through from CLI
279
- const base = [];
280
- if (args.percent) base.push('--percent', String(args.percent));
281
- if (args.source) base.push('--source', String(args.source));
282
- if (args.rpc) base.push('--rpc', String(args.rpc));
283
- if (args.subgraph) base.push('--subgraph', String(args.subgraph));
284
- if (Number.isFinite(args.token0USD)) base.push('--token0-usd', String(args.token0USD));
285
- if (Number.isFinite(args.token1USD)) base.push('--token1-usd', String(args.token1USD));
286
- if (Number.isFinite(args.assumeStable)) base.push('--assume-stable', String(args.assumeStable));
287
- if (args.usdSizes) base.push('--usd-sizes', String(args.usdSizes));
288
- if (args.prices) base.push('--prices', String(args.prices));
289
- if (args.reserveLimit) base.push('--reserve-limit', String(args.reserveLimit));
290
- if (args.json) base.push('--json');
291
- if (args.debug) base.push('--debug');
292
- if (args.block) base.push('--block', String(args.block));
293
- if (args.timestamp) base.push('--timestamp', String(args.timestamp));
294
- if (args.owner) base.push('--owner', String(args.owner));
295
- if (Number.isFinite(args.withdrawPct)) base.push('--withdraw-pct', String(args.withdrawPct));
296
- if (args.withdrawShares) base.push('--withdraw-shares', String(args.withdrawShares));
297
-
298
- const scriptPath = process.argv[1];
299
274
  for (let idx = 0; idx < rows.length; idx++) {
300
275
  const row = rows[idx];
301
276
  let pool = pick(row, ['pool', 'address', 'pool_address', 'id', 'Pool', 'Address', 'contract_address', 'contract address', 'pair_address', 'pair address']);
@@ -316,36 +291,29 @@ async function runBatch(args) {
316
291
  continue;
317
292
  }
318
293
 
319
- const per = [...base];
320
- const rpcs = pick(row, ['rpc', 'RPC', 'rpc_url', 'RPC_URL']);
321
- if (rpcs) per.push('--rpc', rpcs);
322
- const source = pick(row, ['source', 'Source']);
323
- if (source) per.push('--source', source);
324
- const percent = pick(row, ['percent', 'Percents', 'buckets']);
325
- if (percent) per.push('--percent', percent);
326
- const blockOverride = pick(row, ['block', 'Block', 'block_number', 'block number']);
327
- if (blockOverride) per.push('--block', blockOverride);
328
- const tsOverride = pick(row, ['timestamp', 'Timestamp', 'time', 'Time']);
329
- if (tsOverride) per.push('--timestamp', tsOverride);
330
- const token0USD = pick(row, ['token0_usd', 'token0-usd', 'token0USD']);
331
- const token1USD = pick(row, ['token1_usd', 'token1-usd', 'token1USD']);
332
- if (token0USD) per.push('--token0-usd', String(token0USD));
333
- if (token1USD) per.push('--token1-usd', String(token1USD));
334
- const usdSizes = pick(row, ['usd_sizes', 'usd-sizes', 'usdSizes']);
335
- if (usdSizes) per.push('--usd-sizes', usdSizes);
336
- const assumeStable = pick(row, ['assume_stable', 'assume-stable', 'assumeStable']);
337
- if (assumeStable != null) per.push('--assume-stable', String(assumeStable));
338
- const prices = pick(row, ['prices', 'Prices']);
339
- if (prices) per.push('--prices', String(prices));
340
-
341
- const vault = pick(row, ['vault', 'Vault', 'vault_address', 'VaultAddress', 'vault address', 'strategy']);
342
- if (vault) per.push('--vault', vault);
343
- const owner = pick(row, ['owner', 'Owner', 'owner_address', 'owner address', 'withdraw_owner', 'withdraw owner', 'account', 'user', 'recipient', 'to']);
344
- if (owner) per.push('--owner', owner);
345
- const wpct = pick(row, ['withdraw_pct', 'withdraw-pct', 'withdraw percent', 'withdraw_percent', 'withdrawPercentage', 'withdrawPercent', 'pct_withdraw', 'percent_withdraw']);
346
- if (wpct) per.push('--withdraw-pct', String(wpct));
347
- const wshares = pick(row, ['withdraw_shares', 'withdraw-shares', 'withdraw shares', 'withdrawShares', 'shares_to_withdraw', 'withdraw_share_amount']);
348
- if (wshares) per.push('--withdraw-shares', String(wshares));
294
+ const rowArgs = {
295
+ ...args,
296
+ pool,
297
+ rpc: pick(row, ['rpc', 'RPC', 'rpc_url', 'RPC_URL']) || args.rpc,
298
+ source: pick(row, ['source', 'Source']) || args.source,
299
+ percent: pick(row, ['percent', 'Percents', 'buckets']) || args.percent,
300
+ block: pick(row, ['block', 'Block', 'block_number', 'block number']) || args.block,
301
+ timestamp: pick(row, ['timestamp', 'Timestamp', 'time', 'Time']) || args.timestamp,
302
+ token0USD: pick(row, ['token0_usd', 'token0-usd', 'token0USD']) != null ? Number(pick(row, ['token0_usd', 'token0-usd', 'token0USD'])) : args.token0USD,
303
+ token1USD: pick(row, ['token1_usd', 'token1-usd', 'token1USD']) != null ? Number(pick(row, ['token1_usd', 'token1-usd', 'token1USD'])) : args.token1USD,
304
+ usdSizes: pick(row, ['usd_sizes', 'usd-sizes', 'usdSizes']) || args.usdSizes,
305
+ assumeStable: pick(row, ['assume_stable', 'assume-stable', 'assumeStable']) != null ? Number(pick(row, ['assume_stable', 'assume-stable', 'assumeStable'])) : args.assumeStable,
306
+ prices: pick(row, ['prices', 'Prices']) || args.prices,
307
+ vault: pick(row, ['vault', 'Vault', 'vault_address', 'VaultAddress', 'vault address', 'strategy']) || args.vault,
308
+ owner: pick(row, ['owner', 'Owner', 'owner_address', 'owner address', 'withdraw_owner', 'withdraw owner', 'account', 'user', 'recipient', 'to']) || args.owner,
309
+ withdrawPct: pick(row, ['withdraw_pct', 'withdraw-pct', 'withdraw percent', 'withdraw_percent', 'withdrawPercentage', 'withdrawPercent', 'pct_withdraw', 'percent_withdraw']) != null ? Number(pick(row, ['withdraw_pct', 'withdraw-pct', 'withdraw percent', 'withdraw_percent', 'withdrawPercentage', 'withdrawPercent', 'pct_withdraw', 'percent_withdraw'])) : args.withdrawPct,
310
+ withdrawShares: pick(row, ['withdraw_shares', 'withdraw-shares', 'withdraw shares', 'withdrawShares', 'shares_to_withdraw', 'withdraw_share_amount']) || args.withdrawShares,
311
+ };
312
+
313
+ const wpct = rowArgs.withdrawPct;
314
+ const wshares = rowArgs.withdrawShares;
315
+ const vault = rowArgs.vault;
316
+ const owner = rowArgs.owner;
349
317
 
350
318
  if (args.debug) {
351
319
  const dbg = [];
@@ -356,20 +324,14 @@ async function runBatch(args) {
356
324
  if (dbg.length) console.error(`[batch] row ${idx + 1} overrides: ${dbg.join(' ')}`);
357
325
  }
358
326
 
359
- const argsArr = [scriptPath, '--pool', pool, ...per];
360
327
  try {
361
- const { stdout, stderr } = await execFile(process.execPath, argsArr, { env: process.env, maxBuffer: 5 * 1024 * 1024 });
362
328
  const baseName = `${pool.toLowerCase()}`;
363
329
  const file = path.join(outdir, `${baseName}.${args.json ? 'json' : 'txt'}`);
364
- const cleaned = cleanReportOutput(stdout);
365
- await fs.writeFile(file, cleaned, 'utf8');
366
- if (args.debug && stderr) {
367
- // Forward child's debug logs to parent stderr
368
- process.stderr.write(stderr);
369
- }
330
+ const output = await runSingleInvocation(rowArgs);
331
+ await fs.writeFile(file, output, 'utf8');
370
332
  console.log(`Wrote: ${file}`);
371
333
  } catch (err) {
372
- const errMsg = String(err?.stderr || err?.stdout || err?.message || err);
334
+ const errMsg = String(err?.stack || err?.message || err);
373
335
  const errFile = path.join(outdir, `error-${pool.toLowerCase()}.txt`);
374
336
  await fs.writeFile(errFile, errMsg, 'utf8');
375
337
  console.error(`Failed row ${idx + 1} (${pool}): ${err?.message || err}`);
@@ -378,41 +340,6 @@ async function runBatch(args) {
378
340
  }
379
341
  }
380
342
 
381
- function parseChildJsonOutput(raw) {
382
- // First, strip known noisy lines from stdout (e.g., tevm logs, hex chainId)
383
- const cleaned = cleanReportOutput(String(raw ?? ''));
384
- const trimmed = cleaned.trim();
385
- if (!trimmed) return null;
386
- try {
387
- return JSON.parse(trimmed);
388
- } catch (err) {
389
- // Try to recover by locating a likely JSON block. Prefer the last complete
390
- // object/array, since noise often appears before the payload.
391
- const lastObj = trimmed.lastIndexOf('{');
392
- const lastArr = trimmed.lastIndexOf('[');
393
- const start = Math.max(lastObj, lastArr);
394
- if (start >= 0) {
395
- const endChar = trimmed[start] === '[' ? ']' : '}';
396
- const end = trimmed.lastIndexOf(endChar);
397
- if (end > start) {
398
- const slice = trimmed.slice(start, end + 1).trim();
399
- if (slice) {
400
- try {
401
- return JSON.parse(slice);
402
- } catch (_) {
403
- // fall through to error reporting below
404
- }
405
- }
406
- }
407
- }
408
- const snippet = trimmed.length > 240 ? `${trimmed.slice(0, 240)}…` : trimmed;
409
- const message = `${err?.message || err}. Child output: ${snippet}`;
410
- const outErr = err instanceof Error ? err : new Error(String(message));
411
- if (outErr instanceof Error) outErr.message = message;
412
- throw outErr;
413
- }
414
- }
415
-
416
343
  async function runTimeSeries(args) {
417
344
  if (!args.pool) {
418
345
  console.error('Time-series mode requires --pool');
@@ -511,6 +438,11 @@ async function runTimeSeries(args) {
511
438
  console.error('No timestamps resolved for time-series mode');
512
439
  process.exit(1);
513
440
  }
441
+ const resolvedBlocks = await findBlockNumbersForTimestamps({
442
+ rpcUrl: args.rpc,
443
+ timestamps,
444
+ debug: !!args.debug,
445
+ });
514
446
 
515
447
  if (!args.json) {
516
448
  const startIso = formatTimestampUTC(BigInt(tsStrings[0]));
@@ -519,60 +451,33 @@ async function runTimeSeries(args) {
519
451
  console.log(`Time-series: ${tsStrings.length} snapshots from ${startIso || tsStrings[0]} to ${endIso || tsStrings[tsStrings.length - 1]} every ~${intervalHuman}`);
520
452
  }
521
453
 
522
- const base = [];
523
- if (args.percent) base.push('--percent', String(args.percent));
524
- if (args.source) base.push('--source', String(args.source));
525
- if (args.rpc) base.push('--rpc', String(args.rpc));
526
- if (args.subgraph) base.push('--subgraph', String(args.subgraph));
527
- if (Number.isFinite(args.token0USD)) base.push('--token0-usd', String(args.token0USD));
528
- if (Number.isFinite(args.token1USD)) base.push('--token1-usd', String(args.token1USD));
529
- if (Number.isFinite(args.assumeStable)) base.push('--assume-stable', String(args.assumeStable));
530
- if (args.usdSizes) base.push('--usd-sizes', String(args.usdSizes));
531
- if (args.prices) base.push('--prices', String(args.prices));
532
- if (args.reserveLimit) base.push('--reserve-limit', String(args.reserveLimit));
533
- if (args.json) base.push('--json');
534
- if (args.debug) base.push('--debug');
535
- if (args.owner) base.push('--owner', String(args.owner));
536
- if (Number.isFinite(args.withdrawPct)) base.push('--withdraw-pct', String(args.withdrawPct));
537
- if (args.withdrawShares) base.push('--withdraw-shares', String(args.withdrawShares));
538
- if (args.vault) base.push('--vault', String(args.vault));
539
- if (args.outdir) base.push('--outdir', String(args.outdir));
540
-
541
- const scriptPath = process.argv[1];
542
454
  const jsonResults = [];
543
455
  for (let i = 0; i < tsStrings.length; i++) {
544
456
  const tsStr = tsStrings[i];
545
- const perArgs = [scriptPath, '--pool', args.pool, '--timestamp', tsStr, ...base];
457
+ const pointArgs = {
458
+ ...args,
459
+ timestamp: undefined,
460
+ block: resolvedBlocks[i].toString(),
461
+ };
546
462
  const label = formatTimestampUTC(BigInt(tsStr)) || tsStr;
547
463
  if (!args.json) {
548
464
  console.log(`\n=== ${label} (unix ${tsStr}) ===`);
549
465
  }
550
466
  try {
551
- const { stdout, stderr } = await execFile(process.execPath, perArgs, { env: process.env, maxBuffer: 5 * 1024 * 1024 });
467
+ const output = await runSingleInvocation(pointArgs);
552
468
  if (args.json) {
553
- const text = stdout.trim();
469
+ const text = output.trim();
554
470
  if (text) {
555
- try {
556
- jsonResults.push(parseChildJsonOutput(text));
557
- } catch (err) {
558
- if (args.debug && stderr) process.stderr.write(stderr);
559
- console.error(`Failed to parse JSON for timestamp ${tsStr}: ${err?.message || err}`);
560
- console.error('Raw child stdout:');
561
- console.error(text);
562
- process.exit(1);
563
- }
471
+ jsonResults.push(JSON.parse(text));
564
472
  } else {
565
473
  jsonResults.push(null);
566
474
  }
567
475
  } else {
568
- process.stdout.write(stdout);
569
- if (!stdout.endsWith('\n')) console.log('');
476
+ process.stdout.write(output);
477
+ if (!output.endsWith('\n')) console.log('');
570
478
  }
571
- if (args.debug && stderr) process.stderr.write(stderr);
572
479
  } catch (err) {
573
480
  console.error(`Time-series run failed for timestamp ${tsStr}: ${err?.message || err}`);
574
- const errText = err?.stderr || err?.stdout;
575
- if (errText) console.error(String(errText).trim());
576
481
  process.exit(1);
577
482
  }
578
483
  }
@@ -582,66 +487,29 @@ async function runTimeSeries(args) {
582
487
  }
583
488
  }
584
489
 
585
- // Strip noisy logs (e.g., TEVM warnings) from report files while preserving CLI output
586
- function cleanReportOutput(s) {
490
+ function captureConsoleOutput(run) {
491
+ const lines = [];
492
+ const original = console.log;
493
+ console.log = (...args) => {
494
+ lines.push(args.join(' '));
495
+ };
587
496
  try {
588
- const lines = String(s || '').split(/\r?\n/);
589
- const out = [];
590
- for (const line of lines) {
591
- if (!line) { out.push(line); continue; }
592
- // Filter known noisy patterns
593
- if (line.includes('"name":"@tevm/')) continue; // TEVM structured logs
594
- if (/\bEIP-7702\b/i.test(line)) continue; // EIP-7702 warning block
595
- if (/^\s*\{\s*chainId\s*:/i.test(line)) continue; // stray chainId object
596
- if (/^\s*0x[0-9a-fA-F]+\s*$/.test(line)) continue; // bare hex line
597
- out.push(line);
598
- }
599
- // Trim leading empty lines left by filtering
600
- while (out.length && out[0].trim() === '') out.shift();
601
- return out.join('\n');
602
- } catch (_) {
603
- return s;
497
+ run();
498
+ } finally {
499
+ console.log = original;
604
500
  }
501
+ return lines.length ? `${lines.join('\n')}\n` : '';
605
502
  }
606
503
 
607
- async function main() {
608
- const args = parseArgs(process.argv);
609
- if (args.version) {
610
- console.log('liquidity-depth 0.1.0');
611
- return;
612
- }
613
- if (args.help || (!args.pool && !args.csv)) {
614
- printHelp();
615
- if (!args.pool && !args.csv) process.exitCode = 1;
616
- return;
617
- }
618
-
619
- if (args.csv) {
620
- await runBatch(args);
621
- return;
622
- }
623
-
624
- const wantsTimeSeries =
625
- args.timeStart != null ||
626
- args.timeEnd != null ||
627
- args.timeInterval != null ||
628
- args.timeWindow != null ||
629
- args.timeCount != null;
630
- if (wantsTimeSeries) {
631
- await runTimeSeries(args);
632
- return;
633
- }
634
-
504
+ async function executeSingle(args) {
635
505
  let percentBuckets;
636
506
  try {
637
507
  percentBuckets = parsePercentBuckets(args.percent);
638
508
  } catch (e) {
639
- console.error(`Invalid --percent value: ${e?.message || e}`);
640
- process.exit(1);
509
+ throw new Error(`Invalid --percent value: ${e?.message || e}`);
641
510
  }
642
511
  if (!percentBuckets.length) {
643
- console.error('No valid percent buckets parsed.');
644
- process.exit(1);
512
+ throw new Error('No valid percent buckets parsed.');
645
513
  }
646
514
 
647
515
  const wantsWithdraw =
@@ -650,8 +518,7 @@ async function main() {
650
518
  let sharedBlockTag;
651
519
  if (wantsWithdraw) {
652
520
  if (!args.rpc) {
653
- console.error('Missing --rpc (or RPC_URL env) for tevm source.');
654
- process.exit(1);
521
+ throw new Error('Missing --rpc (or RPC_URL env) for tevm source.');
655
522
  }
656
523
  const blockContext = await resolveAnalysisBlockContext({
657
524
  block: args.block,
@@ -667,36 +534,24 @@ async function main() {
667
534
  }
668
535
 
669
536
  let before;
670
- try {
671
- before = await analyzePool({
672
- poolAddress: args.pool,
673
- percentBuckets,
674
- source: args.source,
675
- rpcUrl: args.rpc,
676
- subgraphUrl: args.subgraph,
677
- token0USD: args.token0USD,
678
- token1USD: args.token1USD,
679
- assumeStable: args.assumeStable,
680
- usdSizes: args.usdSizes,
681
- prices: args.prices,
682
- reserveLimit: args.reserveLimit,
683
- block: args.block,
684
- timestamp: args.timestamp,
685
- debug: args.debug,
686
- client: sharedClient,
687
- blockTag: sharedBlockTag,
688
- });
689
- } catch (e) {
690
- if (
691
- args.timestamp != null &&
692
- args.rpc == null &&
693
- String(e?.message || '').includes('--timestamp requires --rpc')
694
- ) {
695
- console.error('--timestamp requires --rpc (or RPC_URL env) to resolve block number');
696
- process.exit(1);
697
- }
698
- throw e;
699
- }
537
+ before = await analyzePool({
538
+ poolAddress: args.pool,
539
+ percentBuckets,
540
+ source: args.source,
541
+ rpcUrl: args.rpc,
542
+ subgraphUrl: args.subgraph,
543
+ token0USD: args.token0USD,
544
+ token1USD: args.token1USD,
545
+ assumeStable: args.assumeStable,
546
+ usdSizes: args.usdSizes,
547
+ prices: args.prices,
548
+ reserveLimit: args.reserveLimit,
549
+ block: args.block,
550
+ timestamp: args.timestamp,
551
+ debug: args.debug,
552
+ client: sharedClient,
553
+ blockTag: sharedBlockTag,
554
+ });
700
555
 
701
556
  let after = null;
702
557
  if (before.usedSource === 'tevm' && wantsWithdraw) {
@@ -734,6 +589,10 @@ async function main() {
734
589
  }
735
590
  }
736
591
 
592
+ return { before, after };
593
+ }
594
+
595
+ function renderSingle(args, before, after) {
737
596
  if (args.json) {
738
597
  const output = after
739
598
  ? {
@@ -741,15 +600,64 @@ async function main() {
741
600
  after: serializeAnalysis(args, after),
742
601
  }
743
602
  : serializeAnalysis(args, before);
744
- console.log(JSON.stringify(output, null, 2));
603
+ return `${JSON.stringify(output, null, 2)}\n`;
604
+ }
605
+ return captureConsoleOutput(() => {
606
+ printAnalysisSummary(args, before);
607
+ if (after) {
608
+ console.log('');
609
+ console.log('--- After Withdraw ---');
610
+ printAnalysisSummary(args, after);
611
+ }
612
+ });
613
+ }
614
+
615
+ async function runSingleInvocation(args) {
616
+ const { before, after } = await executeSingle(args);
617
+ return renderSingle(args, before, after);
618
+ }
619
+
620
+ async function main() {
621
+ const args = parseArgs(process.argv);
622
+ if (args.version) {
623
+ console.log('liquidity-depth 0.1.0');
624
+ return;
625
+ }
626
+ if (args.help || (!args.pool && !args.csv)) {
627
+ printHelp();
628
+ if (!args.pool && !args.csv) process.exitCode = 1;
745
629
  return;
746
630
  }
747
631
 
748
- printAnalysisSummary(args, before);
749
- if (after) {
750
- console.log('');
751
- console.log('--- After Withdraw ---');
752
- printAnalysisSummary(args, after);
632
+ if (args.csv) {
633
+ await runBatch(args);
634
+ return;
635
+ }
636
+
637
+ const wantsTimeSeries =
638
+ args.timeStart != null ||
639
+ args.timeEnd != null ||
640
+ args.timeInterval != null ||
641
+ args.timeWindow != null ||
642
+ args.timeCount != null;
643
+ if (wantsTimeSeries) {
644
+ await runTimeSeries(args);
645
+ return;
646
+ }
647
+
648
+ try {
649
+ const output = await runSingleInvocation(args);
650
+ process.stdout.write(output);
651
+ } catch (e) {
652
+ if (
653
+ args.timestamp != null &&
654
+ args.rpc == null &&
655
+ String(e?.message || '').includes('--timestamp requires --rpc')
656
+ ) {
657
+ console.error('--timestamp requires --rpc (or RPC_URL env) to resolve block number');
658
+ process.exit(1);
659
+ }
660
+ throw e;
753
661
  }
754
662
  }
755
663
 
package/package.json CHANGED
@@ -1,8 +1,12 @@
1
1
  {
2
2
  "name": "@steerprotocol/liquidity-meter",
3
- "version": "2.0.0",
3
+ "version": "2.0.4",
4
4
  "private": false,
5
5
  "description": "CLI to compute Uniswap v3 market depth bands using real on-chain/subgraph data",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/SteerProtocol/liquidity-meter.git"
9
+ },
6
10
  "type": "module",
7
11
  "main": "./src/index.js",
8
12
  "exports": {
@@ -17,6 +21,9 @@
17
21
  "README.md",
18
22
  "templates"
19
23
  ],
24
+ "publishConfig": {
25
+ "access": "public"
26
+ },
20
27
  "bin": {
21
28
  "liquidity-depth": "bin/liquidity-depth.js"
22
29
  },
@@ -25,7 +32,19 @@
25
32
  },
26
33
  "scripts": {
27
34
  "start": "node bin/liquidity-depth.js --help",
28
- "test": "node --test"
35
+ "test": "node --test",
36
+ "test:coverage": "node ./scripts/check-coverage.mjs",
37
+ "lint:commits": "commitlint --from HEAD~1 --to HEAD",
38
+ "release": "semantic-release"
39
+ },
40
+ "devDependencies": {
41
+ "@commitlint/cli": "20.5.0",
42
+ "@commitlint/config-conventional": "20.5.0",
43
+ "@semantic-release/commit-analyzer": "13.0.1",
44
+ "@semantic-release/github": "11.0.6",
45
+ "@semantic-release/npm": "13.1.3",
46
+ "@semantic-release/release-notes-generator": "14.1.0",
47
+ "semantic-release": "25.0.3"
29
48
  },
30
49
  "dependencies": {
31
50
  "axios": "1.7.7",
package/src/api.js CHANGED
@@ -5,6 +5,19 @@ import {
5
5
  } from './depth.js';
6
6
  import { fetchPoolSnapshot as fetchFromSubgraph } from './uniswapv3-subgraph.js';
7
7
 
8
+ const chainIdByRpcUrl = new Map();
9
+ const publicClientByRpcUrl = new Map();
10
+
11
+ function getCachedPublicClient(rpcUrl) {
12
+ if (!rpcUrl) return null;
13
+ let client = publicClientByRpcUrl.get(rpcUrl);
14
+ if (!client) {
15
+ client = createPublicClient({ transport: viemHttp(rpcUrl) });
16
+ publicClientByRpcUrl.set(rpcUrl, client);
17
+ }
18
+ return client;
19
+ }
20
+
8
21
  export function parsePercentBuckets(input = '1,2,5,10') {
9
22
  if (Array.isArray(input)) {
10
23
  return input
@@ -101,28 +114,44 @@ export async function findBlockNumberForTimestamp({
101
114
  rpcUrl,
102
115
  targetTimestampSec,
103
116
  debug = false,
117
+ client,
118
+ bounds,
119
+ blockCache,
104
120
  }) {
105
121
  if (!rpcUrl) throw new Error('RPC URL required to resolve timestamp');
106
- const client = createPublicClient({ transport: viemHttp(rpcUrl) });
107
- const latestNumber = await client.getBlockNumber();
108
- const latestBlock = await client.getBlock({ blockNumber: latestNumber });
122
+ const resolvedClient = client || getCachedPublicClient(rpcUrl);
123
+ const cache = blockCache || new Map();
124
+ const readBlock = async (blockNumber) => {
125
+ const key = blockNumber.toString();
126
+ if (cache.has(key)) return cache.get(key);
127
+ const block = await resolvedClient.getBlock({ blockNumber });
128
+ cache.set(key, block);
129
+ return block;
130
+ };
131
+ const latestNumber = bounds?.latestNumber ?? await resolvedClient.getBlockNumber();
132
+ const latestBlock = bounds?.latestBlock ?? await readBlock(latestNumber);
109
133
  if (!latestBlock || latestBlock.timestamp == null) {
110
134
  throw new Error('Latest block did not include a timestamp');
111
135
  }
112
136
  const latestTs = latestBlock.timestamp;
113
- const genesis = await client.getBlock({ blockNumber: 0n });
137
+ const genesis = bounds?.genesisBlock ?? await readBlock(0n);
114
138
  if (!genesis || genesis.timestamp == null) {
115
139
  throw new Error('Genesis block did not include a timestamp');
116
140
  }
117
141
  if (targetTimestampSec <= genesis.timestamp) return 0n;
118
142
  if (targetTimestampSec >= latestTs) return latestBlock.number;
119
143
 
120
- let lo = 0n;
144
+ let lo = bounds?.loBlock?.number ?? 0n;
145
+ let loTimestamp = bounds?.loBlock?.timestamp ?? genesis.timestamp;
146
+ if (loTimestamp > targetTimestampSec) {
147
+ lo = 0n;
148
+ loTimestamp = genesis.timestamp;
149
+ }
121
150
  let hi = latestBlock.number;
122
- let best = genesis.number ?? 0n;
151
+ let best = loTimestamp <= targetTimestampSec ? lo : (genesis.number ?? 0n);
123
152
  for (let iter = 0; iter < 80 && lo <= hi; iter++) {
124
153
  const mid = (lo + hi) >> 1n;
125
- const block = await client.getBlock({ blockNumber: mid });
154
+ const block = await readBlock(mid);
126
155
  if (!block || block.timestamp == null) {
127
156
  hi = mid - 1n;
128
157
  continue;
@@ -144,12 +173,54 @@ export async function findBlockNumberForTimestamp({
144
173
  return best;
145
174
  }
146
175
 
176
+ export async function findBlockNumbersForTimestamps({
177
+ rpcUrl,
178
+ timestamps,
179
+ debug = false,
180
+ client,
181
+ }) {
182
+ if (!timestamps?.length) return [];
183
+ const resolvedClient = client || getCachedPublicClient(rpcUrl);
184
+ const blockCache = new Map();
185
+ const latestNumber = await resolvedClient.getBlockNumber();
186
+ const latestBlock = await resolvedClient.getBlock({ blockNumber: latestNumber });
187
+ const genesisBlock = await resolvedClient.getBlock({ blockNumber: 0n });
188
+ blockCache.set(latestNumber.toString(), latestBlock);
189
+ blockCache.set('0', genesisBlock);
190
+
191
+ let previousBlock = genesisBlock;
192
+ const out = [];
193
+ for (const timestamp of timestamps) {
194
+ const blockNumber = await findBlockNumberForTimestamp({
195
+ rpcUrl,
196
+ targetTimestampSec: timestamp,
197
+ debug,
198
+ client: resolvedClient,
199
+ bounds: {
200
+ latestNumber,
201
+ latestBlock,
202
+ genesisBlock,
203
+ loBlock: previousBlock,
204
+ },
205
+ blockCache,
206
+ });
207
+ const resolvedBlock =
208
+ blockCache.get(blockNumber.toString()) ||
209
+ await resolvedClient.getBlock({ blockNumber });
210
+ blockCache.set(blockNumber.toString(), resolvedBlock);
211
+ previousBlock = resolvedBlock;
212
+ out.push(blockNumber);
213
+ }
214
+ return out;
215
+ }
216
+
147
217
  export async function resolveAnalysisBlockContext({
148
218
  block,
149
219
  timestamp,
150
220
  rpcUrl,
151
221
  debug = false,
152
222
  blockTag,
223
+ client,
153
224
  }) {
154
225
  let blockNumber;
155
226
  let blockSource;
@@ -174,6 +245,7 @@ export async function resolveAnalysisBlockContext({
174
245
  rpcUrl,
175
246
  targetTimestampSec: timestampSec,
176
247
  debug,
248
+ client,
177
249
  });
178
250
  blockSource = '--timestamp';
179
251
  }
@@ -221,13 +293,18 @@ function usdToRaw(usd, usdPrice, decimals) {
221
293
  async function resolveChainId({ client, rpcUrl, blockTag }) {
222
294
  try {
223
295
  if (client && typeof client.getChainId === 'function') {
224
- return await client.getChainId();
296
+ const chainId = await client.getChainId();
297
+ if (rpcUrl) chainIdByRpcUrl.set(rpcUrl, chainId);
298
+ return chainId;
225
299
  }
226
300
  } catch (_) {}
227
301
  try {
228
302
  if (!rpcUrl) return undefined;
229
- const tempClient = createPublicClient({ transport: viemHttp(rpcUrl) });
230
- return await tempClient.getChainId();
303
+ if (chainIdByRpcUrl.has(rpcUrl)) return chainIdByRpcUrl.get(rpcUrl);
304
+ const tempClient = getCachedPublicClient(rpcUrl);
305
+ const chainId = await tempClient.getChainId();
306
+ chainIdByRpcUrl.set(rpcUrl, chainId);
307
+ return chainId;
231
308
  } catch (_) {
232
309
  return undefined;
233
310
  }
@@ -425,6 +502,7 @@ export async function analyzePool(input = {}) {
425
502
  rpcUrl: options.rpcUrl,
426
503
  debug: options.debug,
427
504
  blockTag: options.blockTag,
505
+ client: options.client,
428
506
  });
429
507
 
430
508
  let client = options.client;
@@ -443,7 +521,7 @@ export async function analyzePool(input = {}) {
443
521
  }
444
522
  const { fetchPoolSnapshotViem } = await import('./viem-onchain.js');
445
523
  if (!client) {
446
- client = createPublicClient({ transport: viemHttp(options.rpcUrl) });
524
+ client = getCachedPublicClient(options.rpcUrl);
447
525
  }
448
526
  usedSource = 'tevm';
449
527
  snap = await fetchPoolSnapshotViem({
@@ -1,8 +1,14 @@
1
- // On-chain fetcher for Uniswap v3 pools using a viem public client
2
- // - Reads pool slot0, liquidity, fee, tickSpacing, token0/1 addresses + ERC20 decimals/symbols
3
- // - Enumerates initialized ticks via tickBitmap within a bounded range
1
+ // On-chain fetcher for Uniswap v3 pools using a viem public client.
2
+ // It prefers JSON-RPC batching when an RPC URL is available to reduce HTTP round trips.
4
3
 
5
- import { createPublicClient, getAddress, http, parseAbi } from 'viem';
4
+ import {
5
+ createPublicClient,
6
+ decodeFunctionResult,
7
+ encodeFunctionData,
8
+ getAddress,
9
+ http,
10
+ parseAbi,
11
+ } from 'viem';
6
12
 
7
13
  const UNISWAP_POOL_ABI = parseAbi([
8
14
  'function slot0() view returns (uint160 sqrtPriceX96, int24 tick, uint16 observationIndex, uint16 observationCardinality, uint16 observationCardinalityNext, uint8 feeProtocol, bool unlocked)',
@@ -32,6 +38,124 @@ const ERC20_ABI = parseAbi([
32
38
  const GLOBAL_STATE_SELECTOR = '0xe76c01e4';
33
39
  const TWO_256 = 1n << 256n;
34
40
  const TWO_255 = 1n << 255n;
41
+ const poolStaticCache = new Map();
42
+ const tokenMetaCache = new Map();
43
+
44
+ function toRpcBlockTag(blockTag) {
45
+ if (typeof blockTag === 'bigint') return `0x${blockTag.toString(16)}`;
46
+ if (typeof blockTag === 'number') return `0x${blockTag.toString(16)}`;
47
+ return blockTag ?? 'latest';
48
+ }
49
+
50
+ async function batchRpcRequest({ rpcUrl, requests }) {
51
+ if (!rpcUrl) throw new Error('rpcUrl is required for batched RPC requests');
52
+ const res = await fetch(rpcUrl, {
53
+ method: 'POST',
54
+ headers: { 'content-type': 'application/json' },
55
+ body: JSON.stringify(requests),
56
+ });
57
+ if (!res.ok) {
58
+ throw new Error(`Batched RPC request failed with HTTP ${res.status}`);
59
+ }
60
+ const body = await res.json();
61
+ const responses = Array.isArray(body) ? body : [body];
62
+ const byId = new Map(responses.map((entry) => [entry.id, entry]));
63
+ return requests.map((request) => {
64
+ const response = byId.get(request.id);
65
+ if (!response) {
66
+ throw new Error(`Missing batched RPC response for id ${request.id}`);
67
+ }
68
+ if (response.error) {
69
+ throw new Error(response.error.message || JSON.stringify(response.error));
70
+ }
71
+ return response.result;
72
+ });
73
+ }
74
+
75
+ async function readContractsBatch({ rpcUrl, blockTag, address, abi, calls }) {
76
+ const normalizedAddress = getAddress(address);
77
+ return readContractCallsBatch({
78
+ rpcUrl,
79
+ blockTag,
80
+ calls: calls.map((call) => ({ ...call, address: normalizedAddress, abi })),
81
+ });
82
+ }
83
+
84
+ async function readContractCallsBatch({ rpcUrl, blockTag, calls }) {
85
+ const rpcBlockTag = toRpcBlockTag(blockTag);
86
+ const requests = calls.map((call, index) => ({
87
+ jsonrpc: '2.0',
88
+ id: index + 1,
89
+ method: 'eth_call',
90
+ params: [
91
+ {
92
+ to: getAddress(call.address),
93
+ data: encodeFunctionData({
94
+ abi: call.abi,
95
+ functionName: call.functionName,
96
+ args: call.args,
97
+ }),
98
+ },
99
+ rpcBlockTag,
100
+ ],
101
+ }));
102
+ const results = await batchRpcRequest({ rpcUrl, requests });
103
+ return results.map((result, index) =>
104
+ decodeFunctionResult({
105
+ abi: calls[index].abi,
106
+ functionName: calls[index].functionName,
107
+ data: result,
108
+ })
109
+ );
110
+ }
111
+
112
+ async function readContracts({ client, rpcUrl, blockTag, address, abi, calls, readBlockParams }) {
113
+ if (rpcUrl) {
114
+ try {
115
+ return await readContractsBatch({ rpcUrl, blockTag, address, abi, calls });
116
+ } catch (_) {}
117
+ }
118
+ return Promise.all(
119
+ calls.map((call) =>
120
+ client.readContract({
121
+ address,
122
+ abi,
123
+ functionName: call.functionName,
124
+ args: call.args,
125
+ ...readBlockParams,
126
+ })
127
+ )
128
+ );
129
+ }
130
+
131
+ async function readRawCall({ client, rpcUrl, to, data, blockTag }) {
132
+ if (rpcUrl) {
133
+ try {
134
+ const [result] = await batchRpcRequest({
135
+ rpcUrl,
136
+ requests: [
137
+ {
138
+ jsonrpc: '2.0',
139
+ id: 1,
140
+ method: 'eth_call',
141
+ params: [{ to, data }, toRpcBlockTag(blockTag)],
142
+ },
143
+ ],
144
+ });
145
+ return result;
146
+ } catch (_) {}
147
+ }
148
+ return client.transport.request({
149
+ method: 'eth_call',
150
+ params: [{ to, data }, toRpcBlockTag(blockTag)],
151
+ });
152
+ }
153
+
154
+ function chunk(array, size) {
155
+ const out = [];
156
+ for (let i = 0; i < array.length; i += size) out.push(array.slice(i, i + size));
157
+ return out;
158
+ }
35
159
 
36
160
  function toSigned256(x) {
37
161
  return x >= TWO_255 ? x - TWO_256 : x;
@@ -115,57 +239,126 @@ export async function fetchPoolSnapshotViem({ poolAddress, rpcUrl, percentBucket
115
239
  let token0Addr;
116
240
  let token1Addr;
117
241
  let poolType = 'uniswap';
242
+ const poolCacheKey = pool.toLowerCase();
243
+ const cachedPoolStatic = poolStaticCache.get(poolCacheKey);
118
244
 
119
- try {
120
- const [slot0, L, fee, spacing, tok0, tok1] = await Promise.all([
121
- c.readContract({ address: pool, abi: UNISWAP_POOL_ABI, functionName: 'slot0', ...readBlockParams }),
122
- c.readContract({ address: pool, abi: UNISWAP_POOL_ABI, functionName: 'liquidity', ...readBlockParams }),
123
- c.readContract({ address: pool, abi: UNISWAP_POOL_ABI, functionName: 'fee', ...readBlockParams }),
124
- c.readContract({ address: pool, abi: UNISWAP_POOL_ABI, functionName: 'tickSpacing', ...readBlockParams }),
125
- c.readContract({ address: pool, abi: UNISWAP_POOL_ABI, functionName: 'token0', ...readBlockParams }),
126
- c.readContract({ address: pool, abi: UNISWAP_POOL_ABI, functionName: 'token1', ...readBlockParams }),
127
- ]);
245
+ if (cachedPoolStatic?.poolType === 'uniswap') {
246
+ poolType = 'uniswap';
247
+ const [slot0, L] = await readContracts({
248
+ client: c,
249
+ rpcUrl,
250
+ blockTag,
251
+ address: pool,
252
+ abi: UNISWAP_POOL_ABI,
253
+ calls: [{ functionName: 'slot0' }, { functionName: 'liquidity' }],
254
+ readBlockParams,
255
+ });
128
256
  sqrtPriceX96 = slot0[0];
129
257
  tick = Number(slot0[1]);
130
258
  liquidity = L;
131
- feePips = Number(fee);
132
- tickSpacing = Number(spacing);
133
- token0Addr = tok0;
134
- token1Addr = tok1;
135
- } catch (uniswapErr) {
259
+ feePips = cachedPoolStatic.feePips;
260
+ tickSpacing = cachedPoolStatic.tickSpacing;
261
+ token0Addr = cachedPoolStatic.token0Addr;
262
+ token1Addr = cachedPoolStatic.token1Addr;
263
+ } else if (cachedPoolStatic?.poolType === 'algebra') {
136
264
  poolType = 'algebra';
137
- let globalState;
138
- try {
139
- const callResult = await c.transport.request({
140
- method: 'eth_call',
141
- params: [
142
- { to: pool, data: GLOBAL_STATE_SELECTOR },
143
- typeof blockTag === 'bigint'
144
- ? `0x${blockTag.toString(16)}`
145
- : typeof blockTag === 'number'
146
- ? `0x${blockTag.toString(16)}`
147
- : blockTag ?? 'latest',
148
- ],
149
- });
150
- globalState = decodeAlgebraGlobalState(callResult);
151
- } catch (algebraStateErr) {
152
- const combined = new Error(`Failed to read pool state as Uniswap (slot0): ${uniswapErr?.message || uniswapErr}. Algebra globalState call also failed: ${algebraStateErr?.message || algebraStateErr}`);
153
- combined.cause = { uniswap: uniswapErr, algebra: algebraStateErr };
154
- throw combined;
155
- }
156
- const [L, spacing, tok0, tok1] = await Promise.all([
157
- c.readContract({ address: pool, abi: ALGEBRA_POOL_ABI, functionName: 'liquidity', ...readBlockParams }),
158
- c.readContract({ address: pool, abi: ALGEBRA_POOL_ABI, functionName: 'tickSpacing', ...readBlockParams }),
159
- c.readContract({ address: pool, abi: ALGEBRA_POOL_ABI, functionName: 'token0', ...readBlockParams }),
160
- c.readContract({ address: pool, abi: ALGEBRA_POOL_ABI, functionName: 'token1', ...readBlockParams }),
265
+ const [globalStateHex, L] = await Promise.all([
266
+ readRawCall({ client: c, rpcUrl, to: pool, data: GLOBAL_STATE_SELECTOR, blockTag }),
267
+ readContracts({
268
+ client: c,
269
+ rpcUrl,
270
+ blockTag,
271
+ address: pool,
272
+ abi: ALGEBRA_POOL_ABI,
273
+ calls: [{ functionName: 'liquidity' }],
274
+ readBlockParams,
275
+ }).then((results) => results[0]),
161
276
  ]);
277
+ const globalState = decodeAlgebraGlobalState(globalStateHex);
162
278
  sqrtPriceX96 = globalState.sqrtPriceX96;
163
279
  tick = globalState.tick;
164
280
  liquidity = L;
165
281
  feePips = globalState.feePips;
166
- tickSpacing = Number(spacing);
167
- token0Addr = tok0;
168
- token1Addr = tok1;
282
+ tickSpacing = cachedPoolStatic.tickSpacing;
283
+ token0Addr = cachedPoolStatic.token0Addr;
284
+ token1Addr = cachedPoolStatic.token1Addr;
285
+ } else {
286
+ try {
287
+ const [slot0, L, fee, spacing, tok0, tok1] = await readContracts({
288
+ client: c,
289
+ rpcUrl,
290
+ blockTag,
291
+ address: pool,
292
+ abi: UNISWAP_POOL_ABI,
293
+ calls: [
294
+ { functionName: 'slot0' },
295
+ { functionName: 'liquidity' },
296
+ { functionName: 'fee' },
297
+ { functionName: 'tickSpacing' },
298
+ { functionName: 'token0' },
299
+ { functionName: 'token1' },
300
+ ],
301
+ readBlockParams,
302
+ });
303
+ sqrtPriceX96 = slot0[0];
304
+ tick = Number(slot0[1]);
305
+ liquidity = L;
306
+ feePips = Number(fee);
307
+ tickSpacing = Number(spacing);
308
+ token0Addr = tok0;
309
+ token1Addr = tok1;
310
+ poolStaticCache.set(poolCacheKey, {
311
+ poolType: 'uniswap',
312
+ feePips,
313
+ tickSpacing,
314
+ token0Addr,
315
+ token1Addr,
316
+ });
317
+ } catch (uniswapErr) {
318
+ poolType = 'algebra';
319
+ let globalState;
320
+ try {
321
+ const callResult = await readRawCall({
322
+ client: c,
323
+ rpcUrl,
324
+ to: pool,
325
+ data: GLOBAL_STATE_SELECTOR,
326
+ blockTag,
327
+ });
328
+ globalState = decodeAlgebraGlobalState(callResult);
329
+ } catch (algebraStateErr) {
330
+ const combined = new Error(`Failed to read pool state as Uniswap (slot0): ${uniswapErr?.message || uniswapErr}. Algebra globalState call also failed: ${algebraStateErr?.message || algebraStateErr}`);
331
+ combined.cause = { uniswap: uniswapErr, algebra: algebraStateErr };
332
+ throw combined;
333
+ }
334
+ const [L, spacing, tok0, tok1] = await readContracts({
335
+ client: c,
336
+ rpcUrl,
337
+ blockTag,
338
+ address: pool,
339
+ abi: ALGEBRA_POOL_ABI,
340
+ calls: [
341
+ { functionName: 'liquidity' },
342
+ { functionName: 'tickSpacing' },
343
+ { functionName: 'token0' },
344
+ { functionName: 'token1' },
345
+ ],
346
+ readBlockParams,
347
+ });
348
+ sqrtPriceX96 = globalState.sqrtPriceX96;
349
+ tick = globalState.tick;
350
+ liquidity = L;
351
+ feePips = globalState.feePips;
352
+ tickSpacing = Number(spacing);
353
+ token0Addr = tok0;
354
+ token1Addr = tok1;
355
+ poolStaticCache.set(poolCacheKey, {
356
+ poolType: 'algebra',
357
+ tickSpacing,
358
+ token0Addr,
359
+ token1Addr,
360
+ });
361
+ }
169
362
  }
170
363
 
171
364
  tickSpacing = Math.abs(Number(tickSpacing));
@@ -173,12 +366,66 @@ export async function fetchPoolSnapshotViem({ poolAddress, rpcUrl, percentBucket
173
366
  throw new Error('tickSpacing resolved to zero');
174
367
  }
175
368
 
176
- const [dec0, sym0, dec1, sym1] = await Promise.all([
177
- c.readContract({ address: token0Addr, abi: ERC20_ABI, functionName: 'decimals', ...readBlockParams }),
178
- c.readContract({ address: token0Addr, abi: ERC20_ABI, functionName: 'symbol', ...readBlockParams }),
179
- c.readContract({ address: token1Addr, abi: ERC20_ABI, functionName: 'decimals', ...readBlockParams }),
180
- c.readContract({ address: token1Addr, abi: ERC20_ABI, functionName: 'symbol', ...readBlockParams }),
181
- ]);
369
+ const token0Key = token0Addr.toLowerCase();
370
+ const token1Key = token1Addr.toLowerCase();
371
+ let token0Meta = tokenMetaCache.get(token0Key);
372
+ let token1Meta = tokenMetaCache.get(token1Key);
373
+
374
+ if (!token0Meta || !token1Meta) {
375
+ if (rpcUrl) {
376
+ const pendingCalls = [];
377
+ if (!token0Meta) {
378
+ pendingCalls.push(
379
+ { address: token0Addr, abi: ERC20_ABI, functionName: 'decimals' },
380
+ { address: token0Addr, abi: ERC20_ABI, functionName: 'symbol' }
381
+ );
382
+ }
383
+ if (!token1Meta) {
384
+ pendingCalls.push(
385
+ { address: token1Addr, abi: ERC20_ABI, functionName: 'decimals' },
386
+ { address: token1Addr, abi: ERC20_ABI, functionName: 'symbol' }
387
+ );
388
+ }
389
+ const results = await readContractCallsBatch({ rpcUrl, blockTag, calls: pendingCalls });
390
+ let offset = 0;
391
+ if (!token0Meta) {
392
+ token0Meta = { decimals: Number(results[offset]), symbol: results[offset + 1] };
393
+ tokenMetaCache.set(token0Key, token0Meta);
394
+ offset += 2;
395
+ }
396
+ if (!token1Meta) {
397
+ token1Meta = { decimals: Number(results[offset]), symbol: results[offset + 1] };
398
+ tokenMetaCache.set(token1Key, token1Meta);
399
+ }
400
+ } else {
401
+ if (!token0Meta) {
402
+ const [decimals, symbol] = await readContracts({
403
+ client: c,
404
+ rpcUrl,
405
+ blockTag,
406
+ address: token0Addr,
407
+ abi: ERC20_ABI,
408
+ calls: [{ functionName: 'decimals' }, { functionName: 'symbol' }],
409
+ readBlockParams,
410
+ });
411
+ token0Meta = { decimals: Number(decimals), symbol };
412
+ tokenMetaCache.set(token0Key, token0Meta);
413
+ }
414
+ if (!token1Meta) {
415
+ const [decimals, symbol] = await readContracts({
416
+ client: c,
417
+ rpcUrl,
418
+ blockTag,
419
+ address: token1Addr,
420
+ abi: ERC20_ABI,
421
+ calls: [{ functionName: 'decimals' }, { functionName: 'symbol' }],
422
+ readBlockParams,
423
+ });
424
+ token1Meta = { decimals: Number(decimals), symbol };
425
+ tokenMetaCache.set(token1Key, token1Meta);
426
+ }
427
+ }
428
+ }
182
429
 
183
430
  const far = Math.max(...percentBuckets);
184
431
  const delta = percentToTickDelta(far);
@@ -193,19 +440,45 @@ export async function fetchPoolSnapshotViem({ poolAddress, rpcUrl, percentBucket
193
440
  const ticks = [];
194
441
  const poolAbi = poolType === 'algebra' ? ALGEBRA_POOL_ABI : UNISWAP_POOL_ABI;
195
442
  const bitmapFn = poolType === 'algebra' ? 'tickTable' : 'tickBitmap';
196
- for (let w = wordStart; w <= wordEnd; w++) {
197
- // tickBitmap index is int16
198
- const bitmap = await c.readContract({ address: pool, abi: poolAbi, functionName: bitmapFn, args: [BigInt(w)], ...readBlockParams });
443
+ const bitmapWords = [];
444
+ for (let w = wordStart; w <= wordEnd; w++) bitmapWords.push(w);
445
+ const bitmapResults = bitmapWords.length
446
+ ? await readContracts({
447
+ client: c,
448
+ rpcUrl,
449
+ blockTag,
450
+ address: pool,
451
+ abi: poolAbi,
452
+ calls: bitmapWords.map((w) => ({ functionName: bitmapFn, args: [BigInt(w)] })),
453
+ readBlockParams,
454
+ })
455
+ : [];
456
+ const tickIndexes = [];
457
+ for (let i = 0; i < bitmapWords.length; i++) {
458
+ const w = bitmapWords[i];
459
+ const bitmap = bitmapResults[i];
199
460
  if (bitmap === 0n) continue;
200
461
  for (let bit = 0; bit < 256; bit++) {
201
462
  if (((bitmap >> BigInt(bit)) & 1n) === 0n) continue;
202
463
  const compressed = w * 256 + bit;
203
464
  const t = compressed * tickSpacing;
204
465
  if (t < start || t > end) continue;
205
- // Read tick data to get liquidityNet
206
- const ret = await c.readContract({ address: pool, abi: poolAbi, functionName: 'ticks', args: [t], ...readBlockParams });
207
- const liquidityNet = ret[1];
208
- ticks.push({ index: t, liquidityNet: liquidityNet });
466
+ tickIndexes.push(t);
467
+ }
468
+ }
469
+
470
+ for (const tickBatch of chunk(tickIndexes, 128)) {
471
+ const tickResults = await readContracts({
472
+ client: c,
473
+ rpcUrl,
474
+ blockTag,
475
+ address: pool,
476
+ abi: poolAbi,
477
+ calls: tickBatch.map((tickIndex) => ({ functionName: 'ticks', args: [tickIndex] })),
478
+ readBlockParams,
479
+ });
480
+ for (let i = 0; i < tickBatch.length; i++) {
481
+ ticks.push({ index: tickBatch[i], liquidityNet: tickResults[i][1] });
209
482
  }
210
483
  }
211
484
 
@@ -215,11 +488,11 @@ export async function fetchPoolSnapshotViem({ poolAddress, rpcUrl, percentBucket
215
488
  liquidity,
216
489
  feePips,
217
490
  ticks: ticks.sort((a,b) => a.index - b.index),
218
- token0: { decimals: Number(dec0), usdPrice: 0, symbol: sym0, id: token0Addr },
219
- token1: { decimals: Number(dec1), usdPrice: 0, symbol: sym1, id: token1Addr },
491
+ token0: { decimals: token0Meta.decimals, usdPrice: 0, symbol: token0Meta.symbol, id: token0Addr },
492
+ token1: { decimals: token1Meta.decimals, usdPrice: 0, symbol: token1Meta.symbol, id: token1Addr },
220
493
  meta: {
221
- token0: { decimals: Number(dec0), symbol: sym0, id: token0Addr },
222
- token1: { decimals: Number(dec1), symbol: sym1, id: token1Addr },
494
+ token0: { decimals: token0Meta.decimals, symbol: token0Meta.symbol, id: token0Addr },
495
+ token1: { decimals: token1Meta.decimals, symbol: token1Meta.symbol, id: token1Addr },
223
496
  poolId: pool,
224
497
  range: { start, end },
225
498
  farPercent: far,