@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.
@@ -0,0 +1,795 @@
1
+ #!/usr/bin/env node
2
+
3
+ // CLI for computing Uniswap v3 market depth bands with real data
4
+
5
+ import fs from 'node:fs/promises';
6
+ import path from 'node:path';
7
+ import { createPublicClient, parseAbi, getAddress, http as viemHttp } from 'viem';
8
+ import {
9
+ analyzePool,
10
+ findBlockNumbersForTimestamps,
11
+ formatTimestampUTC,
12
+ parseDurationArg,
13
+ parsePercentBuckets,
14
+ parseTimestampArg,
15
+ resolveAnalysisBlockContext,
16
+ } from '../src/api.js';
17
+ import { createForkClient, simulateVaultWithdraw, resolveViemChain } from '../src/withdraw.js';
18
+
19
+ const UNISWAP_V3_FACTORY = '0x1F98431c8aD98523631AE4a59f267346ea31F984';
20
+ const FACTORY_ABI = parseAbi([
21
+ 'function getPool(address tokenA, address tokenB, uint24 fee) view returns (address)'
22
+ ]);
23
+
24
+ function parseArgs(argv) {
25
+ const args = {
26
+ percent: '1,2,5,10',
27
+ subgraph: undefined,
28
+ source: 'tevm',
29
+ rpc: process.env.RPC_URL,
30
+ usdSizes: '1000,5000,10000,25000,50000,100000',
31
+ prices: process.env.PRICES_SOURCE || 'auto',
32
+ reserveLimit: Number(process.env.RESERVE_LIMIT) || 100,
33
+ csv: undefined,
34
+ outdir: 'reports',
35
+ // withdraw simulation
36
+ vault: undefined,
37
+ owner: process.env.OWNER,
38
+ withdrawPct: undefined,
39
+ withdrawShares: undefined,
40
+ block: undefined,
41
+ timestamp: undefined,
42
+ timeStart: undefined,
43
+ timeEnd: undefined,
44
+ timeInterval: undefined,
45
+ timeWindow: undefined,
46
+ timeCount: undefined,
47
+ };
48
+ for (let i = 2; i < argv.length; i++) {
49
+ const a = argv[i];
50
+ if (a === '--pool' || a === '-p') args.pool = argv[++i];
51
+ else if (a === '--percent' || a === '-b') args.percent = argv[++i];
52
+ else if (a === '--subgraph' || a === '-s') args.subgraph = argv[++i];
53
+ else if (a === '--rpc' || a === '-r') args.rpc = argv[++i];
54
+ else if (a === '--source') args.source = argv[++i]; // tevm|subgraph|auto
55
+ else if (a === '--token0-usd') args.token0USD = Number(argv[++i]);
56
+ else if (a === '--token1-usd') args.token1USD = Number(argv[++i]);
57
+ else if (a === '--assume-stable') args.assumeStable = Number(argv[++i]); // 0 or 1
58
+ else if (a === '--usd-sizes') args.usdSizes = argv[++i];
59
+ else if (a === '--prices') args.prices = argv[++i]; // llama|coingecko|auto
60
+ // withdraw simulation flags
61
+ else if (a === '--vault') args.vault = argv[++i];
62
+ else if (a === '--owner') args.owner = argv[++i];
63
+ else if (a === '--withdraw-pct' || a === '--withdraw-percent') args.withdrawPct = Number(argv[++i]);
64
+ else if (a === '--withdraw-shares') args.withdrawShares = argv[++i];
65
+ else if (a === '--block') args.block = argv[++i];
66
+ else if (a === '--timestamp' || a === '--time') args.timestamp = argv[++i];
67
+ else if (a === '--time-start') args.timeStart = argv[++i];
68
+ else if (a === '--time-end') args.timeEnd = argv[++i];
69
+ else if (a === '--time-interval') args.timeInterval = argv[++i];
70
+ else if (a === '--time-window') args.timeWindow = argv[++i];
71
+ else if (a === '--time-count') args.timeCount = Number(argv[++i]);
72
+ else if (a === '--debug') args.debug = true;
73
+ else if (a === '--csv') args.csv = argv[++i];
74
+ else if (a === '--outdir') args.outdir = argv[++i];
75
+ else if (a === '--reserve-limit') args.reserveLimit = Number(argv[++i]);
76
+ else if (a === '--json' || a === '-j') args.json = true;
77
+ else if (a === '--help' || a === '-h') args.help = true;
78
+ else if (a === '--version' || a === '-v') args.version = true;
79
+ else if (!args.pool && /^0x[0-9a-fA-F]{40}$/.test(a)) args.pool = a;
80
+ else {
81
+ console.error(`Unknown arg: ${a}`);
82
+ args.help = true;
83
+ }
84
+ }
85
+ return args;
86
+ }
87
+
88
+ function printHelp() {
89
+ console.log(`
90
+ liquidity-depth — Uniswap v3 market depth bands
91
+
92
+ Usage:
93
+ liquidity-depth --pool <POOL_ADDRESS> [--percent 1,2,5,10] [--source tevm|viem|subgraph|auto] [--rpc <URL>] [--block N] [--timestamp ISO|SECONDS]
94
+ [--time-start ISO|SECONDS] [--time-end ISO|SECONDS] [--time-interval 1h] [--time-window 7d] [--time-count N]
95
+ [--token0-usd N] [--token1-usd N] [--usd-sizes 1000,5000,...] [--json]
96
+ liquidity-depth --pool <POOL_ADDRESS> --vault <VAULT_ADDRESS> --owner <OWNER_ADDRESS> [--withdraw-pct N | --withdraw-shares <SHARES>] [same options]
97
+ liquidity-depth --csv <FILE.csv> [--outdir reports] [same options as above]
98
+
99
+ Options:
100
+ -p, --pool Uniswap v3 pool address (e.g., 0x8ad599c3a0ff1de082011efddc58f1908eb6e6d8)
101
+ -b, --percent Percent buckets, comma-separated (default: 1,2,5,10)
102
+ -s, --subgraph Subgraph URL (default: Uniswap v3 mainnet)
103
+ --source Data source: tevm|viem|subgraph|auto (default: tevm)
104
+ -r, --rpc RPC URL (for on-chain fetching via tevm fork). Also reads RPC_URL env var
105
+ --token0-usd Override token0 USD price
106
+ --token1-usd Override token1 USD price
107
+ --assume-stable 0|1 Assume token index is $1 stable (fills missing USD)
108
+ --usd-sizes Comma-separated USD sizes for price impact (default: 1000,5000,10000,25000,50000,100000)
109
+ --block Fork/query state at the specified block number
110
+ --timestamp Resolve a historical block by timestamp (seconds or ISO-8601); requires --rpc
111
+ --time-start Beginning timestamp (inclusive) for time-series mode (seconds or ISO-8601)
112
+ --time-end Ending timestamp (inclusive, default: now) for time-series mode
113
+ --time-interval Step between snapshots in time-series mode (e.g., 1h, 30m; default: 1h)
114
+ --time-window How far back from --time-end to start when --time-start omitted (e.g., 7d)
115
+ --time-count Number of intervals to fetch (e.g., 168 for 7 days hourly); implies backwards from --time-end
116
+ --prices Price source: reserve|dexscreener|llama|coingecko|auto (default: auto; env PRICES_SOURCE)
117
+ --reserve-limit N Limit for Reserve API (default: 100; env RESERVE_LIMIT)
118
+ --debug Print debug logs to stderr for pricing/inference
119
+ --csv CSV file with a header including a 'pool' or 'address' column
120
+ --outdir Output directory for per-pool reports (default: reports)
121
+ --vault Steer vault address (to simulate withdrawal)
122
+ --owner Owner address whose shares are withdrawn (also reads OWNER env)
123
+ --withdraw-pct Percent of owner's shares to withdraw (e.g., 25)
124
+ --withdraw-shares Raw share amount to withdraw (overrides --withdraw-pct)
125
+ -j, --json Output machine-readable JSON
126
+ -h, --help Show help
127
+ -v, --version Show version
128
+ `);
129
+ }
130
+
131
+ function looksLikeAddress(x) {
132
+ return typeof x === 'string' && /^0x[0-9a-fA-F]{40}$/.test(x.trim());
133
+ }
134
+ function extractFirstAddress(x) {
135
+ if (typeof x !== 'string') return undefined;
136
+ const m = x.match(/0x[0-9a-fA-F]{40}/);
137
+ return m ? m[0] : undefined;
138
+ }
139
+ function normKey(k) {
140
+ return String(k || '').toLowerCase().replace(/[ _-]+/g, '');
141
+ }
142
+
143
+ // Minimal CSV parser: returns array of row objects keyed by header.
144
+ // Supports commas, quoted fields with double-quotes escaping.
145
+ function parseCSV(content) {
146
+ const outRows = [];
147
+ let i = 0;
148
+ const len = content.length;
149
+ const fields = [];
150
+ function readLine() {
151
+ fields.length = 0;
152
+ let field = '';
153
+ let inQuotes = false;
154
+ let sawAny = false;
155
+ while (i < len) {
156
+ const ch = content[i++];
157
+ if (!inQuotes && (ch === '\n' || ch === '\r')) {
158
+ if (ch === '\r' && content[i] === '\n') i++; // CRLF
159
+ fields.push(field);
160
+ return true;
161
+ }
162
+ if (ch === '"') {
163
+ if (inQuotes) {
164
+ if (content[i] === '"') { field += '"'; i++; }
165
+ else { inQuotes = false; }
166
+ } else {
167
+ inQuotes = true;
168
+ sawAny = true;
169
+ }
170
+ } else if (ch === ',' && !inQuotes) {
171
+ fields.push(field);
172
+ field = '';
173
+ sawAny = true;
174
+ } else {
175
+ field += ch;
176
+ sawAny = true;
177
+ }
178
+ }
179
+ if (!sawAny && field === '') return false; // EOF after last newline
180
+ fields.push(field);
181
+ return true;
182
+ }
183
+ // read header
184
+ if (!readLine()) return [];
185
+ const header = fields.map((h) => h.trim());
186
+ while (readLine()) {
187
+ const obj = {};
188
+ for (let j = 0; j < header.length; j++) obj[header[j]] = (fields[j] ?? '').trim();
189
+ outRows.push(obj);
190
+ }
191
+ return outRows;
192
+ }
193
+
194
+ async function runBatch(args) {
195
+ const csvPath = args.csv;
196
+ const outdir = args.outdir || 'reports';
197
+ const data = await fs.readFile(csvPath, 'utf8');
198
+ const rows = parseCSV(data);
199
+ if (!rows.length) {
200
+ console.error('CSV is empty or could not be parsed. Expect a header row with a pool/address column.');
201
+ process.exit(1);
202
+ }
203
+ await fs.mkdir(outdir, { recursive: true });
204
+
205
+ const pick = (obj, names) => {
206
+ // try direct keys
207
+ for (const n of names) if (obj[n] != null && String(obj[n]).trim() !== '') return String(obj[n]).trim();
208
+ // try normalized keys
209
+ const map = Object.fromEntries(Object.keys(obj).map((k) => [normKey(k), k]));
210
+ for (const n of names) {
211
+ const key = map[normKey(n)];
212
+ if (key && obj[key] != null && String(obj[key]).trim() !== '') return String(obj[key]).trim();
213
+ }
214
+ return undefined;
215
+ };
216
+
217
+ function parseFeeToUint24(v) {
218
+ if (v == null) return undefined;
219
+ const s = String(v).trim();
220
+ if (!s) return undefined;
221
+ // raw number like 500, 3000, 10000
222
+ if (/^\d+$/.test(s)) {
223
+ const n = Number(s);
224
+ if (Number.isFinite(n) && n > 0) return n;
225
+ }
226
+ // like 30bps or 5 bps
227
+ const mBps = s.match(/([0-9]+(?:\.[0-9]+)?)\s*bps/i);
228
+ if (mBps) {
229
+ const n = Number(mBps[1]);
230
+ if (Number.isFinite(n)) return Math.round(n * 100);
231
+ }
232
+ // like 0.3% or 1 %
233
+ const mPct = s.match(/([0-9]+(?:\.[0-9]+)?)\s*%/);
234
+ if (mPct) {
235
+ const n = Number(mPct[1]);
236
+ if (Number.isFinite(n)) return Math.round(n * 1e4);
237
+ }
238
+ return undefined;
239
+ }
240
+
241
+ async function resolvePoolFromTokens(row, rpcUrl) {
242
+ // attempt to find token addresses + fee
243
+ let t0 = pick(row, ['token0', 'token0_address', 'token 0', 'token 0 address', 'tokenA', 'base']);
244
+ let t1 = pick(row, ['token1', 'token1_address', 'token 1', 'token 1 address', 'tokenB', 'quote']);
245
+ let fee = pick(row, ['fee', 'fee_tier', 'fee tier', 'feebps']);
246
+
247
+ // If not found, scan all values for first two distinct addresses
248
+ if (!looksLikeAddress(t0 || '') || !looksLikeAddress(t1 || '')) {
249
+ const found = [];
250
+ for (const v of Object.values(row)) {
251
+ const addr = extractFirstAddress(String(v || ''));
252
+ if (addr && !found.includes(addr)) found.push(addr);
253
+ if (found.length >= 2) break;
254
+ }
255
+ if (!looksLikeAddress(t0 || '') && found[0]) t0 = found[0];
256
+ if (!looksLikeAddress(t1 || '') && found[1]) t1 = found[1];
257
+ }
258
+
259
+ // parse fee
260
+ const feeNum = parseFeeToUint24(fee);
261
+ if (!looksLikeAddress(t0 || '') || !looksLikeAddress(t1 || '') || !feeNum) return undefined;
262
+ if (!rpcUrl) return undefined;
263
+
264
+ const client = createPublicClient({ transport: viemHttp(rpcUrl) });
265
+ try {
266
+ const pool = await client.readContract({ address: UNISWAP_V3_FACTORY, abi: FACTORY_ABI, functionName: 'getPool', args: [getAddress(t0), getAddress(t1), feeNum] });
267
+ if (pool && pool !== '0x0000000000000000000000000000000000000000') return pool;
268
+ } catch (_) {
269
+ // ignore
270
+ }
271
+ return undefined;
272
+ }
273
+
274
+ for (let idx = 0; idx < rows.length; idx++) {
275
+ const row = rows[idx];
276
+ let pool = pick(row, ['pool', 'address', 'pool_address', 'id', 'Pool', 'Address', 'contract_address', 'contract address', 'pair_address', 'pair address']);
277
+ if (!looksLikeAddress(pool || '')) {
278
+ // try extract from any cell (e.g., Etherscan/Uniswap URL)
279
+ for (const v of Object.values(row)) {
280
+ const found = extractFirstAddress(String(v || ''));
281
+ if (found) { pool = found; break; }
282
+ }
283
+ }
284
+ if (!looksLikeAddress(pool || '')) {
285
+ // try factory lookup using tokens + fee
286
+ const viaFactory = await resolvePoolFromTokens(row, args.rpc);
287
+ if (viaFactory) pool = viaFactory;
288
+ }
289
+ if (!looksLikeAddress(pool || '')) {
290
+ console.warn(`Row ${idx + 1}: skipping (no valid pool address)`);
291
+ continue;
292
+ }
293
+
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;
317
+
318
+ if (args.debug) {
319
+ const dbg = [];
320
+ if (vault) dbg.push(`vault=${vault}`);
321
+ if (owner) dbg.push(`owner=${owner}`);
322
+ if (wpct) dbg.push(`withdraw_pct=${wpct}`);
323
+ if (wshares) dbg.push(`withdraw_shares=${wshares}`);
324
+ if (dbg.length) console.error(`[batch] row ${idx + 1} overrides: ${dbg.join(' ')}`);
325
+ }
326
+
327
+ try {
328
+ const baseName = `${pool.toLowerCase()}`;
329
+ const file = path.join(outdir, `${baseName}.${args.json ? 'json' : 'txt'}`);
330
+ const output = await runSingleInvocation(rowArgs);
331
+ await fs.writeFile(file, output, 'utf8');
332
+ console.log(`Wrote: ${file}`);
333
+ } catch (err) {
334
+ const errMsg = String(err?.stack || err?.message || err);
335
+ const errFile = path.join(outdir, `error-${pool.toLowerCase()}.txt`);
336
+ await fs.writeFile(errFile, errMsg, 'utf8');
337
+ console.error(`Failed row ${idx + 1} (${pool}): ${err?.message || err}`);
338
+ console.error(` -> see ${errFile}`);
339
+ }
340
+ }
341
+ }
342
+
343
+ async function runTimeSeries(args) {
344
+ if (!args.pool) {
345
+ console.error('Time-series mode requires --pool');
346
+ process.exit(1);
347
+ }
348
+ if (args.block) {
349
+ console.error('Time-series mode cannot be combined with --block. Use --time-start/--time-window/--time-count with --timestamp.');
350
+ process.exit(1);
351
+ }
352
+ if (!args.rpc) {
353
+ console.error('Time-series mode requires --rpc (or RPC_URL env) so timestamps can be resolved to blocks.');
354
+ process.exit(1);
355
+ }
356
+
357
+ let intervalSec;
358
+ try {
359
+ intervalSec = parseDurationArg(args.timeInterval ?? '1h');
360
+ } catch (e) {
361
+ console.error(`Invalid --time-interval value: ${e?.message || e}`);
362
+ process.exit(1);
363
+ }
364
+ if (intervalSec <= 0n) {
365
+ console.error('--time-interval must be greater than zero');
366
+ process.exit(1);
367
+ }
368
+
369
+ let endSec;
370
+ try {
371
+ endSec = args.timeEnd != null ? parseTimestampArg(args.timeEnd) : BigInt(Math.floor(Date.now() / 1000));
372
+ } catch (e) {
373
+ console.error(`Invalid --time-end value: ${e?.message || e}`);
374
+ process.exit(1);
375
+ }
376
+
377
+ let startSec;
378
+ if (args.timeStart != null) {
379
+ try {
380
+ startSec = parseTimestampArg(args.timeStart);
381
+ } catch (e) {
382
+ console.error(`Invalid --time-start value: ${e?.message || e}`);
383
+ process.exit(1);
384
+ }
385
+ }
386
+ if (startSec == null && args.timeWindow != null) {
387
+ try {
388
+ const windowSec = parseDurationArg(args.timeWindow);
389
+ startSec = endSec > windowSec ? endSec - windowSec : 0n;
390
+ } catch (e) {
391
+ console.error(`Invalid --time-window value: ${e?.message || e}`);
392
+ process.exit(1);
393
+ }
394
+ }
395
+
396
+ let count = undefined;
397
+ if (args.timeCount != null) {
398
+ if (!Number.isFinite(args.timeCount) || args.timeCount <= 0) {
399
+ console.error('--time-count must be a positive integer');
400
+ process.exit(1);
401
+ }
402
+ count = Math.floor(args.timeCount);
403
+ }
404
+ if (startSec == null) {
405
+ if (count != null) {
406
+ const span = intervalSec * BigInt(Math.max(count - 1, 0));
407
+ startSec = endSec > span ? endSec - span : 0n;
408
+ } else {
409
+ console.error('Time-series mode requires --time-start, --time-window, or --time-count');
410
+ process.exit(1);
411
+ }
412
+ }
413
+
414
+ if (startSec > endSec) {
415
+ [startSec, endSec] = [endSec, startSec];
416
+ }
417
+
418
+ const timestamps = [];
419
+ const maxPoints = 10000;
420
+ for (let ts = startSec; ts <= endSec; ts += intervalSec) {
421
+ timestamps.push(ts);
422
+ if (count != null && timestamps.length >= count) break;
423
+ if (timestamps.length > maxPoints) {
424
+ console.error(`Time-series would produce more than ${maxPoints} snapshots. Increase --time-interval or reduce the window/count.`);
425
+ process.exit(1);
426
+ }
427
+ }
428
+ if (!timestamps.length) timestamps.push(startSec);
429
+ if (count == null && timestamps[timestamps.length - 1] < endSec) {
430
+ timestamps.push(endSec);
431
+ }
432
+ if (count != null && timestamps.length > count) {
433
+ timestamps.splice(0, timestamps.length - count);
434
+ }
435
+
436
+ const tsStrings = timestamps.map((ts) => ts.toString());
437
+ if (!tsStrings.length) {
438
+ console.error('No timestamps resolved for time-series mode');
439
+ process.exit(1);
440
+ }
441
+ const resolvedBlocks = await findBlockNumbersForTimestamps({
442
+ rpcUrl: args.rpc,
443
+ timestamps,
444
+ debug: !!args.debug,
445
+ });
446
+
447
+ if (!args.json) {
448
+ const startIso = formatTimestampUTC(BigInt(tsStrings[0]));
449
+ const endIso = formatTimestampUTC(BigInt(tsStrings[tsStrings.length - 1]));
450
+ const intervalHuman = args.timeInterval ?? '1h';
451
+ console.log(`Time-series: ${tsStrings.length} snapshots from ${startIso || tsStrings[0]} to ${endIso || tsStrings[tsStrings.length - 1]} every ~${intervalHuman}`);
452
+ }
453
+
454
+ const jsonResults = [];
455
+ for (let i = 0; i < tsStrings.length; i++) {
456
+ const tsStr = tsStrings[i];
457
+ const pointArgs = {
458
+ ...args,
459
+ timestamp: undefined,
460
+ block: resolvedBlocks[i].toString(),
461
+ };
462
+ const label = formatTimestampUTC(BigInt(tsStr)) || tsStr;
463
+ if (!args.json) {
464
+ console.log(`\n=== ${label} (unix ${tsStr}) ===`);
465
+ }
466
+ try {
467
+ const output = await runSingleInvocation(pointArgs);
468
+ if (args.json) {
469
+ const text = output.trim();
470
+ if (text) {
471
+ jsonResults.push(JSON.parse(text));
472
+ } else {
473
+ jsonResults.push(null);
474
+ }
475
+ } else {
476
+ process.stdout.write(output);
477
+ if (!output.endsWith('\n')) console.log('');
478
+ }
479
+ } catch (err) {
480
+ console.error(`Time-series run failed for timestamp ${tsStr}: ${err?.message || err}`);
481
+ process.exit(1);
482
+ }
483
+ }
484
+
485
+ if (args.json) {
486
+ console.log(JSON.stringify(jsonResults, null, 2));
487
+ }
488
+ }
489
+
490
+ function captureConsoleOutput(run) {
491
+ const lines = [];
492
+ const original = console.log;
493
+ console.log = (...args) => {
494
+ lines.push(args.join(' '));
495
+ };
496
+ try {
497
+ run();
498
+ } finally {
499
+ console.log = original;
500
+ }
501
+ return lines.length ? `${lines.join('\n')}\n` : '';
502
+ }
503
+
504
+ async function executeSingle(args) {
505
+ let percentBuckets;
506
+ try {
507
+ percentBuckets = parsePercentBuckets(args.percent);
508
+ } catch (e) {
509
+ throw new Error(`Invalid --percent value: ${e?.message || e}`);
510
+ }
511
+ if (!percentBuckets.length) {
512
+ throw new Error('No valid percent buckets parsed.');
513
+ }
514
+
515
+ const wantsWithdraw =
516
+ args.vault && (Number.isFinite(args.withdrawPct) || args.withdrawShares);
517
+ let sharedClient = null;
518
+ let sharedBlockTag;
519
+ if (wantsWithdraw) {
520
+ if (!args.rpc) {
521
+ throw new Error('Missing --rpc (or RPC_URL env) for tevm source.');
522
+ }
523
+ const blockContext = await resolveAnalysisBlockContext({
524
+ block: args.block,
525
+ timestamp: args.timestamp,
526
+ rpcUrl: args.rpc,
527
+ debug: args.debug,
528
+ });
529
+ sharedBlockTag = blockContext.blockTag;
530
+ sharedClient = await createForkClient({
531
+ rpcUrl: args.rpc,
532
+ blockTag: sharedBlockTag,
533
+ });
534
+ }
535
+
536
+ let before;
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
+ });
555
+
556
+ let after = null;
557
+ if (before.usedSource === 'tevm' && wantsWithdraw) {
558
+ const viemChain = await resolveViemChain(args.rpc);
559
+ await simulateVaultWithdraw({
560
+ client: before.client,
561
+ vault: args.vault,
562
+ owner: args.owner,
563
+ withdrawPct: args.withdrawPct,
564
+ withdrawShares: args.withdrawShares,
565
+ debug: !!args.debug,
566
+ cliPool: args.pool,
567
+ viemChain,
568
+ });
569
+ after = await analyzePool({
570
+ poolAddress: args.pool,
571
+ percentBuckets,
572
+ source: 'tevm',
573
+ rpcUrl: args.rpc,
574
+ token0USD: args.token0USD,
575
+ token1USD: args.token1USD,
576
+ assumeStable: args.assumeStable,
577
+ usdSizes: args.usdSizes,
578
+ prices: args.prices,
579
+ reserveLimit: args.reserveLimit,
580
+ debug: args.debug,
581
+ client: before.client,
582
+ blockTag: before.blockTag,
583
+ });
584
+ if (args.debug) {
585
+ console.error(`[after] tick: ${before.snap.tick} -> ${after.snap.tick}`);
586
+ console.error(
587
+ `[after] liquidity: ${before.snap.liquidity.toString()} -> ${after.snap.liquidity.toString()}`
588
+ );
589
+ }
590
+ }
591
+
592
+ return { before, after };
593
+ }
594
+
595
+ function renderSingle(args, before, after) {
596
+ if (args.json) {
597
+ const output = after
598
+ ? {
599
+ before: serializeAnalysis(args, before),
600
+ after: serializeAnalysis(args, after),
601
+ }
602
+ : serializeAnalysis(args, before);
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;
629
+ return;
630
+ }
631
+
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;
661
+ }
662
+ }
663
+
664
+ function serializeAnalysis(args, analysis) {
665
+ const snap = analysis.snap;
666
+ const blockNumber =
667
+ snap.meta?.blockNumber ?? (analysis.blockNumber !== undefined ? analysis.blockNumber : undefined);
668
+ const timestamp =
669
+ snap.meta?.blockTimestamp ??
670
+ (analysis.timestampSec !== undefined ? analysis.timestampSec : undefined);
671
+ return {
672
+ pool: snap.meta.poolId,
673
+ tokens: {
674
+ token0: snap.meta.token0,
675
+ token1: snap.meta.token1,
676
+ },
677
+ ethPriceUSD: snap.meta.ethPriceUSD,
678
+ tick: snap.tick,
679
+ sqrtPriceX96: snap.sqrtPriceX96.toString(),
680
+ liquidity: snap.liquidity.toString(),
681
+ feePips: snap.feePips,
682
+ range: snap.meta.range,
683
+ percentBuckets: analysis.percentBuckets,
684
+ block: {
685
+ number: blockNumber != null ? String(blockNumber) : undefined,
686
+ timestamp: timestamp != null ? Number(timestamp) : undefined,
687
+ iso: formatTimestampUTC(timestamp),
688
+ input:
689
+ args.block ??
690
+ args.timestamp ??
691
+ (typeof analysis.blockTag === 'string' ? analysis.blockTag : undefined),
692
+ },
693
+ ...analysis.result,
694
+ priceImpacts: analysis.priceImpacts
695
+ ? {
696
+ usdSizes: analysis.usdSizes,
697
+ buyPct: analysis.priceImpacts.buyPct,
698
+ sellPct: analysis.priceImpacts.sellPct,
699
+ }
700
+ : undefined,
701
+ prices: {
702
+ token0: {
703
+ address: snap.meta.token0.id,
704
+ symbol: snap.meta.token0.symbol,
705
+ usd: analysis.token0USD,
706
+ source: analysis.priceSource0,
707
+ },
708
+ token1: {
709
+ address: snap.meta.token1.id,
710
+ symbol: snap.meta.token1.symbol,
711
+ usd: analysis.token1USD,
712
+ source: analysis.priceSource1,
713
+ },
714
+ },
715
+ buyCumToken1: analysis.result.buyCumToken1.map((value) => value.toString()),
716
+ sellCumToken0: analysis.result.sellCumToken0.map((value) => value.toString()),
717
+ };
718
+ }
719
+
720
+ function printAnalysisSummary(args, analysis) {
721
+ const snap = analysis.snap;
722
+ const result = analysis.result;
723
+ const fmtUSD = (value) =>
724
+ new Intl.NumberFormat('en-US', {
725
+ style: 'currency',
726
+ currency: 'USD',
727
+ maximumFractionDigits: 0,
728
+ }).format(value);
729
+
730
+ console.log(`Pool: ${args.pool.toLowerCase()} [source: ${analysis.usedSource}]`);
731
+ const blockNumber =
732
+ snap.meta?.blockNumber ?? (analysis.blockNumber !== undefined ? analysis.blockNumber : undefined);
733
+ const timestamp =
734
+ snap.meta?.blockTimestamp ??
735
+ (analysis.timestampSec !== undefined ? analysis.timestampSec : undefined);
736
+ const blockInfoParts = [];
737
+ if (blockNumber != null) blockInfoParts.push(`#${String(blockNumber)}`);
738
+ const isoTimestamp = formatTimestampUTC(timestamp);
739
+ if (isoTimestamp) blockInfoParts.push(isoTimestamp);
740
+ if (blockNumber == null && typeof analysis.blockTag === 'string') {
741
+ blockInfoParts.push(analysis.blockTag);
742
+ }
743
+ if (blockInfoParts.length) console.log(`Block: ${blockInfoParts.join(' | ')}`);
744
+ console.log(
745
+ `Tokens: ${snap.meta.token0.symbol} (${snap.meta.token0.id}) / ${snap.meta.token1.symbol} (${snap.meta.token1.id})`
746
+ );
747
+ if (snap.meta?.ethPriceUSD) console.log(`ETH/USD: ${snap.meta.ethPriceUSD.toFixed(2)}`);
748
+ const feePct = snap.feePips / 10_000;
749
+ const feeBps = snap.feePips / 100;
750
+ console.log(`Tick: ${snap.tick} Fee: ${feePct.toFixed(2)}% (${feeBps.toFixed(2)} bps)`);
751
+ if (analysis.token0USD || analysis.token1USD) {
752
+ const fmtPrice = (value) =>
753
+ value
754
+ ? `$${value.toLocaleString(undefined, { maximumFractionDigits: 6 })}`
755
+ : 'n/a';
756
+ console.log(
757
+ `Prices: ${snap.meta.token0.symbol} ${fmtPrice(analysis.token0USD)} (${analysis.priceSource0 || 'n/a'}), ${snap.meta.token1.symbol} ${fmtPrice(analysis.token1USD)} (${analysis.priceSource1 || 'n/a'})`
758
+ );
759
+ }
760
+ console.log(`Range: [${snap.meta.range.start}, ${snap.meta.range.end}] for ±${snap.meta.farPercent}%`);
761
+ console.log('');
762
+
763
+ const labels = analysis.percentBuckets.map((percent) => `${percent}%`);
764
+ console.log('Buy cumulative (token1 in → price up):');
765
+ labels.forEach((label, index) =>
766
+ console.log(` +${label.padEnd(4)}: ${fmtUSD(result.buyCumUSD[index])}`)
767
+ );
768
+ console.log('Sell cumulative (token0 in → price down):');
769
+ labels.forEach((label, index) =>
770
+ console.log(` -${label.padEnd(4)}: ${fmtUSD(result.sellCumUSD[index])}`)
771
+ );
772
+ console.log('');
773
+ console.log(`Headline Depth +2% : ${fmtUSD(result.depthPlus2USD)}`);
774
+ console.log(`Headline Depth -2% : ${fmtUSD(result.depthMinus2USD)}`);
775
+
776
+ if (!analysis.priceImpacts && analysis.usdSizes.length) {
777
+ console.log('');
778
+ console.log('Note: USD sizes provided but missing USD prices.');
779
+ console.log(' Pass --token0-usd and --token1-usd to enable price impact output.');
780
+ } else if (analysis.priceImpacts) {
781
+ console.log('');
782
+ console.log('Price impact by USD size:');
783
+ analysis.usdSizes.forEach((usd, index) => {
784
+ const buy = analysis.priceImpacts.buyPct[index];
785
+ const sell = analysis.priceImpacts.sellPct[index];
786
+ const fmtPct = (value) => `${value >= 0 ? '+' : ''}${value.toFixed(5)}%`;
787
+ console.log(` $${usd.toLocaleString()}: buy ${fmtPct(buy)} sell ${fmtPct(sell)}`);
788
+ });
789
+ }
790
+ }
791
+
792
+ main().catch((err) => {
793
+ console.error(err?.stack || err?.message || String(err));
794
+ process.exit(1);
795
+ });