@steerprotocol/liquidity-meter 1.0.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,887 @@
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 { execFile as _execFile } from 'node:child_process';
8
+ import { promisify } from 'node:util';
9
+ const execFile = promisify(_execFile);
10
+ import { parseAbi, getAddress } from 'viem';
11
+ import {
12
+ analyzePool,
13
+ formatTimestampUTC,
14
+ parseDurationArg,
15
+ parsePercentBuckets,
16
+ parseTimestampArg,
17
+ resolveAnalysisBlockContext,
18
+ } from '../src/api.js';
19
+ import { createForkClient, simulateVaultWithdraw, resolveViemChain } from '../src/withdraw.js';
20
+
21
+ const UNISWAP_V3_FACTORY = '0x1F98431c8aD98523631AE4a59f267346ea31F984';
22
+ const FACTORY_ABI = parseAbi([
23
+ 'function getPool(address tokenA, address tokenB, uint24 fee) view returns (address)'
24
+ ]);
25
+
26
+ function parseArgs(argv) {
27
+ const args = {
28
+ percent: '1,2,5,10',
29
+ subgraph: undefined,
30
+ source: 'tevm',
31
+ rpc: process.env.RPC_URL,
32
+ usdSizes: '1000,5000,10000,25000,50000,100000',
33
+ prices: process.env.PRICES_SOURCE || 'auto',
34
+ reserveLimit: Number(process.env.RESERVE_LIMIT) || 100,
35
+ csv: undefined,
36
+ outdir: 'reports',
37
+ // withdraw simulation
38
+ vault: undefined,
39
+ owner: process.env.OWNER,
40
+ withdrawPct: undefined,
41
+ withdrawShares: undefined,
42
+ block: undefined,
43
+ timestamp: undefined,
44
+ timeStart: undefined,
45
+ timeEnd: undefined,
46
+ timeInterval: undefined,
47
+ timeWindow: undefined,
48
+ timeCount: undefined,
49
+ };
50
+ for (let i = 2; i < argv.length; i++) {
51
+ const a = argv[i];
52
+ if (a === '--pool' || a === '-p') args.pool = argv[++i];
53
+ else if (a === '--percent' || a === '-b') args.percent = argv[++i];
54
+ else if (a === '--subgraph' || a === '-s') args.subgraph = argv[++i];
55
+ else if (a === '--rpc' || a === '-r') args.rpc = argv[++i];
56
+ else if (a === '--source') args.source = argv[++i]; // tevm|subgraph|auto
57
+ else if (a === '--token0-usd') args.token0USD = Number(argv[++i]);
58
+ else if (a === '--token1-usd') args.token1USD = Number(argv[++i]);
59
+ else if (a === '--assume-stable') args.assumeStable = Number(argv[++i]); // 0 or 1
60
+ else if (a === '--usd-sizes') args.usdSizes = argv[++i];
61
+ else if (a === '--prices') args.prices = argv[++i]; // llama|coingecko|auto
62
+ // withdraw simulation flags
63
+ else if (a === '--vault') args.vault = argv[++i];
64
+ else if (a === '--owner') args.owner = argv[++i];
65
+ else if (a === '--withdraw-pct' || a === '--withdraw-percent') args.withdrawPct = Number(argv[++i]);
66
+ else if (a === '--withdraw-shares') args.withdrawShares = argv[++i];
67
+ else if (a === '--block') args.block = argv[++i];
68
+ else if (a === '--timestamp' || a === '--time') args.timestamp = argv[++i];
69
+ else if (a === '--time-start') args.timeStart = argv[++i];
70
+ else if (a === '--time-end') args.timeEnd = argv[++i];
71
+ else if (a === '--time-interval') args.timeInterval = argv[++i];
72
+ else if (a === '--time-window') args.timeWindow = argv[++i];
73
+ else if (a === '--time-count') args.timeCount = Number(argv[++i]);
74
+ else if (a === '--debug') args.debug = true;
75
+ else if (a === '--csv') args.csv = argv[++i];
76
+ else if (a === '--outdir') args.outdir = argv[++i];
77
+ else if (a === '--reserve-limit') args.reserveLimit = Number(argv[++i]);
78
+ else if (a === '--json' || a === '-j') args.json = true;
79
+ else if (a === '--help' || a === '-h') args.help = true;
80
+ else if (a === '--version' || a === '-v') args.version = true;
81
+ else if (!args.pool && /^0x[0-9a-fA-F]{40}$/.test(a)) args.pool = a;
82
+ else {
83
+ console.error(`Unknown arg: ${a}`);
84
+ args.help = true;
85
+ }
86
+ }
87
+ return args;
88
+ }
89
+
90
+ function printHelp() {
91
+ console.log(`
92
+ liquidity-depth — Uniswap v3 market depth bands
93
+
94
+ Usage:
95
+ liquidity-depth --pool <POOL_ADDRESS> [--percent 1,2,5,10] [--source tevm|viem|subgraph|auto] [--rpc <URL>] [--block N] [--timestamp ISO|SECONDS]
96
+ [--time-start ISO|SECONDS] [--time-end ISO|SECONDS] [--time-interval 1h] [--time-window 7d] [--time-count N]
97
+ [--token0-usd N] [--token1-usd N] [--usd-sizes 1000,5000,...] [--json]
98
+ liquidity-depth --pool <POOL_ADDRESS> --vault <VAULT_ADDRESS> --owner <OWNER_ADDRESS> [--withdraw-pct N | --withdraw-shares <SHARES>] [same options]
99
+ liquidity-depth --csv <FILE.csv> [--outdir reports] [same options as above]
100
+
101
+ Options:
102
+ -p, --pool Uniswap v3 pool address (e.g., 0x8ad599c3a0ff1de082011efddc58f1908eb6e6d8)
103
+ -b, --percent Percent buckets, comma-separated (default: 1,2,5,10)
104
+ -s, --subgraph Subgraph URL (default: Uniswap v3 mainnet)
105
+ --source Data source: tevm|viem|subgraph|auto (default: tevm)
106
+ -r, --rpc RPC URL (for on-chain fetching via tevm fork). Also reads RPC_URL env var
107
+ --token0-usd Override token0 USD price
108
+ --token1-usd Override token1 USD price
109
+ --assume-stable 0|1 Assume token index is $1 stable (fills missing USD)
110
+ --usd-sizes Comma-separated USD sizes for price impact (default: 1000,5000,10000,25000,50000,100000)
111
+ --block Fork/query state at the specified block number
112
+ --timestamp Resolve a historical block by timestamp (seconds or ISO-8601); requires --rpc
113
+ --time-start Beginning timestamp (inclusive) for time-series mode (seconds or ISO-8601)
114
+ --time-end Ending timestamp (inclusive, default: now) for time-series mode
115
+ --time-interval Step between snapshots in time-series mode (e.g., 1h, 30m; default: 1h)
116
+ --time-window How far back from --time-end to start when --time-start omitted (e.g., 7d)
117
+ --time-count Number of intervals to fetch (e.g., 168 for 7 days hourly); implies backwards from --time-end
118
+ --prices Price source: reserve|dexscreener|llama|coingecko|auto (default: auto; env PRICES_SOURCE)
119
+ --reserve-limit N Limit for Reserve API (default: 100; env RESERVE_LIMIT)
120
+ --debug Print debug logs to stderr for pricing/inference
121
+ --csv CSV file with a header including a 'pool' or 'address' column
122
+ --outdir Output directory for per-pool reports (default: reports)
123
+ --vault Steer vault address (to simulate withdrawal)
124
+ --owner Owner address whose shares are withdrawn (also reads OWNER env)
125
+ --withdraw-pct Percent of owner's shares to withdraw (e.g., 25)
126
+ --withdraw-shares Raw share amount to withdraw (overrides --withdraw-pct)
127
+ -j, --json Output machine-readable JSON
128
+ -h, --help Show help
129
+ -v, --version Show version
130
+ `);
131
+ }
132
+
133
+ function looksLikeAddress(x) {
134
+ return typeof x === 'string' && /^0x[0-9a-fA-F]{40}$/.test(x.trim());
135
+ }
136
+ function extractFirstAddress(x) {
137
+ if (typeof x !== 'string') return undefined;
138
+ const m = x.match(/0x[0-9a-fA-F]{40}/);
139
+ return m ? m[0] : undefined;
140
+ }
141
+ function normKey(k) {
142
+ return String(k || '').toLowerCase().replace(/[ _-]+/g, '');
143
+ }
144
+
145
+ // Minimal CSV parser: returns array of row objects keyed by header.
146
+ // Supports commas, quoted fields with double-quotes escaping.
147
+ function parseCSV(content) {
148
+ const outRows = [];
149
+ let i = 0;
150
+ const len = content.length;
151
+ const fields = [];
152
+ function readLine() {
153
+ fields.length = 0;
154
+ let field = '';
155
+ let inQuotes = false;
156
+ let sawAny = false;
157
+ while (i < len) {
158
+ const ch = content[i++];
159
+ if (!inQuotes && (ch === '\n' || ch === '\r')) {
160
+ if (ch === '\r' && content[i] === '\n') i++; // CRLF
161
+ fields.push(field);
162
+ return true;
163
+ }
164
+ if (ch === '"') {
165
+ if (inQuotes) {
166
+ if (content[i] === '"') { field += '"'; i++; }
167
+ else { inQuotes = false; }
168
+ } else {
169
+ inQuotes = true;
170
+ sawAny = true;
171
+ }
172
+ } else if (ch === ',' && !inQuotes) {
173
+ fields.push(field);
174
+ field = '';
175
+ sawAny = true;
176
+ } else {
177
+ field += ch;
178
+ sawAny = true;
179
+ }
180
+ }
181
+ if (!sawAny && field === '') return false; // EOF after last newline
182
+ fields.push(field);
183
+ return true;
184
+ }
185
+ // read header
186
+ if (!readLine()) return [];
187
+ const header = fields.map((h) => h.trim());
188
+ while (readLine()) {
189
+ const obj = {};
190
+ for (let j = 0; j < header.length; j++) obj[header[j]] = (fields[j] ?? '').trim();
191
+ outRows.push(obj);
192
+ }
193
+ return outRows;
194
+ }
195
+
196
+ async function runBatch(args) {
197
+ const csvPath = args.csv;
198
+ const outdir = args.outdir || 'reports';
199
+ const data = await fs.readFile(csvPath, 'utf8');
200
+ const rows = parseCSV(data);
201
+ if (!rows.length) {
202
+ console.error('CSV is empty or could not be parsed. Expect a header row with a pool/address column.');
203
+ process.exit(1);
204
+ }
205
+ await fs.mkdir(outdir, { recursive: true });
206
+
207
+ const pick = (obj, names) => {
208
+ // try direct keys
209
+ for (const n of names) if (obj[n] != null && String(obj[n]).trim() !== '') return String(obj[n]).trim();
210
+ // try normalized keys
211
+ const map = Object.fromEntries(Object.keys(obj).map((k) => [normKey(k), k]));
212
+ for (const n of names) {
213
+ const key = map[normKey(n)];
214
+ if (key && obj[key] != null && String(obj[key]).trim() !== '') return String(obj[key]).trim();
215
+ }
216
+ return undefined;
217
+ };
218
+
219
+ function parseFeeToUint24(v) {
220
+ if (v == null) return undefined;
221
+ const s = String(v).trim();
222
+ if (!s) return undefined;
223
+ // raw number like 500, 3000, 10000
224
+ if (/^\d+$/.test(s)) {
225
+ const n = Number(s);
226
+ if (Number.isFinite(n) && n > 0) return n;
227
+ }
228
+ // like 30bps or 5 bps
229
+ const mBps = s.match(/([0-9]+(?:\.[0-9]+)?)\s*bps/i);
230
+ if (mBps) {
231
+ const n = Number(mBps[1]);
232
+ if (Number.isFinite(n)) return Math.round(n * 100);
233
+ }
234
+ // like 0.3% or 1 %
235
+ const mPct = s.match(/([0-9]+(?:\.[0-9]+)?)\s*%/);
236
+ if (mPct) {
237
+ const n = Number(mPct[1]);
238
+ if (Number.isFinite(n)) return Math.round(n * 1e4);
239
+ }
240
+ return undefined;
241
+ }
242
+
243
+ async function resolvePoolFromTokens(row, rpcUrl) {
244
+ // attempt to find token addresses + fee
245
+ let t0 = pick(row, ['token0', 'token0_address', 'token 0', 'token 0 address', 'tokenA', 'base']);
246
+ let t1 = pick(row, ['token1', 'token1_address', 'token 1', 'token 1 address', 'tokenB', 'quote']);
247
+ let fee = pick(row, ['fee', 'fee_tier', 'fee tier', 'feebps']);
248
+
249
+ // If not found, scan all values for first two distinct addresses
250
+ if (!looksLikeAddress(t0 || '') || !looksLikeAddress(t1 || '')) {
251
+ const found = [];
252
+ for (const v of Object.values(row)) {
253
+ const addr = extractFirstAddress(String(v || ''));
254
+ if (addr && !found.includes(addr)) found.push(addr);
255
+ if (found.length >= 2) break;
256
+ }
257
+ if (!looksLikeAddress(t0 || '') && found[0]) t0 = found[0];
258
+ if (!looksLikeAddress(t1 || '') && found[1]) t1 = found[1];
259
+ }
260
+
261
+ // parse fee
262
+ const feeNum = parseFeeToUint24(fee);
263
+ if (!looksLikeAddress(t0 || '') || !looksLikeAddress(t1 || '') || !feeNum) return undefined;
264
+ if (!rpcUrl) return undefined;
265
+
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();
269
+ try {
270
+ const pool = await client.readContract({ address: UNISWAP_V3_FACTORY, abi: FACTORY_ABI, functionName: 'getPool', args: [getAddress(t0), getAddress(t1), feeNum] });
271
+ if (pool && pool !== '0x0000000000000000000000000000000000000000') return pool;
272
+ } catch (_) {
273
+ // ignore
274
+ }
275
+ return undefined;
276
+ }
277
+
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
+ for (let idx = 0; idx < rows.length; idx++) {
300
+ const row = rows[idx];
301
+ let pool = pick(row, ['pool', 'address', 'pool_address', 'id', 'Pool', 'Address', 'contract_address', 'contract address', 'pair_address', 'pair address']);
302
+ if (!looksLikeAddress(pool || '')) {
303
+ // try extract from any cell (e.g., Etherscan/Uniswap URL)
304
+ for (const v of Object.values(row)) {
305
+ const found = extractFirstAddress(String(v || ''));
306
+ if (found) { pool = found; break; }
307
+ }
308
+ }
309
+ if (!looksLikeAddress(pool || '')) {
310
+ // try factory lookup using tokens + fee
311
+ const viaFactory = await resolvePoolFromTokens(row, args.rpc);
312
+ if (viaFactory) pool = viaFactory;
313
+ }
314
+ if (!looksLikeAddress(pool || '')) {
315
+ console.warn(`Row ${idx + 1}: skipping (no valid pool address)`);
316
+ continue;
317
+ }
318
+
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));
349
+
350
+ if (args.debug) {
351
+ const dbg = [];
352
+ if (vault) dbg.push(`vault=${vault}`);
353
+ if (owner) dbg.push(`owner=${owner}`);
354
+ if (wpct) dbg.push(`withdraw_pct=${wpct}`);
355
+ if (wshares) dbg.push(`withdraw_shares=${wshares}`);
356
+ if (dbg.length) console.error(`[batch] row ${idx + 1} overrides: ${dbg.join(' ')}`);
357
+ }
358
+
359
+ const argsArr = [scriptPath, '--pool', pool, ...per];
360
+ try {
361
+ const { stdout, stderr } = await execFile(process.execPath, argsArr, { env: process.env, maxBuffer: 5 * 1024 * 1024 });
362
+ const baseName = `${pool.toLowerCase()}`;
363
+ 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
+ }
370
+ console.log(`Wrote: ${file}`);
371
+ } catch (err) {
372
+ const errMsg = String(err?.stderr || err?.stdout || err?.message || err);
373
+ const errFile = path.join(outdir, `error-${pool.toLowerCase()}.txt`);
374
+ await fs.writeFile(errFile, errMsg, 'utf8');
375
+ console.error(`Failed row ${idx + 1} (${pool}): ${err?.message || err}`);
376
+ console.error(` -> see ${errFile}`);
377
+ }
378
+ }
379
+ }
380
+
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
+ async function runTimeSeries(args) {
417
+ if (!args.pool) {
418
+ console.error('Time-series mode requires --pool');
419
+ process.exit(1);
420
+ }
421
+ if (args.block) {
422
+ console.error('Time-series mode cannot be combined with --block. Use --time-start/--time-window/--time-count with --timestamp.');
423
+ process.exit(1);
424
+ }
425
+ if (!args.rpc) {
426
+ console.error('Time-series mode requires --rpc (or RPC_URL env) so timestamps can be resolved to blocks.');
427
+ process.exit(1);
428
+ }
429
+
430
+ let intervalSec;
431
+ try {
432
+ intervalSec = parseDurationArg(args.timeInterval ?? '1h');
433
+ } catch (e) {
434
+ console.error(`Invalid --time-interval value: ${e?.message || e}`);
435
+ process.exit(1);
436
+ }
437
+ if (intervalSec <= 0n) {
438
+ console.error('--time-interval must be greater than zero');
439
+ process.exit(1);
440
+ }
441
+
442
+ let endSec;
443
+ try {
444
+ endSec = args.timeEnd != null ? parseTimestampArg(args.timeEnd) : BigInt(Math.floor(Date.now() / 1000));
445
+ } catch (e) {
446
+ console.error(`Invalid --time-end value: ${e?.message || e}`);
447
+ process.exit(1);
448
+ }
449
+
450
+ let startSec;
451
+ if (args.timeStart != null) {
452
+ try {
453
+ startSec = parseTimestampArg(args.timeStart);
454
+ } catch (e) {
455
+ console.error(`Invalid --time-start value: ${e?.message || e}`);
456
+ process.exit(1);
457
+ }
458
+ }
459
+ if (startSec == null && args.timeWindow != null) {
460
+ try {
461
+ const windowSec = parseDurationArg(args.timeWindow);
462
+ startSec = endSec > windowSec ? endSec - windowSec : 0n;
463
+ } catch (e) {
464
+ console.error(`Invalid --time-window value: ${e?.message || e}`);
465
+ process.exit(1);
466
+ }
467
+ }
468
+
469
+ let count = undefined;
470
+ if (args.timeCount != null) {
471
+ if (!Number.isFinite(args.timeCount) || args.timeCount <= 0) {
472
+ console.error('--time-count must be a positive integer');
473
+ process.exit(1);
474
+ }
475
+ count = Math.floor(args.timeCount);
476
+ }
477
+ if (startSec == null) {
478
+ if (count != null) {
479
+ const span = intervalSec * BigInt(Math.max(count - 1, 0));
480
+ startSec = endSec > span ? endSec - span : 0n;
481
+ } else {
482
+ console.error('Time-series mode requires --time-start, --time-window, or --time-count');
483
+ process.exit(1);
484
+ }
485
+ }
486
+
487
+ if (startSec > endSec) {
488
+ [startSec, endSec] = [endSec, startSec];
489
+ }
490
+
491
+ const timestamps = [];
492
+ const maxPoints = 10000;
493
+ for (let ts = startSec; ts <= endSec; ts += intervalSec) {
494
+ timestamps.push(ts);
495
+ if (count != null && timestamps.length >= count) break;
496
+ if (timestamps.length > maxPoints) {
497
+ console.error(`Time-series would produce more than ${maxPoints} snapshots. Increase --time-interval or reduce the window/count.`);
498
+ process.exit(1);
499
+ }
500
+ }
501
+ if (!timestamps.length) timestamps.push(startSec);
502
+ if (count == null && timestamps[timestamps.length - 1] < endSec) {
503
+ timestamps.push(endSec);
504
+ }
505
+ if (count != null && timestamps.length > count) {
506
+ timestamps.splice(0, timestamps.length - count);
507
+ }
508
+
509
+ const tsStrings = timestamps.map((ts) => ts.toString());
510
+ if (!tsStrings.length) {
511
+ console.error('No timestamps resolved for time-series mode');
512
+ process.exit(1);
513
+ }
514
+
515
+ if (!args.json) {
516
+ const startIso = formatTimestampUTC(BigInt(tsStrings[0]));
517
+ const endIso = formatTimestampUTC(BigInt(tsStrings[tsStrings.length - 1]));
518
+ const intervalHuman = args.timeInterval ?? '1h';
519
+ console.log(`Time-series: ${tsStrings.length} snapshots from ${startIso || tsStrings[0]} to ${endIso || tsStrings[tsStrings.length - 1]} every ~${intervalHuman}`);
520
+ }
521
+
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
+ const jsonResults = [];
543
+ for (let i = 0; i < tsStrings.length; i++) {
544
+ const tsStr = tsStrings[i];
545
+ const perArgs = [scriptPath, '--pool', args.pool, '--timestamp', tsStr, ...base];
546
+ const label = formatTimestampUTC(BigInt(tsStr)) || tsStr;
547
+ if (!args.json) {
548
+ console.log(`\n=== ${label} (unix ${tsStr}) ===`);
549
+ }
550
+ try {
551
+ const { stdout, stderr } = await execFile(process.execPath, perArgs, { env: process.env, maxBuffer: 5 * 1024 * 1024 });
552
+ if (args.json) {
553
+ const text = stdout.trim();
554
+ 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
+ }
564
+ } else {
565
+ jsonResults.push(null);
566
+ }
567
+ } else {
568
+ process.stdout.write(stdout);
569
+ if (!stdout.endsWith('\n')) console.log('');
570
+ }
571
+ if (args.debug && stderr) process.stderr.write(stderr);
572
+ } catch (err) {
573
+ 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
+ process.exit(1);
577
+ }
578
+ }
579
+
580
+ if (args.json) {
581
+ console.log(JSON.stringify(jsonResults, null, 2));
582
+ }
583
+ }
584
+
585
+ // Strip noisy logs (e.g., TEVM warnings) from report files while preserving CLI output
586
+ function cleanReportOutput(s) {
587
+ 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;
604
+ }
605
+ }
606
+
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
+
635
+ let percentBuckets;
636
+ try {
637
+ percentBuckets = parsePercentBuckets(args.percent);
638
+ } catch (e) {
639
+ console.error(`Invalid --percent value: ${e?.message || e}`);
640
+ process.exit(1);
641
+ }
642
+ if (!percentBuckets.length) {
643
+ console.error('No valid percent buckets parsed.');
644
+ process.exit(1);
645
+ }
646
+
647
+ const wantsWithdraw =
648
+ args.vault && (Number.isFinite(args.withdrawPct) || args.withdrawShares);
649
+ let sharedClient = null;
650
+ let sharedBlockTag;
651
+ if (wantsWithdraw) {
652
+ if (!args.rpc) {
653
+ console.error('Missing --rpc (or RPC_URL env) for tevm source.');
654
+ process.exit(1);
655
+ }
656
+ const blockContext = await resolveAnalysisBlockContext({
657
+ block: args.block,
658
+ timestamp: args.timestamp,
659
+ rpcUrl: args.rpc,
660
+ debug: args.debug,
661
+ });
662
+ sharedBlockTag = blockContext.blockTag;
663
+ sharedClient = await createForkClient({
664
+ rpcUrl: args.rpc,
665
+ blockTag: sharedBlockTag,
666
+ });
667
+ }
668
+
669
+ 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
+ }
700
+
701
+ let after = null;
702
+ if (before.usedSource === 'tevm' && wantsWithdraw) {
703
+ const viemChain = await resolveViemChain(args.rpc);
704
+ await simulateVaultWithdraw({
705
+ client: before.client,
706
+ vault: args.vault,
707
+ owner: args.owner,
708
+ withdrawPct: args.withdrawPct,
709
+ withdrawShares: args.withdrawShares,
710
+ debug: !!args.debug,
711
+ cliPool: args.pool,
712
+ viemChain,
713
+ });
714
+ after = await analyzePool({
715
+ poolAddress: args.pool,
716
+ percentBuckets,
717
+ source: 'tevm',
718
+ rpcUrl: args.rpc,
719
+ token0USD: args.token0USD,
720
+ token1USD: args.token1USD,
721
+ assumeStable: args.assumeStable,
722
+ usdSizes: args.usdSizes,
723
+ prices: args.prices,
724
+ reserveLimit: args.reserveLimit,
725
+ debug: args.debug,
726
+ client: before.client,
727
+ blockTag: before.blockTag,
728
+ });
729
+ if (args.debug) {
730
+ console.error(`[after] tick: ${before.snap.tick} -> ${after.snap.tick}`);
731
+ console.error(
732
+ `[after] liquidity: ${before.snap.liquidity.toString()} -> ${after.snap.liquidity.toString()}`
733
+ );
734
+ }
735
+ }
736
+
737
+ if (args.json) {
738
+ const output = after
739
+ ? {
740
+ before: serializeAnalysis(args, before),
741
+ after: serializeAnalysis(args, after),
742
+ }
743
+ : serializeAnalysis(args, before);
744
+ console.log(JSON.stringify(output, null, 2));
745
+ return;
746
+ }
747
+
748
+ printAnalysisSummary(args, before);
749
+ if (after) {
750
+ console.log('');
751
+ console.log('--- After Withdraw ---');
752
+ printAnalysisSummary(args, after);
753
+ }
754
+ }
755
+
756
+ function serializeAnalysis(args, analysis) {
757
+ const snap = analysis.snap;
758
+ const blockNumber =
759
+ snap.meta?.blockNumber ?? (analysis.blockNumber !== undefined ? analysis.blockNumber : undefined);
760
+ const timestamp =
761
+ snap.meta?.blockTimestamp ??
762
+ (analysis.timestampSec !== undefined ? analysis.timestampSec : undefined);
763
+ return {
764
+ pool: snap.meta.poolId,
765
+ tokens: {
766
+ token0: snap.meta.token0,
767
+ token1: snap.meta.token1,
768
+ },
769
+ ethPriceUSD: snap.meta.ethPriceUSD,
770
+ tick: snap.tick,
771
+ sqrtPriceX96: snap.sqrtPriceX96.toString(),
772
+ liquidity: snap.liquidity.toString(),
773
+ feePips: snap.feePips,
774
+ range: snap.meta.range,
775
+ percentBuckets: analysis.percentBuckets,
776
+ block: {
777
+ number: blockNumber != null ? String(blockNumber) : undefined,
778
+ timestamp: timestamp != null ? Number(timestamp) : undefined,
779
+ iso: formatTimestampUTC(timestamp),
780
+ input:
781
+ args.block ??
782
+ args.timestamp ??
783
+ (typeof analysis.blockTag === 'string' ? analysis.blockTag : undefined),
784
+ },
785
+ ...analysis.result,
786
+ priceImpacts: analysis.priceImpacts
787
+ ? {
788
+ usdSizes: analysis.usdSizes,
789
+ buyPct: analysis.priceImpacts.buyPct,
790
+ sellPct: analysis.priceImpacts.sellPct,
791
+ }
792
+ : undefined,
793
+ prices: {
794
+ token0: {
795
+ address: snap.meta.token0.id,
796
+ symbol: snap.meta.token0.symbol,
797
+ usd: analysis.token0USD,
798
+ source: analysis.priceSource0,
799
+ },
800
+ token1: {
801
+ address: snap.meta.token1.id,
802
+ symbol: snap.meta.token1.symbol,
803
+ usd: analysis.token1USD,
804
+ source: analysis.priceSource1,
805
+ },
806
+ },
807
+ buyCumToken1: analysis.result.buyCumToken1.map((value) => value.toString()),
808
+ sellCumToken0: analysis.result.sellCumToken0.map((value) => value.toString()),
809
+ };
810
+ }
811
+
812
+ function printAnalysisSummary(args, analysis) {
813
+ const snap = analysis.snap;
814
+ const result = analysis.result;
815
+ const fmtUSD = (value) =>
816
+ new Intl.NumberFormat('en-US', {
817
+ style: 'currency',
818
+ currency: 'USD',
819
+ maximumFractionDigits: 0,
820
+ }).format(value);
821
+
822
+ console.log(`Pool: ${args.pool.toLowerCase()} [source: ${analysis.usedSource}]`);
823
+ const blockNumber =
824
+ snap.meta?.blockNumber ?? (analysis.blockNumber !== undefined ? analysis.blockNumber : undefined);
825
+ const timestamp =
826
+ snap.meta?.blockTimestamp ??
827
+ (analysis.timestampSec !== undefined ? analysis.timestampSec : undefined);
828
+ const blockInfoParts = [];
829
+ if (blockNumber != null) blockInfoParts.push(`#${String(blockNumber)}`);
830
+ const isoTimestamp = formatTimestampUTC(timestamp);
831
+ if (isoTimestamp) blockInfoParts.push(isoTimestamp);
832
+ if (blockNumber == null && typeof analysis.blockTag === 'string') {
833
+ blockInfoParts.push(analysis.blockTag);
834
+ }
835
+ if (blockInfoParts.length) console.log(`Block: ${blockInfoParts.join(' | ')}`);
836
+ console.log(
837
+ `Tokens: ${snap.meta.token0.symbol} (${snap.meta.token0.id}) / ${snap.meta.token1.symbol} (${snap.meta.token1.id})`
838
+ );
839
+ if (snap.meta?.ethPriceUSD) console.log(`ETH/USD: ${snap.meta.ethPriceUSD.toFixed(2)}`);
840
+ const feePct = snap.feePips / 10_000;
841
+ const feeBps = snap.feePips / 100;
842
+ console.log(`Tick: ${snap.tick} Fee: ${feePct.toFixed(2)}% (${feeBps.toFixed(2)} bps)`);
843
+ if (analysis.token0USD || analysis.token1USD) {
844
+ const fmtPrice = (value) =>
845
+ value
846
+ ? `$${value.toLocaleString(undefined, { maximumFractionDigits: 6 })}`
847
+ : 'n/a';
848
+ console.log(
849
+ `Prices: ${snap.meta.token0.symbol} ${fmtPrice(analysis.token0USD)} (${analysis.priceSource0 || 'n/a'}), ${snap.meta.token1.symbol} ${fmtPrice(analysis.token1USD)} (${analysis.priceSource1 || 'n/a'})`
850
+ );
851
+ }
852
+ console.log(`Range: [${snap.meta.range.start}, ${snap.meta.range.end}] for ±${snap.meta.farPercent}%`);
853
+ console.log('');
854
+
855
+ const labels = analysis.percentBuckets.map((percent) => `${percent}%`);
856
+ console.log('Buy cumulative (token1 in → price up):');
857
+ labels.forEach((label, index) =>
858
+ console.log(` +${label.padEnd(4)}: ${fmtUSD(result.buyCumUSD[index])}`)
859
+ );
860
+ console.log('Sell cumulative (token0 in → price down):');
861
+ labels.forEach((label, index) =>
862
+ console.log(` -${label.padEnd(4)}: ${fmtUSD(result.sellCumUSD[index])}`)
863
+ );
864
+ console.log('');
865
+ console.log(`Headline Depth +2% : ${fmtUSD(result.depthPlus2USD)}`);
866
+ console.log(`Headline Depth -2% : ${fmtUSD(result.depthMinus2USD)}`);
867
+
868
+ if (!analysis.priceImpacts && analysis.usdSizes.length) {
869
+ console.log('');
870
+ console.log('Note: USD sizes provided but missing USD prices.');
871
+ console.log(' Pass --token0-usd and --token1-usd to enable price impact output.');
872
+ } else if (analysis.priceImpacts) {
873
+ console.log('');
874
+ console.log('Price impact by USD size:');
875
+ analysis.usdSizes.forEach((usd, index) => {
876
+ const buy = analysis.priceImpacts.buyPct[index];
877
+ const sell = analysis.priceImpacts.sellPct[index];
878
+ const fmtPct = (value) => `${value >= 0 ? '+' : ''}${value.toFixed(5)}%`;
879
+ console.log(` $${usd.toLocaleString()}: buy ${fmtPct(buy)} sell ${fmtPct(sell)}`);
880
+ });
881
+ }
882
+ }
883
+
884
+ main().catch((err) => {
885
+ console.error(err?.stack || err?.message || String(err));
886
+ process.exit(1);
887
+ });