-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcli.js
More file actions
255 lines (223 loc) · 14.1 KB
/
Copy pathcli.js
File metadata and controls
255 lines (223 loc) · 14.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
const fs = require('fs');
const path = require('path');
const { parseBlocksWithUndo } = require('./parser');
const { analyzeBlocks } = require('./analyzer');
const args = process.argv.slice(2);
if (args[0] !== '--block' || args.length < 4) {
console.error(JSON.stringify({ ok: false, error: { code: "INVALID_ARGS", message: "Usage: node cli.js --block <blk.dat> <rev.dat> <xor.dat>" } }));
process.exit(1);
}
const blkFile = args[1];
const revFile = args[2];
const xorFile = args[3];
try {
const blocks = parseBlocksWithUndo(blkFile, revFile, xorFile);
const result = analyzeBlocks(blocks);
// Construct final JSON
const blkStem = path.basename(blkFile, '.dat');
const outJsonPath = path.join('out', `${blkStem}.json`);
const outMdPath = path.join('out', `${blkStem}.md`);
fs.mkdirSync('out', { recursive: true });
// Instead of JSON.stringify on the entire massive object, we manually write out the JSON
// sequentially to avoid V8 "Invalid string length" errors when gigabytes of RAM are required.
const stream = fs.createWriteStream(outJsonPath);
stream.write(`{\n "ok": true,\n "mode": "chain_analysis",\n "file": "${path.basename(blkFile)}",\n "block_count": ${blocks.length},\n`);
stream.write(` "analysis_summary": ${JSON.stringify(result.fileStats, null, 2)},\n`);
stream.write(` "blocks": [\n`);
for (let b = 0; b < result.blocks.length; b++) {
const block = result.blocks[b];
stream.write(` {\n`);
stream.write(` "block_hash": "${block.block_hash}",\n`);
stream.write(` "block_height": ${block.block_height},\n`);
stream.write(` "tx_count": ${block.tx_count},\n`);
stream.write(` "analysis_summary": ${JSON.stringify(block.analysis_summary, null, 2).split('\\n').join('\\n ')},\n`);
stream.write(` "transactions": [\n`);
for (let t = 0; t < block.transactions.length; t++) {
const txStr = JSON.stringify(block.transactions[t]);
stream.write(` ${txStr}${t < block.transactions.length - 1 ? ',' : ''}\n`);
}
stream.write(` ]\n`);
stream.write(` }${b < result.blocks.length - 1 ? ',' : ''}\n`);
}
stream.write(` ]\n}\n`);
stream.end();
// Wait for the JSON stream to fully flush before writing markdown and exiting
stream.on('finish', () => {
// Generate Markdown
let md = `# Chain Analysis Report: ${path.basename(blkFile)}\n\n`;
md += `> Automated chain analysis report generated by the Sherlock engine.\n\n`;
md += `## File Overview\n\n`;
md += `| Property | Value |\n`;
md += `|---|---|\n`;
md += `| **Source File** | ${path.basename(blkFile)} |\n`;
md += `| **Blocks** | ${blocks.length} |\n`;
md += `| **Total Transactions Analyzed** | ${result.fileStats.total_transactions_analyzed.toLocaleString()} |\n`;
md += `| **Flagged Transactions** | ${result.fileStats.flagged_transactions.toLocaleString()} (${(result.fileStats.flagged_transactions / result.fileStats.total_transactions_analyzed * 100).toFixed(1)}%) |\n`;
md += `| **Heuristics Applied** | ${result.fileStats.heuristics_applied.join(', ')} |\n\n`;
md += `## Summary Statistics\n\n`;
md += `### Fee Rate Distribution\n\n`;
md += `| Metric | Value (sat/vB) |\n`;
md += `|---|---|\n`;
md += `| Min | ${result.fileStats.fee_rate_stats.min_sat_vb} |\n`;
md += `| Max | ${result.fileStats.fee_rate_stats.max_sat_vb} |\n`;
md += `| Median | ${result.fileStats.fee_rate_stats.median_sat_vb} |\n`;
md += `| Mean | ${result.fileStats.fee_rate_stats.mean_sat_vb} |\n\n`;
md += `### Script Type Breakdown\n\n`;
md += `| Script Type | Count |\n`;
md += `|---|---|\n`;
for (const [script, count] of Object.entries(result.fileStats.script_type_distribution)) {
md += `| ${script} | ${count.toLocaleString()} |\n`;
}
md += `\n`;
md += `---\n\n`;
md += `## Per-Block Analysis\n\n`;
for (let i = 0; i < blocks.length; i++) {
const blockObj = blocks[i];
const blockRes = result.blocks[i];
md += `### Block ${blockObj.blockHeight} (Hash: \`${blockObj.blockHash}\`)\n\n`;
md += `| Property | Value |\n`;
md += `|---|---|\n`;
md += `| **Timestamp** | ${new Date(blockObj.timestamp * 1000).toISOString()} |\n`;
md += `| **Transactions** | ${blockObj.txCount.toLocaleString()} |\n`;
md += `| **Flagged** | ${blockRes.analysis_summary.flagged_transactions.toLocaleString()} |\n\n`;
// Heuristic findings
md += `#### Heuristic Findings\n\n`;
const hc = { cioh: 0, change_detection: 0, consolidation: 0, address_reuse: 0, round_number_payment: 0, coinjoin: 0, op_return: 0, self_transfer: 0, peeling_chain: 0 };
const classCounts = {};
const notable = [];
for (const tx of blockRes.transactions) {
if (tx.heuristics.cioh.detected) hc.cioh++;
if (tx.heuristics.change_detection.detected) hc.change_detection++;
if (tx.heuristics.consolidation.detected) hc.consolidation++;
if (tx.heuristics.address_reuse.detected) hc.address_reuse++;
if (tx.heuristics.round_number_payment.detected) hc.round_number_payment++;
if (tx.heuristics.coinjoin.detected) hc.coinjoin++;
if (tx.heuristics.op_return.detected) hc.op_return++;
if (tx.heuristics.self_transfer.detected) hc.self_transfer++;
if (tx.heuristics.peeling_chain.detected) hc.peeling_chain++;
classCounts[tx.classification] = (classCounts[tx.classification] || 0) + 1;
if (tx.classification !== 'unknown' && tx.classification !== 'simple_payment') {
notable.push(tx);
}
}
md += `| Heuristic | Detected | % of Txs |\n`;
md += `|---|---|---|\n`;
for (const [h, count] of Object.entries(hc)) {
const pct = blockObj.txCount > 0 ? (count / blockObj.txCount * 100).toFixed(1) : '0.0';
md += `| ${h} | ${count.toLocaleString()} | ${pct}% |\n`;
}
md += `\n`;
// Classification distribution
md += `#### Transaction Classifications\n\n`;
md += `| Classification | Count | % |\n`;
md += `|---|---|---|\n`;
for (const cls of ['simple_payment', 'consolidation', 'batch_payment', 'coinjoin', 'self_transfer', 'unknown']) {
const count = classCounts[cls] || 0;
const pct = blockObj.txCount > 0 ? (count / blockObj.txCount * 100).toFixed(1) : '0.0';
if (count > 0) {
md += `| ${cls} | ${count.toLocaleString()} | ${pct}% |\n`;
}
}
md += `\n`;
// Fee Rate stats for this block
const bfr = blockRes.analysis_summary.fee_rate_stats;
md += `#### Fee Rate (this block)\n\n`;
md += `| Metric | sat/vB |\n`;
md += `|---|---|\n`;
md += `| Min | ${bfr.min_sat_vb} |\n`;
md += `| Max | ${bfr.max_sat_vb} |\n`;
md += `| Median | ${bfr.median_sat_vb} |\n`;
md += `| Mean | ${bfr.mean_sat_vb} |\n\n`;
// Notable transactions
md += `#### Notable Transactions\n\n`;
if (notable.length === 0) {
md += `No notable transactions found.\n\n`;
} else {
md += `Showing up to 15 of ${notable.length.toLocaleString()} notable transactions:\n\n`;
for (const ntx of notable.slice(0, 15)) {
// Build a details string with confidence info
const details = [];
if (ntx.heuristics.consolidation.detected && ntx.heuristics.consolidation.confidence) {
details.push(`consolidation confidence: ${ntx.heuristics.consolidation.confidence}`);
}
if (ntx.heuristics.coinjoin.detected && ntx.heuristics.coinjoin.matching_outputs) {
details.push(`${ntx.heuristics.coinjoin.matching_outputs} matching outputs`);
}
if (ntx.heuristics.self_transfer.detected && ntx.heuristics.self_transfer.confidence) {
details.push(`self-transfer confidence: ${ntx.heuristics.self_transfer.confidence}`);
}
if (ntx.inputs && ntx.outputs) {
details.push(`${ntx.inputs.length} inputs → ${ntx.outputs.length} outputs`);
}
const detailStr = details.length > 0 ? ` — ${details.join(', ')}` : '';
md += `- [\`${ntx.txid}\`](https://mempool.space/tx/${ntx.txid}) — **${ntx.classification}**${detailStr}\n`;
}
if (notable.length > 15) md += `- *...and ${notable.length - 15} more*\n`;
md += `\n`;
}
// ---- Narrative Analysis ----
md += `#### Analysis Summary\n\n`;
// Determine dominant classification
const sortedClasses = Object.entries(classCounts).filter(([c]) => c !== 'unknown').sort((a, b) => b[1] - a[1]);
const dominant = sortedClasses[0];
const flaggedPct = blockObj.txCount > 0 ? (blockRes.analysis_summary.flagged_transactions / blockObj.txCount * 100).toFixed(1) : '0';
md += `This block contains ${blockObj.txCount.toLocaleString()} transactions, of which ${blockRes.analysis_summary.flagged_transactions.toLocaleString()} (${flaggedPct}%) triggered at least one heuristic. `;
if (dominant) {
md += `The dominant classification is **${dominant[0]}** at ${(dominant[1] / blockObj.txCount * 100).toFixed(1)}% of all transactions. `;
}
md += `Change detection fired on ${hc.change_detection.toLocaleString()} transactions (${blockObj.txCount > 0 ? (hc.change_detection / blockObj.txCount * 100).toFixed(1) : '0'}%), and CIOH linked multiple inputs in ${hc.cioh.toLocaleString()} transactions.\n\n`;
// Highlight specific interesting patterns
const narrativeParts = [];
if (hc.consolidation > 0) {
// Find the largest consolidation (most inputs)
const biggestConsol = blockRes.transactions
.filter(t => t.heuristics.consolidation.detected && t.inputs)
.sort((a, b) => b.inputs.length - a.inputs.length)[0];
if (biggestConsol && biggestConsol.inputs.length >= 5) {
narrativeParts.push(`The largest consolidation was [\`${biggestConsol.txid.substring(0, 16)}...\`](https://mempool.space/tx/${biggestConsol.txid}) which swept **${biggestConsol.inputs.length} inputs** into ${biggestConsol.outputs.length} output${biggestConsol.outputs.length > 1 ? 's' : ''} — a clear sign of UTXO housekeeping, likely timed for low-fee conditions.`);
} else {
narrativeParts.push(`${hc.consolidation} consolidation transactions were detected, indicating active UTXO management in this block.`);
}
}
if (hc.coinjoin > 0) {
narrativeParts.push(`${hc.coinjoin} potential CoinJoin transaction${hc.coinjoin > 1 ? 's were' : ' was'} identified, suggesting privacy-seeking activity among some participants.`);
}
if (hc.op_return > 0) {
const opPct = blockObj.txCount > 0 ? (hc.op_return / blockObj.txCount * 100).toFixed(1) : '0';
narrativeParts.push(`OP_RETURN outputs appeared in ${hc.op_return} transactions (${opPct}%), indicating non-payment data-embedding activity on-chain.`);
}
if (hc.peeling_chain > 0) {
const peelPct = blockObj.txCount > 0 ? (hc.peeling_chain / blockObj.txCount * 100).toFixed(1) : '0';
narrativeParts.push(`Peeling chain patterns were detected in ${hc.peeling_chain} transactions (${peelPct}%), where a large UTXO is repeatedly split into a small payment and large change — a common pattern in exchange withdrawals and mixing services.`);
}
if (narrativeParts.length > 0) {
md += narrativeParts.join(' ') + `\n\n`;
}
if (i < blocks.length - 1) md += `---\n\n`;
}
md += `---\n\n`;
md += `## Heuristic Descriptions\n\n`;
md += `| ID | Name | Description |\n`;
md += `|---|---|---|\n`;
md += `| cioh | Common Input Ownership | All inputs likely belong to the same entity |\n`;
md += `| change_detection | Change Detection | Identifies likely change output via script type or round-number analysis |\n`;
md += `| consolidation | Consolidation | Many inputs → 1-2 outputs, typical wallet maintenance |\n`;
md += `| address_reuse | Address Reuse | Same address in both inputs and outputs |\n`;
md += `| round_number_payment | Round Number Payment | Outputs with round BTC amounts (likely payments) |\n`;
md += `| coinjoin | CoinJoin Detection | Equal-value outputs from multiple inputs (privacy technique) |\n`;
md += `| op_return | OP_RETURN Analysis | Detects OP_RETURN data-embedding outputs |\n`;
md += `| self_transfer | Self-Transfer | All inputs/outputs share same script type, no payment component |\n`;
md += `| peeling_chain | Peeling Chain | A large UTXO is repeatedly split into a small payment and a large change output |\n\n`;
md += `*Report generated by Sherlock Chain Analysis Engine*\n`;
fs.writeFileSync(outMdPath, md);
// Exits 0 on success
process.exit(0);
});
stream.on('error', (err) => {
console.error(JSON.stringify({ ok: false, error: { code: "WRITE_ERROR", message: err.message } }));
process.exit(1);
});
} catch (err) {
console.error(JSON.stringify({ ok: false, error: { code: "PARSING_ERROR", message: err.message } }));
process.exit(1);
}