chain-insights 0.2.31 → 0.3.3
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 +40 -14
- package/dist/cases-Cp9DUbEV.mjs +6 -0
- package/dist/{cases-c0iV-XLI.cjs → cases-sTY5aXav.cjs} +3 -3
- package/dist/cli.cjs +122 -66
- package/dist/cli.mjs +122 -66
- package/dist/cli.mjs.map +1 -1
- package/dist/{viz-Da9YWN_I.cjs → data-extractor-Cavd7wHk.cjs} +11 -34
- package/dist/{viz-DkJyqlUu.mjs → data-extractor-DZUJu1Bz.mjs} +3 -32
- package/dist/data-extractor-DZUJu1Bz.mjs.map +1 -0
- package/dist/{dossier-Br62hCG7.cjs → dossier-BXy57V4-.cjs} +13 -1
- package/dist/{dossier-Bl0NkJKC.mjs → dossier-Bjpcbcxa.mjs} +4 -2
- package/dist/{dossier-Bl0NkJKC.mjs.map → dossier-Bjpcbcxa.mjs.map} +1 -1
- package/dist/export-BqTCO9lP.mjs +591 -0
- package/dist/export-BqTCO9lP.mjs.map +1 -0
- package/dist/export-DsXgtCwO.cjs +592 -0
- package/dist/index.cjs +1 -1
- package/dist/index.mjs +1 -1
- package/dist/{init-DBC9Ml33.mjs → init-DLBL_nVG.mjs} +27 -1
- package/dist/{init-DBC9Ml33.mjs.map → init-DLBL_nVG.mjs.map} +1 -1
- package/dist/{init-CFaUWgjK.cjs → init-zqbd7i-_.cjs} +26 -0
- package/dist/mcp-proxy.cjs +215 -77
- package/dist/mcp-proxy.d.cts.map +1 -1
- package/dist/mcp-proxy.d.mts.map +1 -1
- package/dist/mcp-proxy.mjs +215 -77
- package/dist/mcp-proxy.mjs.map +1 -1
- package/dist/{public-tools-BwguvIsf.cjs → public-tools-BvMb3H2P.cjs} +701 -1479
- package/dist/{public-tools-DoRNhMn9.mjs → public-tools-wJoAFDFa.mjs} +700 -1479
- package/dist/public-tools-wJoAFDFa.mjs.map +1 -0
- package/dist/{resolver-D7VBb0uB.mjs → resolver-2jXNtWQO.mjs} +12 -29
- package/dist/resolver-2jXNtWQO.mjs.map +1 -0
- package/dist/{resolver-BUU7ZgW-.cjs → resolver-CZdQwKvh.cjs} +11 -28
- package/dist/{runner-BCDeBYsR.cjs → runner-BhZ4lnF1.cjs} +2 -2
- package/dist/{runner-CTFK0Qcg.mjs → runner-DIJSbkjc.mjs} +3 -3
- package/dist/{runner-CTFK0Qcg.mjs.map → runner-DIJSbkjc.mjs.map} +1 -1
- package/dist/{selector-CTUiQrzI.mjs → selector-CF2o5gxN.mjs} +2 -2
- package/dist/{selector-CTUiQrzI.mjs.map → selector-CF2o5gxN.mjs.map} +1 -1
- package/dist/{selector-DBS2jYH4.cjs → selector-DfAMZEC9.cjs} +1 -1
- package/dist/{session-DwyikazY.cjs → session-BT7VpbAd.cjs} +13 -1
- package/dist/{session-Bha3zFrx.mjs → session-DROyhebe.mjs} +4 -2
- package/dist/{session-Bha3zFrx.mjs.map → session-DROyhebe.mjs.map} +1 -1
- package/dist/{store-BT2SCcQr.mjs → store-CTtqQtaE.mjs} +10 -4
- package/dist/{store-BT2SCcQr.mjs.map → store-CTtqQtaE.mjs.map} +1 -1
- package/dist/{store-DogLawSj.cjs → store-CqPfs47P.cjs} +37 -7
- package/dist/{tool-visibility-BHRFLXuU.mjs → tool-visibility-BpyZHRBi.mjs} +4 -2
- package/dist/tool-visibility-BpyZHRBi.mjs.map +1 -0
- package/dist/{tool-visibility-iAVQV3t0.cjs → tool-visibility-Buq7YdUZ.cjs} +3 -1
- package/dist/viz-5y24S5X1.mjs +35 -0
- package/dist/viz-5y24S5X1.mjs.map +1 -0
- package/dist/viz-Dqp3C5kb.cjs +44 -0
- package/docs/contributing.md +3 -2
- package/docs/graph-tools.md +129 -118
- package/docs/investigation-workspaces.md +14 -0
- package/docs/mcp-proxy.md +20 -2
- package/package.json +1 -1
- package/skills/chain-insights-bittensor-cypher/SKILL.md +24 -0
- package/skills/chain-insights-cypher/SKILL.md +16 -1
- package/skills/chain-insights-cypher/references/memgraph-examples.md +193 -0
- package/skills/chain-insights-developer-experience/SKILL.md +26 -6
- package/skills/chain-insights-investigation/SKILL.md +64 -48
- package/skills/chain-insights-trace-funds/SKILL.md +80 -197
- package/skills/test-chain-insights-graphrag-mcp/SKILL.md +1 -1
- package/skills/test-chain-insights-graphrag-mcp/scripts/run-uat.sh +4 -4
- package/dist/cases-qjPtbnUd.mjs +0 -6
- package/dist/public-tools-DoRNhMn9.mjs.map +0 -1
- package/dist/resolver-D7VBb0uB.mjs.map +0 -1
- package/dist/tool-visibility-BHRFLXuU.mjs.map +0 -1
- package/dist/viz-DkJyqlUu.mjs.map +0 -1
|
@@ -4,6 +4,7 @@ const require_graph_normalizer = require("./graph-normalizer-DbjlbMpz.cjs");
|
|
|
4
4
|
let node_path = require("node:path");
|
|
5
5
|
node_path = require_chunk.__toESM(node_path, 1);
|
|
6
6
|
let node_fs_promises = require("node:fs/promises");
|
|
7
|
+
let node_crypto = require("node:crypto");
|
|
7
8
|
//#region src/investigation/trace-funds.ts
|
|
8
9
|
var AliasTracker = class {
|
|
9
10
|
byAddress = /* @__PURE__ */ new Map();
|
|
@@ -68,10 +69,10 @@ function clampInt$2(value, fallback, min, max) {
|
|
|
68
69
|
if (!Number.isFinite(value)) return fallback;
|
|
69
70
|
return Math.max(min, Math.min(max, Math.trunc(value)));
|
|
70
71
|
}
|
|
71
|
-
function escapeCypherString$
|
|
72
|
+
function escapeCypherString$2(value) {
|
|
72
73
|
return value.replaceAll("\\", "\\\\").replaceAll("\"", "\\\"");
|
|
73
74
|
}
|
|
74
|
-
function sanitizeSegment
|
|
75
|
+
function sanitizeSegment(value) {
|
|
75
76
|
return value.toLowerCase().replace(/[^a-z0-9_-]+/g, "-").replace(/(^-|-$)/g, "").slice(0, 80) || "trace";
|
|
76
77
|
}
|
|
77
78
|
async function ensureDirs(paths) {
|
|
@@ -96,11 +97,11 @@ async function ensureDirs(paths) {
|
|
|
96
97
|
mode: 448
|
|
97
98
|
});
|
|
98
99
|
}
|
|
99
|
-
function textFromToolResult$
|
|
100
|
+
function textFromToolResult$2(result) {
|
|
100
101
|
return (result.content ?? []).filter((item) => item.type === "text").map((item) => item.text).join("\n");
|
|
101
102
|
}
|
|
102
|
-
function parseGraphBatchResult$
|
|
103
|
-
const text = textFromToolResult$
|
|
103
|
+
function parseGraphBatchResult$2(result) {
|
|
104
|
+
const text = textFromToolResult$2(result).trim();
|
|
104
105
|
if (!text) throw new Error("graph_query_batch returned no text content");
|
|
105
106
|
const parsed = JSON.parse(text);
|
|
106
107
|
if (!parsed.facts?.queries) throw new Error("graph_query_batch response did not include facts.queries");
|
|
@@ -111,7 +112,7 @@ function topologyGraphQuery$1(query) {
|
|
|
111
112
|
if (/^USE\s+/i.test(trimmed)) return trimmed;
|
|
112
113
|
return `USE live_topology ${trimmed}`;
|
|
113
114
|
}
|
|
114
|
-
async function callGraphBatch$
|
|
115
|
+
async function callGraphBatch$2(remoteClient, network, queries) {
|
|
115
116
|
const result = await remoteClient.callTool({
|
|
116
117
|
name: "graph_query_batch",
|
|
117
118
|
arguments: {
|
|
@@ -126,8 +127,8 @@ async function callGraphBatch$3(remoteClient, network, queries) {
|
|
|
126
127
|
timeout: GRAPH_QUERY_BATCH_REQUEST_TIMEOUT_MS$1,
|
|
127
128
|
maxTotalTimeout: GRAPH_QUERY_BATCH_REQUEST_TIMEOUT_MS$1
|
|
128
129
|
});
|
|
129
|
-
if (result.isError) throw new Error(textFromToolResult$
|
|
130
|
-
return parseGraphBatchResult$
|
|
130
|
+
if (result.isError) throw new Error(textFromToolResult$2(result) || "graph_query_batch failed");
|
|
131
|
+
return parseGraphBatchResult$2(result);
|
|
131
132
|
}
|
|
132
133
|
function resultsFor(batch, id) {
|
|
133
134
|
const query = batch.facts?.queries?.find((entry) => entry.id === id);
|
|
@@ -159,7 +160,7 @@ function schemaFromGraphBatch(network, batch) {
|
|
|
159
160
|
};
|
|
160
161
|
}
|
|
161
162
|
async function loadOrCaptureTopologySchema(remoteClient, paths, network) {
|
|
162
|
-
const filePath = node_path.default.join(paths.schemaDir, `${sanitizeSegment
|
|
163
|
+
const filePath = node_path.default.join(paths.schemaDir, `${sanitizeSegment(network)}.graph-schema.json`);
|
|
163
164
|
try {
|
|
164
165
|
return {
|
|
165
166
|
schema: JSON.parse(await (0, node_fs_promises.readFile)(filePath, "utf8")),
|
|
@@ -168,7 +169,7 @@ async function loadOrCaptureTopologySchema(remoteClient, paths, network) {
|
|
|
168
169
|
} catch (err) {
|
|
169
170
|
if (err.code !== "ENOENT") throw err;
|
|
170
171
|
}
|
|
171
|
-
const schema = schemaFromGraphBatch(network, await callGraphBatch$
|
|
172
|
+
const schema = schemaFromGraphBatch(network, await callGraphBatch$2(remoteClient, network, SCHEMA_QUERY_SET));
|
|
172
173
|
await (0, node_fs_promises.writeFile)(filePath, JSON.stringify(schema, null, 2) + "\n", { mode: 384 });
|
|
173
174
|
return {
|
|
174
175
|
schema,
|
|
@@ -179,7 +180,7 @@ function flowEdgeMap$1(variableName) {
|
|
|
179
180
|
return `{amount_sum: ${variableName}.amount_sum, amount_usd_sum: ${variableName}.amount_usd_sum, tx_count: ${variableName}.tx_count, first_tx_id: ${variableName}.first_tx_id, last_tx_id: ${variableName}.last_tx_id}`;
|
|
180
181
|
}
|
|
181
182
|
function pathNodeMap$1(variableName) {
|
|
182
|
-
return `{address: ${variableName}.address, labels: ${variableName}.labels, system_labels: ${variableName}.labels, address_type: ${variableName}.address_type, address_subtypes: ${variableName}.address_subtypes}`;
|
|
183
|
+
return `{address: ${variableName}.address, labels: ${variableName}.labels, system_labels: ${variableName}.labels, address_type: ${variableName}.address_type, address_subtypes: ${variableName}.address_subtypes, is_exchange: ${variableName}.is_exchange}`;
|
|
183
184
|
}
|
|
184
185
|
function forwardExchangeQueries(address, limit, minAmountSum, maxHops) {
|
|
185
186
|
return Array.from({ length: maxHops }, (_, index) => forwardExchangeQueryAtDepth(address, limit, minAmountSum, index + 1));
|
|
@@ -198,17 +199,17 @@ function forwardExchangeQueryAtDepth(address, limit, minAmountSum, depth) {
|
|
|
198
199
|
const amountPredicates = edgeVariables.map((edgeVariable) => `${edgeVariable}.amount_sum IS NOT NULL${minAmountSum > 0 ? ` AND ${edgeVariable}.amount_sum >= ${minAmountSum}` : ""}`);
|
|
199
200
|
const predicates = [
|
|
200
201
|
"s <> t",
|
|
202
|
+
...["s", ...intermediateVariables].map((nodeVariable) => `${nodeVariable}.is_exchange IS NULL`),
|
|
201
203
|
"t.is_exchange IS NOT NULL",
|
|
202
|
-
...intermediateVariables.map((nodeVariable) => `${nodeVariable}.is_exchange IS NULL`),
|
|
203
204
|
...amountPredicates
|
|
204
205
|
];
|
|
205
206
|
const depositVariable = nodeVariables[nodeVariables.length - 2];
|
|
206
207
|
return {
|
|
207
208
|
id: `forward_exchange_paths_${depth}`,
|
|
208
209
|
query: [
|
|
209
|
-
`MATCH (s:Address {address: "${escapeCypherString$
|
|
210
|
+
`MATCH (s:Address {address: "${escapeCypherString$2(address)}"})${relationshipChain}`,
|
|
210
211
|
`WHERE ${predicates.join(" AND ")}`,
|
|
211
|
-
`RETURN [${nodeVariables.map((nodeVariable) => `${nodeVariable}.address`).join(", ")}] AS addresses, [${nodeVariables.map((nodeVariable) => `${nodeVariable}.labels`).join(", ")}] AS node_labels, [${nodeVariables.map(pathNodeMap$1).join(", ")}] AS path_nodes, [${edgeVariables.map(flowEdgeMap$1).join(", ")}] AS edge_props, t.address AS exchange_address, t.labels AS exchange_display_labels, t.labels AS exchange_labels, t.address_type AS exchange_address_type, t.address_subtypes AS exchange_address_subtypes, ${depositVariable}.address AS deposit_address, ${depth} AS hops`,
|
|
212
|
+
`RETURN [${nodeVariables.map((nodeVariable) => `${nodeVariable}.address`).join(", ")}] AS addresses, [${nodeVariables.map((nodeVariable) => `${nodeVariable}.labels`).join(", ")}] AS node_labels, [${nodeVariables.map(pathNodeMap$1).join(", ")}] AS path_nodes, [${edgeVariables.map(flowEdgeMap$1).join(", ")}] AS edge_props, t.address AS exchange_address, t.labels AS exchange_display_labels, t.labels AS exchange_labels, t.address_type AS exchange_address_type, t.address_subtypes AS exchange_address_subtypes, t.is_exchange AS exchange_is_exchange, ${depositVariable}.address AS deposit_address, ${depositVariable}.is_exchange AS deposit_is_exchange, ${depth} AS hops`,
|
|
212
213
|
"ORDER BY hops ASC",
|
|
213
214
|
`LIMIT ${limit}`
|
|
214
215
|
].join(" ")
|
|
@@ -232,7 +233,7 @@ function backwardSourceQueryAtDepth(id, depositAddress, depth) {
|
|
|
232
233
|
return {
|
|
233
234
|
id,
|
|
234
235
|
query: [
|
|
235
|
-
`MATCH (dep:Address {address: "${escapeCypherString$
|
|
236
|
+
`MATCH (dep:Address {address: "${escapeCypherString$2(depositAddress)}"})`,
|
|
236
237
|
`MATCH (dep)${relationshipChain}`,
|
|
237
238
|
`WHERE source <> dep AND source.is_exchange IS NOT NULL${intermediatePredicates.length > 0 ? ` AND ${intermediatePredicates.join(" AND ")}` : ""}`,
|
|
238
239
|
`RETURN dep.address AS deposit_address, source.address AS source_exchange, source.labels AS source_display_labels, source.labels AS source_labels, source.address_type AS source_address_type, source.address_subtypes AS source_address_subtypes, ${depth} AS hops, [${nodeVariables.map((nodeVariable) => `${nodeVariable}.address`).join(", ")}] AS addresses, [${nodeVariables.map((nodeVariable) => `${nodeVariable}.labels`).join(", ")}] AS node_labels, [${nodeVariables.map(pathNodeMap$1).join(", ")}] AS path_nodes`,
|
|
@@ -245,7 +246,7 @@ function reverseLeadsQuery(depositAddresses) {
|
|
|
245
246
|
id: "reverse_1hop",
|
|
246
247
|
query: [
|
|
247
248
|
"MATCH (sender:Address)-[r:FLOWS_TO]->(deposit:Address)",
|
|
248
|
-
`WHERE (${depositAddresses.map((address) => `deposit.address = "${escapeCypherString$
|
|
249
|
+
`WHERE (${depositAddresses.map((address) => `deposit.address = "${escapeCypherString$2(address)}"`).join(" OR ")}) AND sender.is_exchange IS NULL AND sender.address <> deposit.address`,
|
|
249
250
|
"RETURN DISTINCT sender.address AS address, sender.labels AS display_labels, sender.labels AS system_labels, sender.address_type AS address_type, sender.address_subtypes AS address_subtypes, coalesce(sender.lifetime_degree_in, 0) AS degree_in, coalesce(sender.lifetime_degree_out, 0) AS degree_out, coalesce(sender.total_volume_usd, 0) AS total_volume_usd, deposit.address AS deposit_address, r.amount_usd_sum AS amount_usd",
|
|
250
251
|
"ORDER BY r.amount_usd_sum DESC",
|
|
251
252
|
`LIMIT ${Math.max(50, depositAddresses.length * 50)}`
|
|
@@ -265,24 +266,34 @@ function directEdgePropsQuery(flows) {
|
|
|
265
266
|
id: "direct_edge_props",
|
|
266
267
|
query: [
|
|
267
268
|
"MATCH (a:Address)-[r:FLOWS_TO]->(b:Address)",
|
|
268
|
-
`WHERE (${pairs.map((pair) => `(a.address = "${escapeCypherString$
|
|
269
|
+
`WHERE (${pairs.map((pair) => `(a.address = "${escapeCypherString$2(pair.src)}" AND b.address = "${escapeCypherString$2(pair.dst)}")`).join(" OR ")})`,
|
|
269
270
|
"RETURN a.address AS src, b.address AS dst, r.amount_sum AS amount_sum, r.amount_usd_sum AS amount_usd_sum, r.tx_count AS tx_count, r.first_tx_id AS first_tx_id, r.last_tx_id AS last_tx_id",
|
|
270
271
|
`LIMIT ${pairs.length}`
|
|
271
272
|
].join(" ")
|
|
272
273
|
};
|
|
273
274
|
}
|
|
274
|
-
function numberValue$
|
|
275
|
+
function numberValue$2(value) {
|
|
275
276
|
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
276
277
|
if (typeof value === "string" && value.trim()) {
|
|
277
278
|
const parsed = Number(value);
|
|
278
279
|
return Number.isFinite(parsed) ? parsed : void 0;
|
|
279
280
|
}
|
|
280
281
|
}
|
|
282
|
+
function isExchangeFlag$1(value) {
|
|
283
|
+
if (value === true) return true;
|
|
284
|
+
if (value === false || value === null || value === void 0) return false;
|
|
285
|
+
if (typeof value === "string") {
|
|
286
|
+
const normalized = value.trim().toLowerCase();
|
|
287
|
+
return normalized === "true" || normalized === "1";
|
|
288
|
+
}
|
|
289
|
+
if (typeof value === "number") return value === 1;
|
|
290
|
+
return false;
|
|
291
|
+
}
|
|
281
292
|
function rowTerminalAmount(row) {
|
|
282
293
|
const edgeProps = Array.isArray(row["edge_props"]) ? row["edge_props"] : [];
|
|
283
294
|
const terminalEdge = edgeProps[edgeProps.length - 1];
|
|
284
295
|
if (!terminalEdge) return void 0;
|
|
285
|
-
return numberValue$
|
|
296
|
+
return numberValue$2(terminalEdge["amount_sum"]) ?? numberValue$2(terminalEdge["amount_usd_sum"]);
|
|
286
297
|
}
|
|
287
298
|
function rowsMatchingMinimumAmount(rows, minAmountSum) {
|
|
288
299
|
if (minAmountSum <= 0) return rows;
|
|
@@ -292,9 +303,12 @@ function stringArrayValue$1(value) {
|
|
|
292
303
|
if (Array.isArray(value)) return value.map(String);
|
|
293
304
|
if (typeof value === "string" && value.trim()) return [value];
|
|
294
305
|
}
|
|
295
|
-
function uniqueStrings(values) {
|
|
306
|
+
function uniqueStrings$1(values) {
|
|
296
307
|
return [...new Set(values ?? [])];
|
|
297
308
|
}
|
|
309
|
+
function hasExactExchangeLabel$1(labels) {
|
|
310
|
+
return (labels ?? []).some((label) => label.trim().toLowerCase() === "exchange");
|
|
311
|
+
}
|
|
298
312
|
function nodeMetadataFromValue(value, fallbackAddress) {
|
|
299
313
|
if (typeof value !== "object" || value === null || Array.isArray(value)) return fallbackAddress ? { address: fallbackAddress } : void 0;
|
|
300
314
|
const record = value;
|
|
@@ -305,34 +319,47 @@ function nodeMetadataFromValue(value, fallbackAddress) {
|
|
|
305
319
|
labels: stringArrayValue$1(record["labels"]),
|
|
306
320
|
system_labels: stringArrayValue$1(record["system_labels"]),
|
|
307
321
|
address_type: typeof record["address_type"] === "string" ? record["address_type"] : void 0,
|
|
308
|
-
address_subtypes: stringArrayValue$1(record["address_subtypes"])
|
|
322
|
+
address_subtypes: stringArrayValue$1(record["address_subtypes"]),
|
|
323
|
+
is_exchange: isExchangeFlag$1(record["is_exchange"])
|
|
309
324
|
};
|
|
310
325
|
}
|
|
311
326
|
function isExchangeFlow(flow) {
|
|
312
|
-
return flow.terminal_exchange || flow.
|
|
327
|
+
return flow.terminal_exchange || isExchangeFlag$1(flow.dst_node?.is_exchange) || hasExactExchangeLabel$1(flow.dst_labels) || hasExactExchangeLabel$1(flow.dst_node?.system_labels) || hasExactExchangeLabel$1(flow.dst_node?.labels);
|
|
328
|
+
}
|
|
329
|
+
function isExchangeNode(metadata, labels) {
|
|
330
|
+
return isExchangeFlag$1(metadata?.is_exchange) || hasExactExchangeLabel$1(labels) || hasExactExchangeLabel$1(metadata?.system_labels) || hasExactExchangeLabel$1(metadata?.labels);
|
|
331
|
+
}
|
|
332
|
+
function rowTouchesExchangeBeforeTerminal(pathNodes, nodeLabels, pathLength) {
|
|
333
|
+
for (let index = 0; index < Math.max(pathLength - 1, 0); index += 1) if (isExchangeNode(pathNodes[index], nodeLabels[index])) return true;
|
|
334
|
+
return false;
|
|
313
335
|
}
|
|
314
336
|
function depositFromRow(row) {
|
|
315
337
|
const pathAddresses = stringArrayValue$1(row["addresses"]) ?? [];
|
|
316
338
|
if (pathAddresses.length < 2) return null;
|
|
339
|
+
const nodeLabels = Array.isArray(row["node_labels"]) ? row["node_labels"].map((labels) => stringArrayValue$1(labels) ?? []) : [];
|
|
317
340
|
const exchangeAddress = typeof row["exchange_address"] === "string" ? row["exchange_address"] : pathAddresses[pathAddresses.length - 1];
|
|
318
341
|
const edgeProps = Array.isArray(row["edge_props"]) ? row["edge_props"] : [];
|
|
319
342
|
const terminalEdge = edgeProps[edgeProps.length - 1] ?? {};
|
|
320
343
|
const pathNodes = Array.isArray(row["path_nodes"]) ? row["path_nodes"].map((node, index) => nodeMetadataFromValue(node, pathAddresses[index])).filter((node) => Boolean(node)) : void 0;
|
|
344
|
+
const depositIndex = pathAddresses.length - 2;
|
|
345
|
+
const depositNode = pathNodes?.find((node) => node.address === pathAddresses[depositIndex]) ?? pathNodes?.[depositIndex];
|
|
346
|
+
if (isExchangeFlag$1(row["deposit_is_exchange"]) || isExchangeNode(depositNode, nodeLabels[depositIndex])) return null;
|
|
321
347
|
const exchangeNode = {
|
|
322
348
|
address: exchangeAddress,
|
|
323
349
|
labels: stringArrayValue$1(row["exchange_display_labels"]),
|
|
324
350
|
system_labels: stringArrayValue$1(row["exchange_system_labels"]) ?? stringArrayValue$1(row["exchange_labels"]),
|
|
325
351
|
address_type: typeof row["exchange_address_type"] === "string" ? row["exchange_address_type"] : void 0,
|
|
326
|
-
address_subtypes: stringArrayValue$1(row["exchange_address_subtypes"])
|
|
352
|
+
address_subtypes: stringArrayValue$1(row["exchange_address_subtypes"]),
|
|
353
|
+
is_exchange: true
|
|
327
354
|
};
|
|
328
355
|
return {
|
|
329
356
|
address: pathAddresses[pathAddresses.length - 2],
|
|
330
357
|
exchangeAddress,
|
|
331
358
|
exchangeLabels: stringArrayValue$1(row["exchange_labels"]),
|
|
332
359
|
exchangeNode,
|
|
333
|
-
amount_sum: numberValue$
|
|
334
|
-
amount_usd_sum: numberValue$
|
|
335
|
-
hops: numberValue$
|
|
360
|
+
amount_sum: numberValue$2(terminalEdge["amount_sum"]),
|
|
361
|
+
amount_usd_sum: numberValue$2(terminalEdge["amount_usd_sum"]),
|
|
362
|
+
hops: numberValue$2(row["hops"]) ?? pathAddresses.length - 1,
|
|
336
363
|
path: pathAddresses,
|
|
337
364
|
pathNodes
|
|
338
365
|
};
|
|
@@ -346,13 +373,14 @@ function flowsFromForwardRows(rows) {
|
|
|
346
373
|
const nodeLabels = Array.isArray(row["node_labels"]) ? row["node_labels"].map((labels) => stringArrayValue$1(labels) ?? []) : [];
|
|
347
374
|
const pathNodes = Array.isArray(row["path_nodes"]) ? row["path_nodes"].map((node, index) => nodeMetadataFromValue(node, pathAddresses[index])) : [];
|
|
348
375
|
const edgeProps = Array.isArray(row["edge_props"]) ? row["edge_props"] : [];
|
|
376
|
+
if (rowTouchesExchangeBeforeTerminal(pathNodes, nodeLabels, pathAddresses.length)) continue;
|
|
349
377
|
const deposit = depositFromRow(row);
|
|
350
378
|
if (deposit) deposits.push(deposit);
|
|
351
379
|
for (let index = 0; index < pathAddresses.length - 1; index += 1) {
|
|
352
380
|
const src = pathAddresses[index];
|
|
353
381
|
const dst = pathAddresses[index + 1];
|
|
354
382
|
const edge = edgeProps[index] ?? {};
|
|
355
|
-
const amount = numberValue$
|
|
383
|
+
const amount = numberValue$2(edge["amount_sum"]) ?? numberValue$2(edge["amount_usd_sum"]) ?? 0;
|
|
356
384
|
const terminal = index === pathAddresses.length - 2;
|
|
357
385
|
const key = `${src}->${dst}`;
|
|
358
386
|
if (seenEdges.has(key)) continue;
|
|
@@ -362,8 +390,8 @@ function flowsFromForwardRows(rows) {
|
|
|
362
390
|
src,
|
|
363
391
|
dst,
|
|
364
392
|
amount_sum: amount,
|
|
365
|
-
amount_usd_sum: numberValue$
|
|
366
|
-
tx_count: numberValue$
|
|
393
|
+
amount_usd_sum: numberValue$2(edge["amount_usd_sum"]),
|
|
394
|
+
tx_count: numberValue$2(edge["tx_count"]),
|
|
367
395
|
first_tx_id: typeof edge["first_tx_id"] === "string" ? edge["first_tx_id"] : void 0,
|
|
368
396
|
last_tx_id: typeof edge["last_tx_id"] === "string" ? edge["last_tx_id"] : void 0,
|
|
369
397
|
src_labels: nodeLabels[index],
|
|
@@ -382,7 +410,7 @@ function flowsFromForwardRows(rows) {
|
|
|
382
410
|
async function hydrateDirectEdgeProps(remoteClient, network, flows, deposits) {
|
|
383
411
|
const query = directEdgePropsQuery(flows);
|
|
384
412
|
if (!query) return;
|
|
385
|
-
const batch = await callGraphBatch$
|
|
413
|
+
const batch = await callGraphBatch$2(remoteClient, network, [query]);
|
|
386
414
|
const edgeProps = /* @__PURE__ */ new Map();
|
|
387
415
|
for (const row of resultsFor(batch, "direct_edge_props")) {
|
|
388
416
|
const src = typeof row["src"] === "string" ? row["src"] : "";
|
|
@@ -393,29 +421,29 @@ async function hydrateDirectEdgeProps(remoteClient, network, flows, deposits) {
|
|
|
393
421
|
for (const flow of flows) {
|
|
394
422
|
const props = edgeProps.get(edgeKey$1(flow.src, flow.dst));
|
|
395
423
|
if (!props) continue;
|
|
396
|
-
flow.amount_sum = numberValue$
|
|
397
|
-
flow.amount_usd_sum = numberValue$
|
|
398
|
-
flow.tx_count = numberValue$
|
|
424
|
+
flow.amount_sum = numberValue$2(props["amount_sum"]) ?? flow.amount_sum;
|
|
425
|
+
flow.amount_usd_sum = numberValue$2(props["amount_usd_sum"]);
|
|
426
|
+
flow.tx_count = numberValue$2(props["tx_count"]);
|
|
399
427
|
flow.first_tx_id = typeof props["first_tx_id"] === "string" ? props["first_tx_id"] : void 0;
|
|
400
428
|
flow.last_tx_id = typeof props["last_tx_id"] === "string" ? props["last_tx_id"] : void 0;
|
|
401
429
|
}
|
|
402
430
|
for (const deposit of deposits) {
|
|
403
431
|
const props = edgeProps.get(edgeKey$1(deposit.address, deposit.exchangeAddress));
|
|
404
432
|
if (!props) continue;
|
|
405
|
-
deposit.amount_sum = numberValue$
|
|
406
|
-
deposit.amount_usd_sum = numberValue$
|
|
433
|
+
deposit.amount_sum = numberValue$2(props["amount_sum"]);
|
|
434
|
+
deposit.amount_usd_sum = numberValue$2(props["amount_usd_sum"]);
|
|
407
435
|
}
|
|
408
436
|
}
|
|
409
437
|
async function collectProbeTrace(remoteClient, options) {
|
|
410
|
-
const { flows, deposits } = flowsFromForwardRows(rowsMatchingMinimumAmount(((await callGraphBatch$
|
|
438
|
+
const { flows, deposits } = flowsFromForwardRows(rowsMatchingMinimumAmount(((await callGraphBatch$2(remoteClient, options.network, [...forwardExchangeQueries(options.seedAddress, Math.max(options.perAddressLimit * 20, 200), options.minAmountSum, options.maxHops)])).facts?.queries ?? []).filter((query) => query.id?.startsWith("forward_exchange_paths_")).flatMap((query) => {
|
|
411
439
|
if (query.ok === false) throw new Error(query.error || `Query failed: ${query.id}`);
|
|
412
440
|
return query.results ?? [];
|
|
413
441
|
}), options.minAmountSum));
|
|
414
442
|
await hydrateDirectEdgeProps(remoteClient, options.network, flows, deposits);
|
|
415
443
|
const uniqueDepositAddresses = [...new Set(deposits.map((deposit) => deposit.address))];
|
|
416
444
|
const sourceMatches = [];
|
|
417
|
-
if (uniqueDepositAddresses.length > 0) {
|
|
418
|
-
const backwardBatch = await callGraphBatch$
|
|
445
|
+
if (options.includeDepositTraceback !== false && uniqueDepositAddresses.length > 0) {
|
|
446
|
+
const backwardBatch = await callGraphBatch$2(remoteClient, options.network, uniqueDepositAddresses.slice(0, Math.max(1, Math.floor(20 / options.maxHops))).flatMap((address, index) => backwardSourceQueries(`backward_from_deposit_${index + 1}`, address, options.maxHops)));
|
|
419
447
|
for (const query of backwardBatch.facts?.queries ?? []) for (const row of query.results ?? []) {
|
|
420
448
|
const pathAddresses = stringArrayValue$1(row["addresses"]) ?? [];
|
|
421
449
|
const pathNodes = Array.isArray(row["path_nodes"]) ? row["path_nodes"].map((node, index) => nodeMetadataFromValue(node, pathAddresses[index])).filter((node) => Boolean(node)) : void 0;
|
|
@@ -434,23 +462,23 @@ async function collectProbeTrace(remoteClient, options) {
|
|
|
434
462
|
source_exchange: sourceExchange,
|
|
435
463
|
source_labels: stringArrayValue$1(row["source_labels"]),
|
|
436
464
|
sourceNode,
|
|
437
|
-
hops: numberValue$
|
|
465
|
+
hops: numberValue$2(row["hops"]) ?? Math.max(pathAddresses.length - 1, 0),
|
|
438
466
|
path: pathAddresses,
|
|
439
467
|
pathNodes
|
|
440
468
|
});
|
|
441
469
|
}
|
|
442
470
|
}
|
|
443
471
|
const reverseLeads = [];
|
|
444
|
-
if (uniqueDepositAddresses.length > 0) {
|
|
445
|
-
const reverseBatch = await callGraphBatch$
|
|
472
|
+
if (options.includeDepositTraceback !== false && uniqueDepositAddresses.length > 0) {
|
|
473
|
+
const reverseBatch = await callGraphBatch$2(remoteClient, options.network, [reverseLeadsQuery(uniqueDepositAddresses)]);
|
|
446
474
|
for (const row of resultsFor(reverseBatch, "reverse_1hop")) {
|
|
447
475
|
const address = typeof row["address"] === "string" ? row["address"] : "";
|
|
448
476
|
const depositAddress = typeof row["deposit_address"] === "string" ? row["deposit_address"] : "";
|
|
449
477
|
if (!address || !depositAddress) continue;
|
|
450
478
|
const labels = stringArrayValue$1(row["display_labels"]) ?? stringArrayValue$1(row["labels"]) ?? [];
|
|
451
|
-
const degreeIn = numberValue$
|
|
452
|
-
const degreeOut = numberValue$
|
|
453
|
-
const totalVolume = numberValue$
|
|
479
|
+
const degreeIn = numberValue$2(row["degree_in"]) ?? 0;
|
|
480
|
+
const degreeOut = numberValue$2(row["degree_out"]) ?? 0;
|
|
481
|
+
const totalVolume = numberValue$2(row["total_volume_usd"]) ?? 0;
|
|
454
482
|
const reason = labels.length > 0 ? "labeled_entity" : degreeIn > 50 ? "fan_in_hub" : degreeOut > 50 ? "fan_out_hub" : totalVolume > 1e5 ? "high_volume_sender" : "";
|
|
455
483
|
if (!reason) continue;
|
|
456
484
|
reverseLeads.push({
|
|
@@ -467,7 +495,7 @@ async function collectProbeTrace(remoteClient, options) {
|
|
|
467
495
|
degree_out: degreeOut,
|
|
468
496
|
total_volume_usd: totalVolume,
|
|
469
497
|
deposit_address: depositAddress,
|
|
470
|
-
amount_usd: numberValue$
|
|
498
|
+
amount_usd: numberValue$2(row["amount_usd"]),
|
|
471
499
|
reason
|
|
472
500
|
});
|
|
473
501
|
}
|
|
@@ -494,7 +522,7 @@ function buildAliases(seedAddress, deposits, sourceMatches, reverseLeads) {
|
|
|
494
522
|
for (const lead of reverseLeads) aliases.assign(lead.address, "L");
|
|
495
523
|
return aliases;
|
|
496
524
|
}
|
|
497
|
-
function buildGraph
|
|
525
|
+
function buildGraph(seedAddress, network, flows, deposits, sourceMatches, reverseLeads) {
|
|
498
526
|
const totals = /* @__PURE__ */ new Map();
|
|
499
527
|
const ensure = (address) => {
|
|
500
528
|
if (!totals.has(address)) totals.set(address, {
|
|
@@ -509,14 +537,14 @@ function buildGraph$1(seedAddress, network, flows, deposits, sourceMatches, reve
|
|
|
509
537
|
};
|
|
510
538
|
const mergeNode = (address, metadata, role, systemLabelsFallback) => {
|
|
511
539
|
const node = ensure(address);
|
|
512
|
-
node.labels = uniqueStrings([...node.labels, ...metadata?.labels ?? []]);
|
|
513
|
-
node.systemLabels = uniqueStrings([
|
|
540
|
+
node.labels = uniqueStrings$1([...node.labels, ...metadata?.labels ?? []]);
|
|
541
|
+
node.systemLabels = uniqueStrings$1([
|
|
514
542
|
...node.systemLabels,
|
|
515
543
|
...metadata?.system_labels ?? [],
|
|
516
544
|
...systemLabelsFallback ?? []
|
|
517
545
|
]);
|
|
518
546
|
if (metadata?.address_type) node.addressType = metadata.address_type;
|
|
519
|
-
node.addressSubtypes = uniqueStrings([...node.addressSubtypes, ...metadata?.address_subtypes ?? []]);
|
|
547
|
+
node.addressSubtypes = uniqueStrings$1([...node.addressSubtypes, ...metadata?.address_subtypes ?? []]);
|
|
520
548
|
if (role) node.roles.add(role);
|
|
521
549
|
return node;
|
|
522
550
|
};
|
|
@@ -564,10 +592,10 @@ function buildGraph$1(seedAddress, network, flows, deposits, sourceMatches, reve
|
|
|
564
592
|
id: address,
|
|
565
593
|
address,
|
|
566
594
|
node_type: "address",
|
|
567
|
-
labels: uniqueStrings(data.labels),
|
|
568
|
-
...data.systemLabels.length > 0 ? { system_labels: uniqueStrings(data.systemLabels) } : {},
|
|
595
|
+
labels: uniqueStrings$1(data.labels),
|
|
596
|
+
...data.systemLabels.length > 0 ? { system_labels: uniqueStrings$1(data.systemLabels) } : {},
|
|
569
597
|
...data.addressType ? { address_type: data.addressType } : {},
|
|
570
|
-
...data.addressSubtypes.length > 0 ? { address_subtypes: uniqueStrings(data.addressSubtypes) } : {},
|
|
598
|
+
...data.addressSubtypes.length > 0 ? { address_subtypes: uniqueStrings$1(data.addressSubtypes) } : {},
|
|
571
599
|
...data.roles.size > 0 ? { roles: [...data.roles] } : {},
|
|
572
600
|
flow_in_usd: data.in,
|
|
573
601
|
flow_out_usd: data.out
|
|
@@ -645,10 +673,10 @@ function buildMarkdownReport(seedAddress, network, flows, deposits, sourceMatche
|
|
|
645
673
|
"```"
|
|
646
674
|
].join("\n") + "\n";
|
|
647
675
|
}
|
|
648
|
-
function probeEvidence(seedAddress, network, schemaPath, aliases, flows, deposits, sourceMatches, reverseLeads) {
|
|
676
|
+
function probeEvidence(seedAddress, network, schemaPath, aliases, flows, deposits, sourceMatches, reverseLeads, evidenceSource = "track_funds") {
|
|
649
677
|
return {
|
|
650
678
|
schema: "chain-insights.probe_evidence.v1",
|
|
651
|
-
source:
|
|
679
|
+
source: evidenceSource,
|
|
652
680
|
network,
|
|
653
681
|
seed_address: seedAddress,
|
|
654
682
|
schema_ref: schemaPath,
|
|
@@ -706,7 +734,7 @@ function tableCsv(flows) {
|
|
|
706
734
|
].map((value) => JSON.stringify(String(value))).join(","));
|
|
707
735
|
return rows.join("\n") + "\n";
|
|
708
736
|
}
|
|
709
|
-
function htmlEscape(value) {
|
|
737
|
+
function htmlEscape$1(value) {
|
|
710
738
|
return String(value ?? "").replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll("\"", """).replaceAll("'", "'");
|
|
711
739
|
}
|
|
712
740
|
function buildTableHtml(seedAddress, network, flows, deposits, sourceMatches, reverseLeads) {
|
|
@@ -737,14 +765,14 @@ function buildTableHtml(seedAddress, network, flows, deposits, sourceMatches, re
|
|
|
737
765
|
...flow,
|
|
738
766
|
terminal_exchange_display: flow.terminal_exchange ? "yes" : "no"
|
|
739
767
|
};
|
|
740
|
-
return `<tr>${headers.map((header) => `<td>${htmlEscape(values[header])}</td>`).join("")}</tr>`;
|
|
768
|
+
return `<tr>${headers.map((header) => `<td>${htmlEscape$1(values[header])}</td>`).join("")}</tr>`;
|
|
741
769
|
}).join("\n");
|
|
742
770
|
return `<!doctype html>
|
|
743
771
|
<html lang="en">
|
|
744
772
|
<head>
|
|
745
773
|
<meta charset="utf-8">
|
|
746
774
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
747
|
-
<title>Trace Funds Table - ${htmlEscape(seedAddress)}</title>
|
|
775
|
+
<title>Trace Funds Table - ${htmlEscape$1(seedAddress)}</title>
|
|
748
776
|
<style>
|
|
749
777
|
:root { color-scheme: dark; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #0b0d12; color: #f4f2ea; }
|
|
750
778
|
body { margin: 0; background: #0b0d12; color: #f4f2ea; }
|
|
@@ -765,9 +793,9 @@ function buildTableHtml(seedAddress, network, flows, deposits, sourceMatches, re
|
|
|
765
793
|
<main>
|
|
766
794
|
<h1>Trace Funds Table</h1>
|
|
767
795
|
<div class="meta">
|
|
768
|
-
<div>Network: <strong>${htmlEscape(network)}</strong></div>
|
|
769
|
-
<div>Seed: <strong>${htmlEscape(seedAddress)}</strong></div>
|
|
770
|
-
<div>Generated: <strong>${htmlEscape((/* @__PURE__ */ new Date()).toISOString())}</strong></div>
|
|
796
|
+
<div>Network: <strong>${htmlEscape$1(network)}</strong></div>
|
|
797
|
+
<div>Seed: <strong>${htmlEscape$1(seedAddress)}</strong></div>
|
|
798
|
+
<div>Generated: <strong>${htmlEscape$1((/* @__PURE__ */ new Date()).toISOString())}</strong></div>
|
|
771
799
|
</div>
|
|
772
800
|
<div class="summary">
|
|
773
801
|
<span class="pill">${flows.length} FLOWS_TO edges</span>
|
|
@@ -777,7 +805,7 @@ function buildTableHtml(seedAddress, network, flows, deposits, sourceMatches, re
|
|
|
777
805
|
</div>
|
|
778
806
|
<div class="table-wrap">
|
|
779
807
|
<table>
|
|
780
|
-
<thead><tr>${headers.map((header) => `<th>${htmlEscape(headerLabels[header])}</th>`).join("")}</tr></thead>
|
|
808
|
+
<thead><tr>${headers.map((header) => `<th>${htmlEscape$1(headerLabels[header])}</th>`).join("")}</tr></thead>
|
|
781
809
|
<tbody>
|
|
782
810
|
${rows}
|
|
783
811
|
</tbody>
|
|
@@ -788,7 +816,7 @@ ${rows}
|
|
|
788
816
|
</html>
|
|
789
817
|
`;
|
|
790
818
|
}
|
|
791
|
-
function summarize
|
|
819
|
+
function summarize(seedAddress, network, flows, sourceMatches, reverseLeads, aliases, files, continuation) {
|
|
792
820
|
const totalAmount = flows.reduce((sum, flow) => sum + flow.amount_sum, 0);
|
|
793
821
|
const byHop = /* @__PURE__ */ new Map();
|
|
794
822
|
for (const flow of flows) byHop.set(flow.hop, (byHop.get(flow.hop) ?? 0) + 1);
|
|
@@ -824,6 +852,7 @@ async function runFundFlowProbe(remoteClient, _config, options) {
|
|
|
824
852
|
const maxHops = clampInt$2(options.maxHops, 3, 1, 5);
|
|
825
853
|
const perAddressLimit = clampInt$2(options.perAddressLimit, 5, 1, 10);
|
|
826
854
|
const minAmountSum = Math.max(0, options.minAmountSum ?? 0);
|
|
855
|
+
const evidenceSource = options.evidenceSource ?? "track_funds";
|
|
827
856
|
const paths = require_output_root.workspaceOutputPaths();
|
|
828
857
|
await ensureDirs(paths);
|
|
829
858
|
const schemaResult = await loadOrCaptureTopologySchema(remoteClient, paths, network);
|
|
@@ -832,12 +861,13 @@ async function runFundFlowProbe(remoteClient, _config, options) {
|
|
|
832
861
|
network,
|
|
833
862
|
maxHops,
|
|
834
863
|
perAddressLimit,
|
|
835
|
-
minAmountSum
|
|
864
|
+
minAmountSum,
|
|
865
|
+
includeDepositTraceback: options.includeDepositTraceback
|
|
836
866
|
});
|
|
837
867
|
const aliases = buildAliases(seedAddress, deposits, sourceMatches, reverseLeads);
|
|
838
|
-
const slug = `${(/* @__PURE__ */ new Date()).toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z")}_${sanitizeSegment
|
|
839
|
-
const compact = probeEvidence(seedAddress, network, schemaResult.filePath, aliases, flows, deposits, sourceMatches, reverseLeads);
|
|
840
|
-
const graph = buildGraph
|
|
868
|
+
const slug = `${(/* @__PURE__ */ new Date()).toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z")}_${sanitizeSegment(seedAddress.slice(0, 16))}`;
|
|
869
|
+
const compact = probeEvidence(seedAddress, network, schemaResult.filePath, aliases, flows, deposits, sourceMatches, reverseLeads, evidenceSource);
|
|
870
|
+
const graph = buildGraph(seedAddress, network, flows, deposits, sourceMatches, reverseLeads);
|
|
841
871
|
const compactPath = node_path.default.join(paths.reportTablesRoot, `${slug}.compact-evidence.json`);
|
|
842
872
|
const graphPath = node_path.default.join(paths.reportGraphsRoot, `${slug}.graph.json`);
|
|
843
873
|
const graphHtmlPath = node_path.default.join(paths.reportsRoot, `${slug}.graph.html`);
|
|
@@ -852,13 +882,13 @@ async function runFundFlowProbe(remoteClient, _config, options) {
|
|
|
852
882
|
await (0, node_fs_promises.writeFile)(tableHtmlPath, buildTableHtml(seedAddress, network, flows, deposits, sourceMatches, reverseLeads), { mode: 384 });
|
|
853
883
|
await (0, node_fs_promises.writeFile)(reportPath, buildMarkdownReport(seedAddress, network, flows, deposits, sourceMatches, reverseLeads, aliases, graphPath, schemaResult.filePath), { mode: 384 });
|
|
854
884
|
if (options.caseId) {
|
|
855
|
-
const { EvidenceStore } = await Promise.resolve().then(() => require("./cases-
|
|
885
|
+
const { EvidenceStore } = await Promise.resolve().then(() => require("./cases-sTY5aXav.cjs"));
|
|
856
886
|
await EvidenceStore.append(options.caseId, {
|
|
857
|
-
source:
|
|
887
|
+
source: evidenceSource,
|
|
858
888
|
queryParams: `network=${network} seed_address=${seedAddress} max_hops=${maxHops} per_address_limit=${perAddressLimit} min_amount_sum=${minAmountSum}`,
|
|
859
889
|
content: JSON.stringify({
|
|
860
890
|
schema: "chain-insights.evidence_pointer.v1",
|
|
861
|
-
source:
|
|
891
|
+
source: evidenceSource,
|
|
862
892
|
network,
|
|
863
893
|
seed_address: seedAddress,
|
|
864
894
|
address_map: aliases.compactAddressMap(),
|
|
@@ -899,7 +929,7 @@ async function runFundFlowProbe(remoteClient, _config, options) {
|
|
|
899
929
|
report: reportPath
|
|
900
930
|
};
|
|
901
931
|
return {
|
|
902
|
-
summaryText: summarize
|
|
932
|
+
summaryText: summarize(seedAddress, network, flows, sourceMatches, reverseLeads, aliases, files, continuation),
|
|
903
933
|
compactEvidence: compact,
|
|
904
934
|
graphData: graph,
|
|
905
935
|
files,
|
|
@@ -908,1350 +938,6 @@ async function runFundFlowProbe(remoteClient, _config, options) {
|
|
|
908
938
|
};
|
|
909
939
|
}
|
|
910
940
|
//#endregion
|
|
911
|
-
//#region src/investigation/scam-topology.ts
|
|
912
|
-
const SCAM_TOPOLOGY_GRAPH_QUERY_TIMEOUT_SECONDS = 15;
|
|
913
|
-
const SCAM_TOPOLOGY_GRAPH_BATCH_REQUEST_TIMEOUT_MS = 900 * 1e3;
|
|
914
|
-
const SCAM_TOPOLOGY_MAX_BATCH_QUERIES = 20;
|
|
915
|
-
const SCAM_TOPOLOGY_ARCHIVE_BATCH_QUERIES = 1;
|
|
916
|
-
const SCAM_TOPOLOGY_DEPOSIT_CLUSTER_LIMIT = 200;
|
|
917
|
-
const SCAM_TOPOLOGY_DEFAULT_MAX_HOPS = 16;
|
|
918
|
-
const SCAM_TOPOLOGY_MAX_HOPS = 64;
|
|
919
|
-
const SCAM_TOPOLOGY_FRONTIER_LIMIT = 10;
|
|
920
|
-
const SCAM_TOPOLOGY_MAX_FRONTIER_SOURCES_PER_HOP = 50;
|
|
921
|
-
function parseAddressList$1(value) {
|
|
922
|
-
const raw = Array.isArray(value) ? value.join(",") : value ?? "";
|
|
923
|
-
return [...new Set(raw.split(",").map((entry) => entry.trim()).filter(Boolean))];
|
|
924
|
-
}
|
|
925
|
-
function stringArray(value) {
|
|
926
|
-
if (Array.isArray(value)) return value.map(String).map((entry) => entry.trim()).filter(Boolean);
|
|
927
|
-
if (typeof value === "string" && value.trim()) {
|
|
928
|
-
const trimmed = value.trim();
|
|
929
|
-
if (trimmed.startsWith("[")) try {
|
|
930
|
-
const parsed = JSON.parse(trimmed);
|
|
931
|
-
if (Array.isArray(parsed)) return parsed.map(String).map((entry) => entry.trim()).filter(Boolean);
|
|
932
|
-
} catch {}
|
|
933
|
-
return trimmed.split(",").map((entry) => entry.trim()).filter(Boolean);
|
|
934
|
-
}
|
|
935
|
-
return [];
|
|
936
|
-
}
|
|
937
|
-
function stringValue$1(value) {
|
|
938
|
-
return typeof value === "string" && value.trim() ? value.trim() : void 0;
|
|
939
|
-
}
|
|
940
|
-
function numberValue$2(value) {
|
|
941
|
-
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
942
|
-
if (typeof value === "string" && value.trim()) {
|
|
943
|
-
const parsed = Number(value);
|
|
944
|
-
if (Number.isFinite(parsed)) return parsed;
|
|
945
|
-
}
|
|
946
|
-
}
|
|
947
|
-
function clampInt$1(value, fallback, min, max) {
|
|
948
|
-
if (!Number.isFinite(value)) return fallback;
|
|
949
|
-
return Math.max(min, Math.min(max, Math.trunc(value)));
|
|
950
|
-
}
|
|
951
|
-
function chunks(values, size) {
|
|
952
|
-
const result = [];
|
|
953
|
-
for (let index = 0; index < values.length; index += size) result.push(values.slice(index, index + size));
|
|
954
|
-
return result;
|
|
955
|
-
}
|
|
956
|
-
function sanitizeSegment(value) {
|
|
957
|
-
return value.toLowerCase().replace(/[^a-z0-9_-]+/g, "-").replace(/(^-|-$)/g, "").slice(0, 80) || "scam-topology";
|
|
958
|
-
}
|
|
959
|
-
async function ensureScamTopologyDirs(paths) {
|
|
960
|
-
await (0, node_fs_promises.mkdir)(paths.reportsRoot, {
|
|
961
|
-
recursive: true,
|
|
962
|
-
mode: 448
|
|
963
|
-
});
|
|
964
|
-
await (0, node_fs_promises.mkdir)(paths.reportGraphsRoot, {
|
|
965
|
-
recursive: true,
|
|
966
|
-
mode: 448
|
|
967
|
-
});
|
|
968
|
-
await (0, node_fs_promises.mkdir)(paths.reportTablesRoot, {
|
|
969
|
-
recursive: true,
|
|
970
|
-
mode: 448
|
|
971
|
-
});
|
|
972
|
-
}
|
|
973
|
-
function escapeCypherString$2(value) {
|
|
974
|
-
return value.replaceAll("\\", "\\\\").replaceAll("\"", "\\\"");
|
|
975
|
-
}
|
|
976
|
-
function textFromToolResult$2(result) {
|
|
977
|
-
return (result.content ?? []).filter((item) => item.type === "text").map((item) => item.text).join("\n");
|
|
978
|
-
}
|
|
979
|
-
function parseGraphBatchResult$2(result) {
|
|
980
|
-
const text = textFromToolResult$2(result).trim();
|
|
981
|
-
if (!text) throw new Error("graph_query_batch returned no text content");
|
|
982
|
-
const parsed = JSON.parse(text);
|
|
983
|
-
if (!parsed.facts?.queries) throw new Error("graph_query_batch response did not include facts.queries");
|
|
984
|
-
return parsed;
|
|
985
|
-
}
|
|
986
|
-
async function callGraphBatch$2(remoteClient, network, queries) {
|
|
987
|
-
const result = await remoteClient.callTool({
|
|
988
|
-
name: "graph_query_batch",
|
|
989
|
-
arguments: {
|
|
990
|
-
network,
|
|
991
|
-
queries,
|
|
992
|
-
per_query_timeout_seconds: SCAM_TOPOLOGY_GRAPH_QUERY_TIMEOUT_SECONDS
|
|
993
|
-
}
|
|
994
|
-
}, void 0, {
|
|
995
|
-
timeout: SCAM_TOPOLOGY_GRAPH_BATCH_REQUEST_TIMEOUT_MS,
|
|
996
|
-
maxTotalTimeout: SCAM_TOPOLOGY_GRAPH_BATCH_REQUEST_TIMEOUT_MS
|
|
997
|
-
});
|
|
998
|
-
if (result.isError) throw new Error(textFromToolResult$2(result) || "graph_query_batch failed");
|
|
999
|
-
return parseGraphBatchResult$2(result);
|
|
1000
|
-
}
|
|
1001
|
-
function graphForScope(graphScope) {
|
|
1002
|
-
return graphScope === "history" ? "archive_topology" : "live_topology";
|
|
1003
|
-
}
|
|
1004
|
-
function isExchangeFlag(value) {
|
|
1005
|
-
if (value === true) return true;
|
|
1006
|
-
if (value === false || value === null || value === void 0) return false;
|
|
1007
|
-
if (typeof value === "string") {
|
|
1008
|
-
const normalized = value.trim().toLowerCase();
|
|
1009
|
-
return normalized === "true" || normalized === "1";
|
|
1010
|
-
}
|
|
1011
|
-
if (typeof value === "number") return value === 1;
|
|
1012
|
-
return false;
|
|
1013
|
-
}
|
|
1014
|
-
/**
|
|
1015
|
-
* Address-type values that mark a node as part of the scam topology itself
|
|
1016
|
-
* (a written scam label or the protected victim role). Such nodes must never be
|
|
1017
|
-
* read back as exchange infrastructure, even when their label text contains a
|
|
1018
|
-
* brand or the word "exchange" — that text is our own output syncing back into
|
|
1019
|
-
* the graph, not authoritative exchange signal.
|
|
1020
|
-
*/
|
|
1021
|
-
const NON_EXCHANGE_ADDRESS_TYPES = new Set(["scam", "victim"]);
|
|
1022
|
-
/**
|
|
1023
|
-
* Determine whether a node's `address_type` marks it as scam- or victim-typed,
|
|
1024
|
-
* which disqualifies it from being treated as an exchange endpoint.
|
|
1025
|
-
*
|
|
1026
|
-
* @param addressType - The node's `address_type` property, if present.
|
|
1027
|
-
* @returns `true` when the type is a scam/victim role.
|
|
1028
|
-
*/
|
|
1029
|
-
function isScamOrVictimType(addressType) {
|
|
1030
|
-
if (addressType === void 0) return false;
|
|
1031
|
-
return NON_EXCHANGE_ADDRESS_TYPES.has(addressType.trim().toLowerCase());
|
|
1032
|
-
}
|
|
1033
|
-
/**
|
|
1034
|
-
* Detect an authoritative exchange label.
|
|
1035
|
-
*
|
|
1036
|
-
* Only an exact `exchange` token or a trailing `, exchange` registry suffix
|
|
1037
|
-
* (e.g. `"Binance, exchange"`) counts. Loose substring matching such as
|
|
1038
|
-
* `includes('exchange')` is deliberately rejected because scam labels written
|
|
1039
|
-
* by this tool (e.g. `"... -> Kucoin"`, subtype text containing "exchange")
|
|
1040
|
-
* sync back into the graph and would otherwise be mis-read as exchanges.
|
|
1041
|
-
*
|
|
1042
|
-
* @param labels - Node label strings from the graph.
|
|
1043
|
-
* @returns `true` when at least one label is an authoritative exchange marker.
|
|
1044
|
-
*/
|
|
1045
|
-
function hasExchangeLabel(labels) {
|
|
1046
|
-
return labels.some((label) => {
|
|
1047
|
-
const normalized = label.trim().toLowerCase();
|
|
1048
|
-
return normalized === "exchange" || /(^|,)\s*exchange$/.test(normalized);
|
|
1049
|
-
});
|
|
1050
|
-
}
|
|
1051
|
-
/**
|
|
1052
|
-
* Decide whether a node is an exchange endpoint that terminates traversal.
|
|
1053
|
-
*
|
|
1054
|
-
* Exchange status keys off the authoritative `is_exchange` flag first, then an
|
|
1055
|
-
* exact `exchange` registry label. A node whose `address_type` is SCAM or
|
|
1056
|
-
* VICTIM is never an exchange endpoint, regardless of its label or role text,
|
|
1057
|
-
* because that signal originates from this tool's own labels rather than from
|
|
1058
|
-
* exchange enrichment.
|
|
1059
|
-
*
|
|
1060
|
-
* @param labels - Destination node label strings.
|
|
1061
|
-
* @param isExchange - The node's authoritative `is_exchange` property.
|
|
1062
|
-
* @param roles - Enrichment role strings for the node.
|
|
1063
|
-
* @param addressType - The node's `address_type` property, if present.
|
|
1064
|
-
* @returns `true` when the node should be treated as an exchange endpoint.
|
|
1065
|
-
*/
|
|
1066
|
-
function isExchangeEndpoint(labels, isExchange, roles, addressType) {
|
|
1067
|
-
if (isScamOrVictimType(addressType)) return false;
|
|
1068
|
-
return isExchangeFlag(isExchange) || hasExchangeLabel(labels) || roles.some((role) => role.trim().toLowerCase() === "exchange");
|
|
1069
|
-
}
|
|
1070
|
-
/**
|
|
1071
|
-
* Transaction-count threshold above which a deposit edge is treated as shared
|
|
1072
|
-
* exchange infrastructure rather than a scammer-dedicated cash-out address.
|
|
1073
|
-
*/
|
|
1074
|
-
const SHARED_EXCHANGE_DEPOSIT_TX_COUNT = 1e3;
|
|
1075
|
-
/**
|
|
1076
|
-
* USD-volume threshold above which a deposit edge is treated as shared exchange
|
|
1077
|
-
* infrastructure rather than a scammer-dedicated cash-out address.
|
|
1078
|
-
*/
|
|
1079
|
-
const SHARED_EXCHANGE_DEPOSIT_USD_SUM = 5e6;
|
|
1080
|
-
/**
|
|
1081
|
-
* Decide whether a penultimate-to-exchange edge represents shared exchange
|
|
1082
|
-
* deposit infrastructure (an omnibus or routing address many users fund)
|
|
1083
|
-
* rather than a scammer-dedicated cash-out address.
|
|
1084
|
-
*
|
|
1085
|
-
* A single scammer's cash-out deposit for one incident does not aggregate
|
|
1086
|
-
* thousands of transfers or tens of millions of USD; an edge that does is
|
|
1087
|
-
* exchange-side infrastructure and must not be auto-labeled scam.
|
|
1088
|
-
*
|
|
1089
|
-
* @param edge - A `terminal_exchange` topology edge (deposit -> exchange).
|
|
1090
|
-
* @returns `true` when the edge's `tx_count` or `amount_usd_sum` exceeds the
|
|
1091
|
-
* shared-infrastructure thresholds.
|
|
1092
|
-
*/
|
|
1093
|
-
function isSharedExchangeDeposit(edge) {
|
|
1094
|
-
return edge.tx_count !== void 0 && edge.tx_count >= SHARED_EXCHANGE_DEPOSIT_TX_COUNT || edge.amount_usd_sum !== void 0 && edge.amount_usd_sum >= SHARED_EXCHANGE_DEPOSIT_USD_SUM;
|
|
1095
|
-
}
|
|
1096
|
-
function isGenericContextLabel(label) {
|
|
1097
|
-
const normalized = label.trim().toLowerCase();
|
|
1098
|
-
return normalized === "exchange" || normalized === "validator" || /^miner subnet \d+$/.test(normalized) || /^subnet \d+(?: owner)?$/.test(normalized);
|
|
1099
|
-
}
|
|
1100
|
-
function exchangeNamesFromLabels(labels) {
|
|
1101
|
-
return [...new Set(labels.map((label) => label.trim().replace(/,\s*exchange$/i, "").trim()).filter((label) => label.length > 0 && !isGenericContextLabel(label)))];
|
|
1102
|
-
}
|
|
1103
|
-
function traversalProjection() {
|
|
1104
|
-
return [
|
|
1105
|
-
"src.address AS src",
|
|
1106
|
-
"dst.address AS dst",
|
|
1107
|
-
"src.labels AS src_labels",
|
|
1108
|
-
"dst.labels AS dst_labels",
|
|
1109
|
-
"src.is_exchange AS src_is_exchange",
|
|
1110
|
-
"dst.is_exchange AS dst_is_exchange",
|
|
1111
|
-
"src.address_type AS src_address_type",
|
|
1112
|
-
"dst.address_type AS dst_address_type",
|
|
1113
|
-
"src.address_subtypes AS src_address_subtypes",
|
|
1114
|
-
"dst.address_subtypes AS dst_address_subtypes",
|
|
1115
|
-
"r.amount_sum AS amount_sum",
|
|
1116
|
-
"r.amount_usd_sum AS amount_usd_sum",
|
|
1117
|
-
"r.tx_count AS tx_count",
|
|
1118
|
-
"r.first_seen_timestamp AS first_seen_timestamp",
|
|
1119
|
-
"r.last_seen_timestamp AS last_seen_timestamp",
|
|
1120
|
-
"r.first_tx_id AS first_tx_id",
|
|
1121
|
-
"r.last_tx_id AS last_tx_id"
|
|
1122
|
-
].join(", ");
|
|
1123
|
-
}
|
|
1124
|
-
function frontierQuery(graphScope, sourceAddress, hop, sourceIndex, perAddressLimit, minAmountSum, activityThresholdTimestamp) {
|
|
1125
|
-
const where = ["src.address <> dst.address"];
|
|
1126
|
-
if (minAmountSum !== void 0) where.push(`r.amount_sum >= ${minAmountSum}`);
|
|
1127
|
-
if (graphScope === "incident" && activityThresholdTimestamp !== void 0) where.push(`(r.first_seen_timestamp >= ${activityThresholdTimestamp} OR r.last_seen_timestamp >= ${activityThresholdTimestamp})`);
|
|
1128
|
-
return {
|
|
1129
|
-
id: sourceIndex === void 0 ? `${graphScope}_hop_${hop}` : `${graphScope}_hop_${hop}_source_${sourceIndex}`,
|
|
1130
|
-
query: [
|
|
1131
|
-
`USE ${graphForScope(graphScope)}`,
|
|
1132
|
-
`MATCH (src:Address {address: "${escapeCypherString$2(sourceAddress)}"})-[r:FLOWS_TO]->(dst:Address)`,
|
|
1133
|
-
`WHERE ${where.join(" AND ")}`,
|
|
1134
|
-
`RETURN ${traversalProjection()}`,
|
|
1135
|
-
"ORDER BY r.amount_sum DESC",
|
|
1136
|
-
`LIMIT ${perAddressLimit}`
|
|
1137
|
-
].join(" ")
|
|
1138
|
-
};
|
|
1139
|
-
}
|
|
1140
|
-
function activityThresholdFor(policy, incidentTimestampMs, entry) {
|
|
1141
|
-
if (policy === "global_incident") return incidentTimestampMs;
|
|
1142
|
-
return entry.arrivalTimestamp ?? incidentTimestampMs;
|
|
1143
|
-
}
|
|
1144
|
-
function edgeArrivalTimestamp(edge, threshold) {
|
|
1145
|
-
if (threshold === void 0) return edge.first_seen_timestamp ?? edge.last_seen_timestamp;
|
|
1146
|
-
if (edge.first_seen_timestamp !== void 0 && edge.first_seen_timestamp >= threshold) return edge.first_seen_timestamp;
|
|
1147
|
-
if (edge.last_seen_timestamp !== void 0 && edge.last_seen_timestamp >= threshold) return threshold;
|
|
1148
|
-
return edge.first_seen_timestamp ?? edge.last_seen_timestamp ?? threshold;
|
|
1149
|
-
}
|
|
1150
|
-
function depositClusterQuery(graphScope, depositAddress, index, minAmountSum) {
|
|
1151
|
-
const where = ["src.address <> dst.address", "src.is_exchange IS NULL"];
|
|
1152
|
-
if (minAmountSum !== void 0) where.push(`r.amount_sum >= ${minAmountSum}`);
|
|
1153
|
-
return {
|
|
1154
|
-
id: `${graphScope}_deposit_cluster_${index}`,
|
|
1155
|
-
query: [
|
|
1156
|
-
`USE ${graphForScope(graphScope)}`,
|
|
1157
|
-
`MATCH (src:Address)-[r:FLOWS_TO]->(dst:Address {address: "${escapeCypherString$2(depositAddress)}"})`,
|
|
1158
|
-
`WHERE ${where.join(" AND ")}`,
|
|
1159
|
-
`RETURN ${traversalProjection()}`,
|
|
1160
|
-
"ORDER BY r.amount_sum DESC",
|
|
1161
|
-
`LIMIT ${SCAM_TOPOLOGY_DEPOSIT_CLUSTER_LIMIT}`
|
|
1162
|
-
].join(" ")
|
|
1163
|
-
};
|
|
1164
|
-
}
|
|
1165
|
-
function edgeFromRow(row, graphScope, hop, context) {
|
|
1166
|
-
const src = stringValue$1(row["src"]) ?? stringValue$1(row["from_address"]);
|
|
1167
|
-
const dst = stringValue$1(row["dst"]) ?? stringValue$1(row["to_address"]);
|
|
1168
|
-
if (!src || !dst || src === dst) return null;
|
|
1169
|
-
const srcLabels = stringArray(row["src_labels"]);
|
|
1170
|
-
const dstLabels = stringArray(row["dst_labels"]);
|
|
1171
|
-
const srcRoles = stringArray(row["src_roles"]);
|
|
1172
|
-
const dstRoles = stringArray(row["dst_roles"]);
|
|
1173
|
-
const srcAddressType = stringValue$1(row["src_address_type"]);
|
|
1174
|
-
const dstAddressType = stringValue$1(row["dst_address_type"]);
|
|
1175
|
-
const srcAddressSubtypes = stringArray(row["src_address_subtypes"]);
|
|
1176
|
-
const dstAddressSubtypes = stringArray(row["dst_address_subtypes"]);
|
|
1177
|
-
const srcIsExchange = isExchangeEndpoint(srcLabels, row["src_is_exchange"], srcRoles, srcAddressType);
|
|
1178
|
-
const dstIsExchange = isExchangeEndpoint(dstLabels, row["dst_is_exchange"], dstRoles, dstAddressType);
|
|
1179
|
-
const dstIsScamTyped = isScamOrVictimType(dstAddressType);
|
|
1180
|
-
const genericLabeledBoundary = dstLabels.length > 0 && !dstIsExchange && !dstIsScamTyped;
|
|
1181
|
-
return {
|
|
1182
|
-
relation: dstIsExchange ? "terminal_exchange" : genericLabeledBoundary ? "context_boundary" : hop === 1 ? "seed_outflow" : "traversal_edge",
|
|
1183
|
-
src,
|
|
1184
|
-
dst,
|
|
1185
|
-
hop,
|
|
1186
|
-
graph_scope: graphScope,
|
|
1187
|
-
topology_graph: graphForScope(graphScope),
|
|
1188
|
-
seed_address: context.seedAddress,
|
|
1189
|
-
seed_role: context.seedRole,
|
|
1190
|
-
amount_sum: numberValue$2(row["amount_sum"]),
|
|
1191
|
-
amount_usd_sum: numberValue$2(row["amount_usd_sum"]),
|
|
1192
|
-
tx_count: numberValue$2(row["tx_count"]),
|
|
1193
|
-
first_seen_timestamp: numberValue$2(row["first_seen_timestamp"]),
|
|
1194
|
-
last_seen_timestamp: numberValue$2(row["last_seen_timestamp"]),
|
|
1195
|
-
first_tx_id: stringValue$1(row["first_tx_id"]),
|
|
1196
|
-
last_tx_id: stringValue$1(row["last_tx_id"]),
|
|
1197
|
-
src_labels: srcLabels,
|
|
1198
|
-
dst_labels: dstLabels,
|
|
1199
|
-
src_is_exchange: srcIsExchange,
|
|
1200
|
-
dst_is_exchange: dstIsExchange,
|
|
1201
|
-
...srcAddressType !== void 0 ? { src_address_type: srcAddressType } : {},
|
|
1202
|
-
...dstAddressType !== void 0 ? { dst_address_type: dstAddressType } : {},
|
|
1203
|
-
...srcAddressSubtypes.length > 0 ? { src_address_subtypes: srcAddressSubtypes } : {},
|
|
1204
|
-
...dstAddressSubtypes.length > 0 ? { dst_address_subtypes: dstAddressSubtypes } : {}
|
|
1205
|
-
};
|
|
1206
|
-
}
|
|
1207
|
-
function edgeKey(edge) {
|
|
1208
|
-
return `${edge.graph_scope}\u0000${edge.seed_role ?? ""}\u0000${edge.seed_address ?? ""}\u0000${edge.src}\u0000${edge.dst}`;
|
|
1209
|
-
}
|
|
1210
|
-
function frontierKey(entry) {
|
|
1211
|
-
return `${entry.seedRole}\u0000${entry.seedAddress}\u0000${entry.address}`;
|
|
1212
|
-
}
|
|
1213
|
-
async function runDirectedTraversal(remoteClient, network, seeds, graphScope, activityPolicy, maxHops, perAddressLimit, minAmountSum, incidentTimestampMs) {
|
|
1214
|
-
const edgesByKey = /* @__PURE__ */ new Map();
|
|
1215
|
-
const skippedQueryErrors = [];
|
|
1216
|
-
let frontier = seeds.map((seed) => ({
|
|
1217
|
-
address: seed.address,
|
|
1218
|
-
seedAddress: seed.address,
|
|
1219
|
-
seedRole: seed.role,
|
|
1220
|
-
arrivalTimestamp: incidentTimestampMs,
|
|
1221
|
-
waveIndex: 0
|
|
1222
|
-
}));
|
|
1223
|
-
const visited = new Set(frontier.map(frontierKey));
|
|
1224
|
-
for (let hop = 1; hop <= maxHops && frontier.length > 0; hop += 1) {
|
|
1225
|
-
const frontierByAddress = /* @__PURE__ */ new Map();
|
|
1226
|
-
for (const entry of frontier) {
|
|
1227
|
-
const entries = frontierByAddress.get(entry.address) ?? [];
|
|
1228
|
-
entries.push(entry);
|
|
1229
|
-
frontierByAddress.set(entry.address, entries);
|
|
1230
|
-
}
|
|
1231
|
-
const frontierAddresses = [...frontierByAddress.keys()];
|
|
1232
|
-
const queries = frontierAddresses.map((address, index) => {
|
|
1233
|
-
const entry = frontierByAddress.get(address)?.[0];
|
|
1234
|
-
return frontierQuery(graphScope, address, hop, frontierAddresses.length === 1 ? void 0 : index + 1, perAddressLimit, minAmountSum, entry ? activityThresholdFor(activityPolicy, incidentTimestampMs, entry) : incidentTimestampMs);
|
|
1235
|
-
});
|
|
1236
|
-
const nextByKey = /* @__PURE__ */ new Map();
|
|
1237
|
-
const maxBatchQueries = graphScope === "history" ? SCAM_TOPOLOGY_ARCHIVE_BATCH_QUERIES : SCAM_TOPOLOGY_MAX_BATCH_QUERIES;
|
|
1238
|
-
for (const queryChunk of chunks(queries, maxBatchQueries)) {
|
|
1239
|
-
let batch;
|
|
1240
|
-
try {
|
|
1241
|
-
batch = await callGraphBatch$2(remoteClient, network, queryChunk);
|
|
1242
|
-
} catch (err) {
|
|
1243
|
-
if (hop === 1) throw err;
|
|
1244
|
-
for (const query of queryChunk) skippedQueryErrors.push({
|
|
1245
|
-
id: query.id,
|
|
1246
|
-
hop,
|
|
1247
|
-
graph_scope: graphScope,
|
|
1248
|
-
error: err.message
|
|
1249
|
-
});
|
|
1250
|
-
continue;
|
|
1251
|
-
}
|
|
1252
|
-
for (const queryResult of batch.facts?.queries ?? []) {
|
|
1253
|
-
if (queryResult.ok === false) {
|
|
1254
|
-
if (hop === 1) throw new Error(queryResult.error || `Query failed: ${queryResult.id}`);
|
|
1255
|
-
skippedQueryErrors.push({
|
|
1256
|
-
id: queryResult.id,
|
|
1257
|
-
hop,
|
|
1258
|
-
graph_scope: graphScope,
|
|
1259
|
-
error: queryResult.error || `Query failed: ${queryResult.id}`
|
|
1260
|
-
});
|
|
1261
|
-
continue;
|
|
1262
|
-
}
|
|
1263
|
-
for (const row of queryResult.results ?? []) {
|
|
1264
|
-
const src = stringValue$1(row["src"]) ?? stringValue$1(row["from_address"]);
|
|
1265
|
-
if (!src) continue;
|
|
1266
|
-
const contexts = frontierByAddress.get(src) ?? [];
|
|
1267
|
-
for (const context of contexts) {
|
|
1268
|
-
const baseEdge = edgeFromRow(row, graphScope, hop, context);
|
|
1269
|
-
if (!baseEdge || edgesByKey.has(edgeKey(baseEdge))) continue;
|
|
1270
|
-
const threshold = activityThresholdFor(activityPolicy, incidentTimestampMs, context);
|
|
1271
|
-
const targetEntry = {
|
|
1272
|
-
address: baseEdge.dst,
|
|
1273
|
-
seedAddress: context.seedAddress,
|
|
1274
|
-
seedRole: context.seedRole,
|
|
1275
|
-
arrivalTimestamp: edgeArrivalTimestamp(baseEdge, threshold),
|
|
1276
|
-
waveIndex: hop
|
|
1277
|
-
};
|
|
1278
|
-
const targetKey = frontierKey(targetEntry);
|
|
1279
|
-
const seenBefore = visited.has(targetKey);
|
|
1280
|
-
const terminal = baseEdge.relation === "terminal_exchange" || baseEdge.relation === "context_boundary";
|
|
1281
|
-
const expandsFrontier = !seenBefore && !terminal;
|
|
1282
|
-
const edge = {
|
|
1283
|
-
...baseEdge,
|
|
1284
|
-
relation: seenBefore && baseEdge.relation === "traversal_edge" ? "convergence_edge" : baseEdge.relation,
|
|
1285
|
-
activity_policy: activityPolicy,
|
|
1286
|
-
wave_index: hop,
|
|
1287
|
-
expands_frontier: expandsFrontier,
|
|
1288
|
-
converges_to_seen_node: seenBefore,
|
|
1289
|
-
activity_threshold_timestamp: threshold,
|
|
1290
|
-
src_arrival_timestamp: context.arrivalTimestamp,
|
|
1291
|
-
dst_arrival_timestamp: targetEntry.arrivalTimestamp
|
|
1292
|
-
};
|
|
1293
|
-
edgesByKey.set(edgeKey(edge), edge);
|
|
1294
|
-
if (!seenBefore) visited.add(targetKey);
|
|
1295
|
-
if (!expandsFrontier) continue;
|
|
1296
|
-
const nextEntry = {
|
|
1297
|
-
address: edge.dst,
|
|
1298
|
-
seedAddress: context.seedAddress,
|
|
1299
|
-
seedRole: context.seedRole,
|
|
1300
|
-
arrivalTimestamp: edge.dst_arrival_timestamp,
|
|
1301
|
-
waveIndex: hop
|
|
1302
|
-
};
|
|
1303
|
-
nextByKey.set(targetKey, nextEntry);
|
|
1304
|
-
}
|
|
1305
|
-
}
|
|
1306
|
-
}
|
|
1307
|
-
}
|
|
1308
|
-
frontier = [...nextByKey.values()].slice(0, SCAM_TOPOLOGY_MAX_FRONTIER_SOURCES_PER_HOP);
|
|
1309
|
-
}
|
|
1310
|
-
return {
|
|
1311
|
-
graphScope,
|
|
1312
|
-
topologyGraph: graphForScope(graphScope),
|
|
1313
|
-
activityPolicy,
|
|
1314
|
-
edges: [...edgesByKey.values()],
|
|
1315
|
-
skippedQueryErrors
|
|
1316
|
-
};
|
|
1317
|
-
}
|
|
1318
|
-
async function expandDepositClusters(remoteClient, network, run, minAmountSum) {
|
|
1319
|
-
const edgesByKey = new Map(run.edges.map((edge) => [edgeKey(edge), edge]));
|
|
1320
|
-
const terminalDepositsByKey = /* @__PURE__ */ new Map();
|
|
1321
|
-
for (const edge of run.edges) {
|
|
1322
|
-
if (edge.relation !== "terminal_exchange") continue;
|
|
1323
|
-
const key = `${edge.seed_role ?? ""}\u0000${edge.seed_address ?? ""}\u0000${edge.src}`;
|
|
1324
|
-
if (!terminalDepositsByKey.has(key)) terminalDepositsByKey.set(key, edge);
|
|
1325
|
-
}
|
|
1326
|
-
const terminalDeposits = [...terminalDepositsByKey.values()];
|
|
1327
|
-
if (terminalDeposits.length === 0) return run;
|
|
1328
|
-
const queries = terminalDeposits.map((edge, index) => depositClusterQuery(run.graphScope, edge.src, index + 1, minAmountSum));
|
|
1329
|
-
const maxBatchQueries = run.graphScope === "history" ? SCAM_TOPOLOGY_ARCHIVE_BATCH_QUERIES : SCAM_TOPOLOGY_MAX_BATCH_QUERIES;
|
|
1330
|
-
for (const queryChunk of chunks(queries, maxBatchQueries)) {
|
|
1331
|
-
let batch;
|
|
1332
|
-
try {
|
|
1333
|
-
batch = await callGraphBatch$2(remoteClient, network, queryChunk);
|
|
1334
|
-
} catch (err) {
|
|
1335
|
-
for (const query of queryChunk) run.skippedQueryErrors.push({
|
|
1336
|
-
id: query.id,
|
|
1337
|
-
graph_scope: run.graphScope,
|
|
1338
|
-
error: err.message
|
|
1339
|
-
});
|
|
1340
|
-
continue;
|
|
1341
|
-
}
|
|
1342
|
-
for (const queryResult of batch.facts?.queries ?? []) {
|
|
1343
|
-
if (queryResult.ok === false) {
|
|
1344
|
-
run.skippedQueryErrors.push({
|
|
1345
|
-
id: queryResult.id,
|
|
1346
|
-
graph_scope: run.graphScope,
|
|
1347
|
-
error: queryResult.error || `Query failed: ${queryResult.id}`
|
|
1348
|
-
});
|
|
1349
|
-
continue;
|
|
1350
|
-
}
|
|
1351
|
-
const terminalEdge = terminalDeposits[queries.findIndex((query) => query.id === queryResult.id)];
|
|
1352
|
-
if (!terminalEdge) continue;
|
|
1353
|
-
const context = {
|
|
1354
|
-
address: terminalEdge.src,
|
|
1355
|
-
seedAddress: terminalEdge.seed_address ?? terminalEdge.src,
|
|
1356
|
-
seedRole: terminalEdge.seed_role ?? "victim",
|
|
1357
|
-
arrivalTimestamp: terminalEdge.src_arrival_timestamp ?? terminalEdge.first_seen_timestamp ?? terminalEdge.last_seen_timestamp,
|
|
1358
|
-
waveIndex: Math.max(0, terminalEdge.hop - 1)
|
|
1359
|
-
};
|
|
1360
|
-
for (const row of queryResult.results ?? []) {
|
|
1361
|
-
const edge = edgeFromRow(row, run.graphScope, Math.max(1, terminalEdge.hop - 1), context);
|
|
1362
|
-
if (!edge || edge.dst !== terminalEdge.src || edge.src === terminalEdge.dst) continue;
|
|
1363
|
-
const clusterEdge = {
|
|
1364
|
-
...edge,
|
|
1365
|
-
relation: "deposit_cluster_inflow",
|
|
1366
|
-
seed_address: terminalEdge.seed_address,
|
|
1367
|
-
seed_role: terminalEdge.seed_role,
|
|
1368
|
-
activity_policy: run.activityPolicy,
|
|
1369
|
-
wave_index: Math.max(1, terminalEdge.hop - 1),
|
|
1370
|
-
expands_frontier: false,
|
|
1371
|
-
converges_to_seen_node: true,
|
|
1372
|
-
activity_threshold_timestamp: terminalEdge.activity_threshold_timestamp,
|
|
1373
|
-
src_arrival_timestamp: edge.first_seen_timestamp ?? edge.last_seen_timestamp,
|
|
1374
|
-
dst_arrival_timestamp: terminalEdge.dst_arrival_timestamp
|
|
1375
|
-
};
|
|
1376
|
-
if (!edgesByKey.has(edgeKey(clusterEdge))) edgesByKey.set(edgeKey(clusterEdge), clusterEdge);
|
|
1377
|
-
}
|
|
1378
|
-
}
|
|
1379
|
-
}
|
|
1380
|
-
return {
|
|
1381
|
-
...run,
|
|
1382
|
-
edges: [...edgesByKey.values()]
|
|
1383
|
-
};
|
|
1384
|
-
}
|
|
1385
|
-
function candidateKey(candidate) {
|
|
1386
|
-
return `${candidate.address}\u0000${candidate.address_subtype}`;
|
|
1387
|
-
}
|
|
1388
|
-
function mergeCandidate(candidates, candidate) {
|
|
1389
|
-
const key = candidateKey(candidate);
|
|
1390
|
-
const existing = candidates.get(key);
|
|
1391
|
-
if (!existing) {
|
|
1392
|
-
candidates.set(key, candidate);
|
|
1393
|
-
return;
|
|
1394
|
-
}
|
|
1395
|
-
existing.confidence_score = Math.max(existing.confidence_score, candidate.confidence_score);
|
|
1396
|
-
existing.evidence.push(...candidate.evidence);
|
|
1397
|
-
if (candidate.promotion_status === "promote_confirmed") {
|
|
1398
|
-
existing.promotion_status = "promote_confirmed";
|
|
1399
|
-
existing.trust_level = "blacklisted";
|
|
1400
|
-
existing.risk_level = "critical";
|
|
1401
|
-
}
|
|
1402
|
-
}
|
|
1403
|
-
function labelForSubtype(subtype) {
|
|
1404
|
-
switch (subtype) {
|
|
1405
|
-
case "scam_seed": return "Known scam seed";
|
|
1406
|
-
case "laundering_intermediate": return "Scam laundering intermediate";
|
|
1407
|
-
case "exchange_deposit_candidate": return "Scam exchange deposit candidate";
|
|
1408
|
-
}
|
|
1409
|
-
}
|
|
1410
|
-
/**
|
|
1411
|
-
* Per-hop multiplicative decay applied to a candidate's base confidence. Each
|
|
1412
|
-
* additional hop from the seed multiplies confidence by this factor, so deeper
|
|
1413
|
-
* addresses score strictly lower than closer ones at equal value.
|
|
1414
|
-
*/
|
|
1415
|
-
const SCAM_TOPOLOGY_HOP_DECAY = .85;
|
|
1416
|
-
/**
|
|
1417
|
-
* Floor on the hop-decay multiplier so very deep chains do not collapse to
|
|
1418
|
-
* zero; keeps confidence bounded and positive while still ranking deep edges
|
|
1419
|
-
* below shallow ones.
|
|
1420
|
-
*/
|
|
1421
|
-
const SCAM_TOPOLOGY_MIN_HOP_FACTOR = .25;
|
|
1422
|
-
/**
|
|
1423
|
-
* Native/USD value at or above which the value factor saturates to its maximum
|
|
1424
|
-
* contribution. Chosen so a large incident-scale transfer earns full value
|
|
1425
|
-
* weight while small dust transfers earn little.
|
|
1426
|
-
*/
|
|
1427
|
-
const SCAM_TOPOLOGY_VALUE_SATURATION = 1e5;
|
|
1428
|
-
/**
|
|
1429
|
-
* Fraction of confidence governed by carried value (the remainder is the fixed
|
|
1430
|
-
* base). A high-value edge keeps near-base confidence; a dust edge is damped.
|
|
1431
|
-
*/
|
|
1432
|
-
const SCAM_TOPOLOGY_VALUE_WEIGHT = .5;
|
|
1433
|
-
/**
|
|
1434
|
-
* Confidence threshold at or above which a close-hop candidate is auto-promoted
|
|
1435
|
-
* to `promote_confirmed` instead of `review_required`.
|
|
1436
|
-
*
|
|
1437
|
-
* Calibrated to the decayed-confidence scale, not to a raw base. Carried value
|
|
1438
|
-
* is scored from the native token amount (see {@link reliableScoringValue}),
|
|
1439
|
-
* whose magnitudes (hundreds–thousands of TAO) sit well below the USD-scale
|
|
1440
|
-
* {@link SCAM_TOPOLOGY_VALUE_SATURATION}, so even a hop-1 incident-scale edge
|
|
1441
|
-
* decays to roughly 0.5–0.6. A 0.72 bar was therefore unreachable on
|
|
1442
|
-
* victim-anchored traces and nothing ever promoted. At 0.5, combined with the
|
|
1443
|
-
* `hop <= 2` gate, only the close-hop, real-value core promotes while dust and
|
|
1444
|
-
* deeper edges stay review-only. (Deeper fix tracked: make value scaling
|
|
1445
|
-
* asset-relative so the native/USD unit split no longer compresses scores.)
|
|
1446
|
-
*/
|
|
1447
|
-
const SCAM_TOPOLOGY_PROMOTE_CONFIDENCE = .5;
|
|
1448
|
-
/**
|
|
1449
|
-
* Maximum hop distance eligible for auto-promotion. Only the close-hop core of
|
|
1450
|
-
* a topology can promote automatically; the diluted tail stays review-only.
|
|
1451
|
-
*/
|
|
1452
|
-
const SCAM_TOPOLOGY_PROMOTE_MAX_HOP = 2;
|
|
1453
|
-
/**
|
|
1454
|
-
* Choose the value used for confidence scoring.
|
|
1455
|
-
*
|
|
1456
|
-
* Deep-hop `amount_usd_sum` is frequently inconsistent with the native amount
|
|
1457
|
-
* (e.g. hundreds of tokens reported as a few dollars) because price coverage is
|
|
1458
|
-
* missing at depth or the price join is wrong. Trusting such USD would deflate
|
|
1459
|
-
* confidence for genuinely high-value transfers, so the native `amount_sum` is
|
|
1460
|
-
* always preferred when present and positive. USD is used only as a fallback
|
|
1461
|
-
* when no usable native amount exists. Native units are consistent within a
|
|
1462
|
-
* single asset's topology, so value comparisons across edges remain meaningful.
|
|
1463
|
-
*
|
|
1464
|
-
* @param amountSum - Native transferred amount on the edge, if present.
|
|
1465
|
-
* @param amountUsdSum - Reported USD value on the edge, if present.
|
|
1466
|
-
* @returns A positive scoring value, or `undefined` when neither amount is
|
|
1467
|
-
* usable.
|
|
1468
|
-
*/
|
|
1469
|
-
function reliableScoringValue(amountSum, amountUsdSum) {
|
|
1470
|
-
const native = amountSum !== void 0 && Number.isFinite(amountSum) && amountSum > 0 ? amountSum : void 0;
|
|
1471
|
-
if (native !== void 0) return native;
|
|
1472
|
-
return amountUsdSum !== void 0 && Number.isFinite(amountUsdSum) && amountUsdSum > 0 ? amountUsdSum : void 0;
|
|
1473
|
-
}
|
|
1474
|
-
/**
|
|
1475
|
-
* Compute a bounded [0, 1] value factor from a reliable scoring value using a
|
|
1476
|
-
* logarithmic scale, so confidence increases monotonically with carried value
|
|
1477
|
-
* and saturates for incident-scale transfers.
|
|
1478
|
-
*
|
|
1479
|
-
* @param value - A non-negative scoring value, or `undefined`.
|
|
1480
|
-
* @returns A factor in [0, 1]; `0` when no value is available.
|
|
1481
|
-
*/
|
|
1482
|
-
function valueFactor(value) {
|
|
1483
|
-
if (value === void 0 || value <= 0) return 0;
|
|
1484
|
-
return Math.log10(1 + Math.min(value, SCAM_TOPOLOGY_VALUE_SATURATION)) / Math.log10(100001);
|
|
1485
|
-
}
|
|
1486
|
-
/**
|
|
1487
|
-
* Confidence model for a topology edge: a base confidence that decays
|
|
1488
|
-
* multiplicatively with hop distance and scales with carried value.
|
|
1489
|
-
*
|
|
1490
|
-
* The result is `base * hopFactor * (1 - VALUE_WEIGHT + VALUE_WEIGHT * valueFactor)`,
|
|
1491
|
-
* where `hopFactor = max(MIN_HOP_FACTOR, HOP_DECAY^(hop - 1))`. It is bounded in
|
|
1492
|
-
* `(0, base]`, strictly decreasing in `hop`, and increasing in carried value, so
|
|
1493
|
-
* a hop-1 high-value edge always outranks a deeper low-value edge. Carried value
|
|
1494
|
-
* is read from the native amount when available (see {@link reliableScoringValue}),
|
|
1495
|
-
* so unreliable deep-hop USD pricing cannot distort the score.
|
|
1496
|
-
*
|
|
1497
|
-
* @param edge - The topology edge being scored.
|
|
1498
|
-
* @param baseConfidence - The relation-and-role base confidence in `(0, 1]`.
|
|
1499
|
-
* @returns A confidence score in `(0, 1]`.
|
|
1500
|
-
*/
|
|
1501
|
-
function decayedConfidence(edge, baseConfidence) {
|
|
1502
|
-
const hop = Number.isFinite(edge.hop) && edge.hop > 0 ? edge.hop : 1;
|
|
1503
|
-
const hopFactor = Math.max(SCAM_TOPOLOGY_MIN_HOP_FACTOR, Math.pow(SCAM_TOPOLOGY_HOP_DECAY, hop - 1));
|
|
1504
|
-
const value = reliableScoringValue(edge.amount_sum, edge.amount_usd_sum);
|
|
1505
|
-
const valueScale = 1 - SCAM_TOPOLOGY_VALUE_WEIGHT + SCAM_TOPOLOGY_VALUE_WEIGHT * valueFactor(value);
|
|
1506
|
-
const confidence = baseConfidence * hopFactor * valueScale;
|
|
1507
|
-
return Math.min(baseConfidence, Math.max(0, confidence));
|
|
1508
|
-
}
|
|
1509
|
-
/**
|
|
1510
|
-
* Decide the promotion tier for a scored candidate. Only a close-hop,
|
|
1511
|
-
* high-confidence core auto-promotes to `promote_confirmed`; everything else
|
|
1512
|
-
* stays `review_required` for human triage.
|
|
1513
|
-
*
|
|
1514
|
-
* @param edge - The topology edge backing the candidate.
|
|
1515
|
-
* @param confidence - The candidate's decayed confidence score.
|
|
1516
|
-
* @returns The promotion status for the candidate.
|
|
1517
|
-
*/
|
|
1518
|
-
function promotionTier(edge, confidence) {
|
|
1519
|
-
const hop = Number.isFinite(edge.hop) && edge.hop > 0 ? edge.hop : 1;
|
|
1520
|
-
return confidence >= SCAM_TOPOLOGY_PROMOTE_CONFIDENCE && hop <= SCAM_TOPOLOGY_PROMOTE_MAX_HOP ? "promote_confirmed" : "review_required";
|
|
1521
|
-
}
|
|
1522
|
-
/**
|
|
1523
|
-
* Build a scam label candidate from a topology edge with hop-and-value decayed
|
|
1524
|
-
* confidence and an automatic promotion tier.
|
|
1525
|
-
*
|
|
1526
|
-
* @param address - The candidate address.
|
|
1527
|
-
* @param subtype - The candidate scam subtype.
|
|
1528
|
-
* @param evidence - Structured evidence for the candidate.
|
|
1529
|
-
* @param edge - The topology edge backing the candidate.
|
|
1530
|
-
* @param baseConfidence - The relation-and-role base confidence in `(0, 1]`.
|
|
1531
|
-
* @returns A scored label candidate.
|
|
1532
|
-
*/
|
|
1533
|
-
function makeScoredCandidate(address, subtype, evidence, edge, baseConfidence) {
|
|
1534
|
-
const confidence = decayedConfidence(edge, baseConfidence);
|
|
1535
|
-
return makeCandidate(address, subtype, evidence, confidence, promotionTier(edge, confidence));
|
|
1536
|
-
}
|
|
1537
|
-
function makeCandidate(address, subtype, evidence, confidence, promotionStatus) {
|
|
1538
|
-
return {
|
|
1539
|
-
address,
|
|
1540
|
-
label: labelForSubtype(subtype),
|
|
1541
|
-
address_type: "SCAM",
|
|
1542
|
-
address_subtype: subtype,
|
|
1543
|
-
trust_level: promotionStatus === "promote_confirmed" ? "blacklisted" : "candidate",
|
|
1544
|
-
risk_level: promotionStatus === "promote_confirmed" ? "critical" : "high",
|
|
1545
|
-
confidence_score: confidence,
|
|
1546
|
-
promotion_status: promotionStatus,
|
|
1547
|
-
source: "scam_topology",
|
|
1548
|
-
evidence: [evidence]
|
|
1549
|
-
};
|
|
1550
|
-
}
|
|
1551
|
-
function addRole(rolesByAddress, address, role) {
|
|
1552
|
-
if (!address) return;
|
|
1553
|
-
const roles = rolesByAddress.get(address) ?? /* @__PURE__ */ new Set();
|
|
1554
|
-
roles.add(role);
|
|
1555
|
-
rolesByAddress.set(address, roles);
|
|
1556
|
-
}
|
|
1557
|
-
function pushCaseRole(caseRoles, role) {
|
|
1558
|
-
if (caseRoles.some((entry) => entry.address === role.address && entry.role === role.role && entry.seed_address === role.seed_address && entry.seed_role === role.seed_role)) return;
|
|
1559
|
-
caseRoles.push(role);
|
|
1560
|
-
}
|
|
1561
|
-
function pushSafetyDecision(safetyDecisions, decision) {
|
|
1562
|
-
if (safetyDecisions.some((entry) => JSON.stringify(entry) === JSON.stringify(decision))) return;
|
|
1563
|
-
safetyDecisions.push(decision);
|
|
1564
|
-
}
|
|
1565
|
-
function edgeEvidence(edge, reason) {
|
|
1566
|
-
return {
|
|
1567
|
-
seed_address: edge.seed_address,
|
|
1568
|
-
seed_role: edge.seed_role,
|
|
1569
|
-
graph_scope: edge.graph_scope,
|
|
1570
|
-
scope_membership: edge.scope_membership,
|
|
1571
|
-
hop: edge.hop,
|
|
1572
|
-
src: edge.src,
|
|
1573
|
-
dst: edge.dst,
|
|
1574
|
-
amount_sum: edge.amount_sum,
|
|
1575
|
-
amount_usd_sum: edge.amount_usd_sum,
|
|
1576
|
-
tx_count: edge.tx_count,
|
|
1577
|
-
...edge.relation === "terminal_exchange" ? {
|
|
1578
|
-
deposit_address: edge.src,
|
|
1579
|
-
exchange_address: edge.dst,
|
|
1580
|
-
exchange_names: exchangeNamesFromLabels(edge.dst_labels),
|
|
1581
|
-
exchange_labels: edge.dst_labels
|
|
1582
|
-
} : {},
|
|
1583
|
-
reason
|
|
1584
|
-
};
|
|
1585
|
-
}
|
|
1586
|
-
function classifyTopology(seeds, edges) {
|
|
1587
|
-
const candidates = /* @__PURE__ */ new Map();
|
|
1588
|
-
const caseRoles = [];
|
|
1589
|
-
const safetyDecisions = [];
|
|
1590
|
-
const rolesByAddress = /* @__PURE__ */ new Map();
|
|
1591
|
-
const seedAddresses = new Set(seeds.map((seed) => seed.address));
|
|
1592
|
-
const victimAddresses = new Set(seeds.filter((seed) => seed.role === "victim").map((seed) => seed.address));
|
|
1593
|
-
const exchangeDepositAddresses = new Set(edges.filter((edge) => edge.relation === "terminal_exchange").map((edge) => edge.src).filter((address) => !seedAddresses.has(address) && !victimAddresses.has(address)));
|
|
1594
|
-
const terminalPoints = [];
|
|
1595
|
-
const exchangeDeposits = [];
|
|
1596
|
-
const investigationHints = [];
|
|
1597
|
-
for (const seed of seeds) {
|
|
1598
|
-
pushCaseRole(caseRoles, {
|
|
1599
|
-
address: seed.address,
|
|
1600
|
-
role: seed.role
|
|
1601
|
-
});
|
|
1602
|
-
addRole(rolesByAddress, seed.address, seed.role);
|
|
1603
|
-
if (seed.role === "victim") pushSafetyDecision(safetyDecisions, {
|
|
1604
|
-
address: seed.address,
|
|
1605
|
-
decision: "do_not_label_victim_seed",
|
|
1606
|
-
reason: "Victim/source addresses are protected case roles, not risky actors by default."
|
|
1607
|
-
});
|
|
1608
|
-
else mergeCandidate(candidates, makeCandidate(seed.address, "scam_seed", {
|
|
1609
|
-
seed_address: seed.address,
|
|
1610
|
-
seed_role: seed.role,
|
|
1611
|
-
reason: "Operator supplied this address as a known scammer seed."
|
|
1612
|
-
}, 1, "promote_confirmed"));
|
|
1613
|
-
}
|
|
1614
|
-
for (const edge of edges) {
|
|
1615
|
-
if (edge.relation === "deposit_cluster_inflow") {
|
|
1616
|
-
if (seedAddresses.has(edge.src) || victimAddresses.has(edge.src) || exchangeDepositAddresses.has(edge.src)) continue;
|
|
1617
|
-
if (edge.src_labels.length > 0) {
|
|
1618
|
-
pushCaseRole(caseRoles, {
|
|
1619
|
-
address: edge.src,
|
|
1620
|
-
role: "continue_from_address",
|
|
1621
|
-
seed_address: edge.seed_address,
|
|
1622
|
-
seed_role: edge.seed_role
|
|
1623
|
-
});
|
|
1624
|
-
addRole(rolesByAddress, edge.src, "continue_from_address");
|
|
1625
|
-
investigationHints.push({
|
|
1626
|
-
address: edge.src,
|
|
1627
|
-
hint_type: "generic_labeled_cluster_member",
|
|
1628
|
-
labels: edge.src_labels,
|
|
1629
|
-
reason: "Generic labels are preserved as context, but the address shares an exchange-deposit inflow cluster with the scam topology.",
|
|
1630
|
-
seed_address: edge.seed_address
|
|
1631
|
-
});
|
|
1632
|
-
pushSafetyDecision(safetyDecisions, {
|
|
1633
|
-
address: edge.src,
|
|
1634
|
-
decision: "context_only_generic_labeled_cluster_member",
|
|
1635
|
-
reason: "Generic non-exchange labels stop automatic scam labeling; investigate manually if this context should continue.",
|
|
1636
|
-
labels: edge.src_labels,
|
|
1637
|
-
seed_address: edge.seed_address
|
|
1638
|
-
});
|
|
1639
|
-
continue;
|
|
1640
|
-
}
|
|
1641
|
-
pushCaseRole(caseRoles, {
|
|
1642
|
-
address: edge.src,
|
|
1643
|
-
role: "laundering_intermediate",
|
|
1644
|
-
seed_address: edge.seed_address,
|
|
1645
|
-
seed_role: edge.seed_role
|
|
1646
|
-
});
|
|
1647
|
-
addRole(rolesByAddress, edge.src, "laundering_intermediate");
|
|
1648
|
-
mergeCandidate(candidates, makeScoredCandidate(edge.src, "laundering_intermediate", edgeEvidence(edge, "Address sends into an exchange-deposit cluster reached from a known scam topology seed."), edge, edge.seed_role === "scammer" ? .78 : .64));
|
|
1649
|
-
continue;
|
|
1650
|
-
}
|
|
1651
|
-
if (edge.relation === "terminal_exchange") {
|
|
1652
|
-
const exchangeNames = exchangeNamesFromLabels(edge.dst_labels);
|
|
1653
|
-
const exchangeDeposit = {
|
|
1654
|
-
deposit_address: edge.src,
|
|
1655
|
-
exchange_address: edge.dst,
|
|
1656
|
-
exchange_names: exchangeNames,
|
|
1657
|
-
exchange_labels: edge.dst_labels,
|
|
1658
|
-
amount_sum: edge.amount_sum,
|
|
1659
|
-
amount_usd_sum: edge.amount_usd_sum,
|
|
1660
|
-
tx_count: edge.tx_count,
|
|
1661
|
-
hop: edge.hop,
|
|
1662
|
-
graph_scope: edge.graph_scope,
|
|
1663
|
-
topology_graph: edge.topology_graph,
|
|
1664
|
-
scope_membership: edge.scope_membership,
|
|
1665
|
-
seed_address: edge.seed_address,
|
|
1666
|
-
seed_role: edge.seed_role,
|
|
1667
|
-
first_seen_timestamp: edge.first_seen_timestamp,
|
|
1668
|
-
last_seen_timestamp: edge.last_seen_timestamp,
|
|
1669
|
-
first_tx_id: edge.first_tx_id,
|
|
1670
|
-
last_tx_id: edge.last_tx_id
|
|
1671
|
-
};
|
|
1672
|
-
pushCaseRole(caseRoles, {
|
|
1673
|
-
address: edge.dst,
|
|
1674
|
-
role: "exchange_endpoint",
|
|
1675
|
-
seed_address: edge.seed_address,
|
|
1676
|
-
seed_role: edge.seed_role
|
|
1677
|
-
});
|
|
1678
|
-
addRole(rolesByAddress, edge.dst, "exchange_endpoint");
|
|
1679
|
-
terminalPoints.push({
|
|
1680
|
-
address: edge.dst,
|
|
1681
|
-
terminal_type: "exchange_endpoint",
|
|
1682
|
-
source_address: edge.src,
|
|
1683
|
-
deposit_address: edge.src,
|
|
1684
|
-
exchange_address: edge.dst,
|
|
1685
|
-
exchange_names: exchangeNames,
|
|
1686
|
-
exchange_labels: edge.dst_labels,
|
|
1687
|
-
seed_address: edge.seed_address,
|
|
1688
|
-
graph_scope: edge.graph_scope,
|
|
1689
|
-
topology_graph: edge.topology_graph,
|
|
1690
|
-
scope_membership: edge.scope_membership
|
|
1691
|
-
});
|
|
1692
|
-
if (!exchangeDeposits.some((deposit) => deposit.deposit_address === exchangeDeposit.deposit_address && deposit.exchange_address === exchangeDeposit.exchange_address && deposit.seed_address === exchangeDeposit.seed_address && deposit.seed_role === exchangeDeposit.seed_role)) exchangeDeposits.push(exchangeDeposit);
|
|
1693
|
-
pushSafetyDecision(safetyDecisions, {
|
|
1694
|
-
address: edge.dst,
|
|
1695
|
-
decision: "do_not_label_exchange_endpoint",
|
|
1696
|
-
reason: "Exchange endpoints are terminal service context, not scam label candidates.",
|
|
1697
|
-
seed_address: edge.seed_address
|
|
1698
|
-
});
|
|
1699
|
-
if (!seedAddresses.has(edge.src) && !victimAddresses.has(edge.src)) {
|
|
1700
|
-
pushCaseRole(caseRoles, {
|
|
1701
|
-
address: edge.src,
|
|
1702
|
-
role: "exchange_deposit_candidate",
|
|
1703
|
-
seed_address: edge.seed_address,
|
|
1704
|
-
seed_role: edge.seed_role
|
|
1705
|
-
});
|
|
1706
|
-
addRole(rolesByAddress, edge.src, "exchange_deposit_candidate");
|
|
1707
|
-
if (isSharedExchangeDeposit(edge)) pushSafetyDecision(safetyDecisions, {
|
|
1708
|
-
address: edge.src,
|
|
1709
|
-
decision: "do_not_label_shared_exchange_deposit",
|
|
1710
|
-
reason: "Penultimate address shows shared exchange-deposit throughput (high tx_count or USD volume); treated as exchange-adjacent context, not an automatic scam candidate.",
|
|
1711
|
-
tx_count: edge.tx_count,
|
|
1712
|
-
amount_usd_sum: edge.amount_usd_sum,
|
|
1713
|
-
seed_address: edge.seed_address
|
|
1714
|
-
});
|
|
1715
|
-
else mergeCandidate(candidates, makeScoredCandidate(edge.src, "exchange_deposit_candidate", edgeEvidence(edge, "Address is the penultimate hop before an exchange endpoint."), edge, edge.seed_role === "scammer" ? .8 : .68));
|
|
1716
|
-
}
|
|
1717
|
-
continue;
|
|
1718
|
-
}
|
|
1719
|
-
if (edge.relation === "context_boundary") {
|
|
1720
|
-
pushCaseRole(caseRoles, {
|
|
1721
|
-
address: edge.dst,
|
|
1722
|
-
role: "context_boundary",
|
|
1723
|
-
seed_address: edge.seed_address,
|
|
1724
|
-
seed_role: edge.seed_role
|
|
1725
|
-
});
|
|
1726
|
-
addRole(rolesByAddress, edge.dst, "context_boundary");
|
|
1727
|
-
terminalPoints.push({
|
|
1728
|
-
address: edge.dst,
|
|
1729
|
-
terminal_type: "context_boundary",
|
|
1730
|
-
source_address: edge.src,
|
|
1731
|
-
labels: edge.dst_labels,
|
|
1732
|
-
seed_address: edge.seed_address,
|
|
1733
|
-
graph_scope: edge.graph_scope,
|
|
1734
|
-
scope_membership: edge.scope_membership
|
|
1735
|
-
});
|
|
1736
|
-
investigationHints.push({
|
|
1737
|
-
address: edge.dst,
|
|
1738
|
-
hint_type: "generic_labeled_context",
|
|
1739
|
-
labels: edge.dst_labels,
|
|
1740
|
-
reason: "Non-exchange labels are context hints only and stop automatic scam traversal.",
|
|
1741
|
-
seed_address: edge.seed_address
|
|
1742
|
-
});
|
|
1743
|
-
pushSafetyDecision(safetyDecisions, {
|
|
1744
|
-
address: edge.dst,
|
|
1745
|
-
decision: "context_only_generic_labeled_node",
|
|
1746
|
-
reason: "Generic non-exchange labels are not hard-coded scam infrastructure classes.",
|
|
1747
|
-
labels: edge.dst_labels,
|
|
1748
|
-
seed_address: edge.seed_address
|
|
1749
|
-
});
|
|
1750
|
-
continue;
|
|
1751
|
-
}
|
|
1752
|
-
if (seedAddresses.has(edge.dst) || victimAddresses.has(edge.dst) || exchangeDepositAddresses.has(edge.dst)) continue;
|
|
1753
|
-
pushCaseRole(caseRoles, {
|
|
1754
|
-
address: edge.dst,
|
|
1755
|
-
role: "laundering_intermediate",
|
|
1756
|
-
seed_address: edge.seed_address,
|
|
1757
|
-
seed_role: edge.seed_role
|
|
1758
|
-
});
|
|
1759
|
-
addRole(rolesByAddress, edge.dst, "laundering_intermediate");
|
|
1760
|
-
mergeCandidate(candidates, makeScoredCandidate(edge.dst, "laundering_intermediate", edgeEvidence(edge, "Address appears on an outward path from a known scam topology seed."), edge, edge.seed_role === "scammer" ? .85 : .72));
|
|
1761
|
-
}
|
|
1762
|
-
return {
|
|
1763
|
-
labelCandidates: [...candidates.values()].sort((a, b) => b.confidence_score - a.confidence_score || a.address.localeCompare(b.address)),
|
|
1764
|
-
caseRoles,
|
|
1765
|
-
safetyDecisions,
|
|
1766
|
-
rolesByAddress,
|
|
1767
|
-
intermediaries: [...new Set(caseRoles.filter((role) => role.role === "laundering_intermediate").map((role) => role.address))],
|
|
1768
|
-
terminalPoints,
|
|
1769
|
-
exchangeDeposits,
|
|
1770
|
-
investigationHints
|
|
1771
|
-
};
|
|
1772
|
-
}
|
|
1773
|
-
function mergeLabels(existing, next) {
|
|
1774
|
-
return [...new Set([...stringArray(existing), ...next])];
|
|
1775
|
-
}
|
|
1776
|
-
function primaryFlowEdges(edges) {
|
|
1777
|
-
return edges.filter((edge) => edge.relation !== "deposit_cluster_inflow");
|
|
1778
|
-
}
|
|
1779
|
-
function depositClusterEdges(edges) {
|
|
1780
|
-
return edges.filter((edge) => edge.relation === "deposit_cluster_inflow");
|
|
1781
|
-
}
|
|
1782
|
-
function shortestPathFromSeed(seedAddress, targetAddress, edges) {
|
|
1783
|
-
if (seedAddress === targetAddress) return [seedAddress];
|
|
1784
|
-
const adjacency = /* @__PURE__ */ new Map();
|
|
1785
|
-
for (const edge of edges) {
|
|
1786
|
-
const destinations = adjacency.get(edge.src) ?? [];
|
|
1787
|
-
destinations.push(edge.dst);
|
|
1788
|
-
adjacency.set(edge.src, destinations);
|
|
1789
|
-
}
|
|
1790
|
-
const queue = [seedAddress];
|
|
1791
|
-
const parent = new Map([[seedAddress, null]]);
|
|
1792
|
-
for (let index = 0; index < queue.length; index += 1) {
|
|
1793
|
-
const current = queue[index];
|
|
1794
|
-
for (const next of adjacency.get(current) ?? []) {
|
|
1795
|
-
if (parent.has(next)) continue;
|
|
1796
|
-
parent.set(next, current);
|
|
1797
|
-
if (next === targetAddress) {
|
|
1798
|
-
const path = [targetAddress];
|
|
1799
|
-
let cursor = current;
|
|
1800
|
-
while (cursor) {
|
|
1801
|
-
path.push(cursor);
|
|
1802
|
-
cursor = parent.get(cursor);
|
|
1803
|
-
}
|
|
1804
|
-
return path.reverse();
|
|
1805
|
-
}
|
|
1806
|
-
queue.push(next);
|
|
1807
|
-
}
|
|
1808
|
-
}
|
|
1809
|
-
return [seedAddress, targetAddress];
|
|
1810
|
-
}
|
|
1811
|
-
function scamLabelsByAddress(facts) {
|
|
1812
|
-
const labels = Array.isArray(facts["scam_labels"]) ? facts["scam_labels"] : [];
|
|
1813
|
-
const result = /* @__PURE__ */ new Map();
|
|
1814
|
-
for (const label of labels) {
|
|
1815
|
-
if (!label || typeof label !== "object" || Array.isArray(label)) continue;
|
|
1816
|
-
const record = label;
|
|
1817
|
-
const address = stringValue$1(record["address"]);
|
|
1818
|
-
const confidence = numberValue$2(record["confidence"]);
|
|
1819
|
-
if (!address || confidence === void 0) continue;
|
|
1820
|
-
result.set(address, {
|
|
1821
|
-
address,
|
|
1822
|
-
scam: true,
|
|
1823
|
-
confidence,
|
|
1824
|
-
source: "scam_topology",
|
|
1825
|
-
source_victim_address: stringValue$1(record["source_victim_address"]) ?? "",
|
|
1826
|
-
source_incident_timestamp_ms: numberValue$2(record["source_incident_timestamp_ms"]) ?? 0
|
|
1827
|
-
});
|
|
1828
|
-
}
|
|
1829
|
-
return result;
|
|
1830
|
-
}
|
|
1831
|
-
function buildGraph(seeds, edges, rolesByAddress, facts) {
|
|
1832
|
-
const nodesById = /* @__PURE__ */ new Map();
|
|
1833
|
-
const primaryEdges = primaryFlowEdges(edges);
|
|
1834
|
-
const clusterEdges = depositClusterEdges(edges);
|
|
1835
|
-
const scamLabels = scamLabelsByAddress(facts);
|
|
1836
|
-
for (const seed of seeds) nodesById.set(seed.address, {
|
|
1837
|
-
id: seed.address,
|
|
1838
|
-
address: seed.address,
|
|
1839
|
-
node_type: "address",
|
|
1840
|
-
roles: [...rolesByAddress.get(seed.address) ?? new Set([seed.role])],
|
|
1841
|
-
flow_in_usd: 0,
|
|
1842
|
-
flow_out_usd: 0
|
|
1843
|
-
});
|
|
1844
|
-
const mergeNode = (address, labels, roles = []) => {
|
|
1845
|
-
const existing = nodesById.get(address) ?? {
|
|
1846
|
-
id: address,
|
|
1847
|
-
address,
|
|
1848
|
-
node_type: "address",
|
|
1849
|
-
flow_in_usd: 0,
|
|
1850
|
-
flow_out_usd: 0
|
|
1851
|
-
};
|
|
1852
|
-
const addressRoles = [...new Set([
|
|
1853
|
-
...stringArray(existing["roles"]),
|
|
1854
|
-
...[...rolesByAddress.get(address) ?? []],
|
|
1855
|
-
...roles
|
|
1856
|
-
])];
|
|
1857
|
-
const scamLabel = scamLabels.get(address);
|
|
1858
|
-
nodesById.set(address, {
|
|
1859
|
-
...existing,
|
|
1860
|
-
labels: mergeLabels(existing["labels"], labels),
|
|
1861
|
-
roles: addressRoles,
|
|
1862
|
-
...scamLabel ? {
|
|
1863
|
-
scam: true,
|
|
1864
|
-
scam_confidence: scamLabel.confidence,
|
|
1865
|
-
scam_source: scamLabel.source
|
|
1866
|
-
} : {}
|
|
1867
|
-
});
|
|
1868
|
-
return nodesById.get(address);
|
|
1869
|
-
};
|
|
1870
|
-
const addFlowTotals = (address, direction, amount) => {
|
|
1871
|
-
const node = nodesById.get(address) ?? mergeNode(address, []);
|
|
1872
|
-
const key = direction === "in" ? "flow_in_usd" : "flow_out_usd";
|
|
1873
|
-
node[key] = (numberValue$2(node[key]) ?? 0) + amount;
|
|
1874
|
-
nodesById.set(address, node);
|
|
1875
|
-
};
|
|
1876
|
-
for (const edge of edges) {
|
|
1877
|
-
const src = mergeNode(edge.src, edge.src_labels, edge.relation === "deposit_cluster_inflow" ? ["lead"] : []);
|
|
1878
|
-
const dstRoles = edge.relation === "terminal_exchange" ? ["exchange"] : edge.relation === "context_boundary" ? ["context_boundary"] : [];
|
|
1879
|
-
const dst = mergeNode(edge.dst, edge.dst_labels, dstRoles);
|
|
1880
|
-
if (edge.src_is_exchange) src["is_exchange"] = true;
|
|
1881
|
-
if (edge.dst_is_exchange) dst["is_exchange"] = true;
|
|
1882
|
-
const amount = edge.amount_usd_sum ?? edge.amount_sum ?? 0;
|
|
1883
|
-
addFlowTotals(edge.src, "out", amount);
|
|
1884
|
-
addFlowTotals(edge.dst, "in", amount);
|
|
1885
|
-
}
|
|
1886
|
-
const deposits = primaryEdges.filter((edge) => edge.relation === "terminal_exchange").map((edge) => ({
|
|
1887
|
-
address: edge.src,
|
|
1888
|
-
exchangeAddress: edge.dst,
|
|
1889
|
-
exchangeLabels: edge.dst_labels,
|
|
1890
|
-
exchangeNames: exchangeNamesFromLabels(edge.dst_labels),
|
|
1891
|
-
amount_sum: edge.amount_sum,
|
|
1892
|
-
amount_usd_sum: edge.amount_usd_sum,
|
|
1893
|
-
hops: edge.hop,
|
|
1894
|
-
path: shortestPathFromSeed(edge.seed_address ?? seeds[0]?.address ?? edge.src, edge.dst, primaryEdges),
|
|
1895
|
-
seed_role: edge.seed_role,
|
|
1896
|
-
seed_address: edge.seed_address
|
|
1897
|
-
}));
|
|
1898
|
-
const reverseLeads = clusterEdges.map((edge) => ({
|
|
1899
|
-
address: edge.src,
|
|
1900
|
-
labels: edge.src_labels,
|
|
1901
|
-
deposit_address: edge.dst,
|
|
1902
|
-
amount_usd: edge.amount_usd_sum ?? edge.amount_sum,
|
|
1903
|
-
degree_in: void 0,
|
|
1904
|
-
degree_out: void 0,
|
|
1905
|
-
total_volume_usd: edge.amount_usd_sum,
|
|
1906
|
-
reason: "deposit_cluster_inflow",
|
|
1907
|
-
seed_role: edge.seed_role,
|
|
1908
|
-
seed_address: edge.seed_address,
|
|
1909
|
-
first_seen_timestamp: edge.first_seen_timestamp,
|
|
1910
|
-
last_seen_timestamp: edge.last_seen_timestamp,
|
|
1911
|
-
tx_count: edge.tx_count
|
|
1912
|
-
}));
|
|
1913
|
-
return require_graph_normalizer.normalizeGraphPayload({
|
|
1914
|
-
schema: "chain-insights.graph.v1",
|
|
1915
|
-
nodes: [...nodesById.values()],
|
|
1916
|
-
edges: [...primaryEdges.map((edge) => ({
|
|
1917
|
-
source: edge.src,
|
|
1918
|
-
target: edge.dst,
|
|
1919
|
-
edge_type: "flows_to",
|
|
1920
|
-
relation: edge.relation,
|
|
1921
|
-
hop: edge.hop,
|
|
1922
|
-
wave_index: edge.wave_index,
|
|
1923
|
-
graph_scope: edge.graph_scope,
|
|
1924
|
-
topology_graph: edge.topology_graph,
|
|
1925
|
-
activity_policy: edge.activity_policy,
|
|
1926
|
-
scope_membership: edge.scope_membership,
|
|
1927
|
-
seed_address: edge.seed_address,
|
|
1928
|
-
seed_role: edge.seed_role,
|
|
1929
|
-
usd_amount: edge.amount_usd_sum ?? edge.amount_sum,
|
|
1930
|
-
amount_sum: edge.amount_sum,
|
|
1931
|
-
amount_usd_sum: edge.amount_usd_sum,
|
|
1932
|
-
tx_count: edge.tx_count ?? 0,
|
|
1933
|
-
first_seen_timestamp: edge.first_seen_timestamp,
|
|
1934
|
-
last_seen_timestamp: edge.last_seen_timestamp,
|
|
1935
|
-
first_tx_id: edge.first_tx_id,
|
|
1936
|
-
last_tx_id: edge.last_tx_id,
|
|
1937
|
-
expands_frontier: edge.expands_frontier,
|
|
1938
|
-
converges_to_seen_node: edge.converges_to_seen_node,
|
|
1939
|
-
activity_threshold_timestamp: edge.activity_threshold_timestamp,
|
|
1940
|
-
src_arrival_timestamp: edge.src_arrival_timestamp,
|
|
1941
|
-
dst_arrival_timestamp: edge.dst_arrival_timestamp,
|
|
1942
|
-
terminal_exchange: edge.relation === "terminal_exchange",
|
|
1943
|
-
context_boundary: edge.relation === "context_boundary"
|
|
1944
|
-
})), ...reverseLeads.map((lead) => ({
|
|
1945
|
-
source: lead.address,
|
|
1946
|
-
target: lead.deposit_address,
|
|
1947
|
-
edge_type: "flows_to",
|
|
1948
|
-
relation: "deposit_cluster_inflow",
|
|
1949
|
-
usd_amount: lead.amount_usd ?? 0,
|
|
1950
|
-
amount_sum: lead.amount_usd ?? 0,
|
|
1951
|
-
tx_count: lead.tx_count ?? 0,
|
|
1952
|
-
direction: "reverse_1hop_lead"
|
|
1953
|
-
}))],
|
|
1954
|
-
flows: primaryEdges.map((edge) => ({
|
|
1955
|
-
hop: edge.hop,
|
|
1956
|
-
src: edge.src,
|
|
1957
|
-
dst: edge.dst,
|
|
1958
|
-
relation: edge.relation,
|
|
1959
|
-
graph_scope: edge.graph_scope,
|
|
1960
|
-
topology_graph: edge.topology_graph,
|
|
1961
|
-
activity_policy: edge.activity_policy,
|
|
1962
|
-
wave_index: edge.wave_index,
|
|
1963
|
-
scope_membership: edge.scope_membership,
|
|
1964
|
-
seed_address: edge.seed_address,
|
|
1965
|
-
seed_role: edge.seed_role,
|
|
1966
|
-
amount_sum: edge.amount_sum,
|
|
1967
|
-
amount_usd_sum: edge.amount_usd_sum,
|
|
1968
|
-
tx_count: edge.tx_count,
|
|
1969
|
-
first_seen_timestamp: edge.first_seen_timestamp,
|
|
1970
|
-
last_seen_timestamp: edge.last_seen_timestamp,
|
|
1971
|
-
first_tx_id: edge.first_tx_id,
|
|
1972
|
-
last_tx_id: edge.last_tx_id,
|
|
1973
|
-
expands_frontier: edge.expands_frontier,
|
|
1974
|
-
converges_to_seen_node: edge.converges_to_seen_node,
|
|
1975
|
-
terminal_exchange: edge.relation === "terminal_exchange",
|
|
1976
|
-
context_boundary: edge.relation === "context_boundary"
|
|
1977
|
-
})),
|
|
1978
|
-
deposits,
|
|
1979
|
-
source_matches: [],
|
|
1980
|
-
reverse_leads: reverseLeads,
|
|
1981
|
-
edge_anchors: [],
|
|
1982
|
-
metadata: {
|
|
1983
|
-
source: "scam_topology",
|
|
1984
|
-
network: facts["network"],
|
|
1985
|
-
victim_address: facts["victim_address"],
|
|
1986
|
-
incident_timestamp_ms: facts["incident_timestamp_ms"],
|
|
1987
|
-
scam_label_count: Array.isArray(facts["scam_labels"]) ? facts["scam_labels"].length : 0,
|
|
1988
|
-
label_candidate_count: Array.isArray(facts["label_candidates"]) ? facts["label_candidates"].length : 0,
|
|
1989
|
-
topology_edge_count: edges.length,
|
|
1990
|
-
primary_flow_count: primaryEdges.length,
|
|
1991
|
-
reverse_lead_count: reverseLeads.length,
|
|
1992
|
-
primary_activity_policy: "node_relative",
|
|
1993
|
-
generated_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
1994
|
-
}
|
|
1995
|
-
});
|
|
1996
|
-
}
|
|
1997
|
-
function makeScamLabels(candidates, victimAddress, incidentTimestampMs) {
|
|
1998
|
-
return candidates.filter((candidate) => candidate.address_subtype !== "scam_seed").map((candidate) => ({
|
|
1999
|
-
address: candidate.address,
|
|
2000
|
-
scam: true,
|
|
2001
|
-
confidence: candidate.confidence_score,
|
|
2002
|
-
source: "scam_topology",
|
|
2003
|
-
source_victim_address: victimAddress,
|
|
2004
|
-
source_incident_timestamp_ms: incidentTimestampMs
|
|
2005
|
-
}));
|
|
2006
|
-
}
|
|
2007
|
-
function summarize(network, victimAddress, incidentTimestampMs, candidates, scamLabels, safetyDecisions, topologyEdges, terminalPoints) {
|
|
2008
|
-
const review = candidates.filter((candidate) => candidate.promotion_status === "review_required").length;
|
|
2009
|
-
return [
|
|
2010
|
-
`Scam topology complete for ${network}`,
|
|
2011
|
-
"",
|
|
2012
|
-
"Topology graph: live_topology",
|
|
2013
|
-
`Victim/source seed: ${victimAddress}`,
|
|
2014
|
-
`Incident timestamp ms: ${incidentTimestampMs}`,
|
|
2015
|
-
`Topology edges: ${topologyEdges.length}.`,
|
|
2016
|
-
`Terminal points: ${terminalPoints.length}.`,
|
|
2017
|
-
`Scam labels: ${scamLabels.length}.`,
|
|
2018
|
-
`Review candidates: ${candidates.length} (${review} review_required).`,
|
|
2019
|
-
`Safety decisions: ${safetyDecisions.length}.`,
|
|
2020
|
-
"",
|
|
2021
|
-
"Policy: victims, exchange endpoints, and generic labeled context nodes are not automatic scam labels."
|
|
2022
|
-
].join("\n");
|
|
2023
|
-
}
|
|
2024
|
-
function csvCell(value) {
|
|
2025
|
-
if (value === void 0 || value === null) return "\"\"";
|
|
2026
|
-
if (Array.isArray(value) || typeof value === "object" && value !== null) return JSON.stringify(JSON.stringify(value));
|
|
2027
|
-
return JSON.stringify(String(value));
|
|
2028
|
-
}
|
|
2029
|
-
function labelCandidatesCsv(candidates) {
|
|
2030
|
-
const rows = [[
|
|
2031
|
-
"address",
|
|
2032
|
-
"label",
|
|
2033
|
-
"address_type",
|
|
2034
|
-
"address_subtype",
|
|
2035
|
-
"trust_level",
|
|
2036
|
-
"risk_level",
|
|
2037
|
-
"confidence_score",
|
|
2038
|
-
"promotion_status",
|
|
2039
|
-
"source",
|
|
2040
|
-
"evidence_count"
|
|
2041
|
-
].join(",")];
|
|
2042
|
-
for (const candidate of candidates) rows.push([
|
|
2043
|
-
candidate.address,
|
|
2044
|
-
candidate.label,
|
|
2045
|
-
candidate.address_type,
|
|
2046
|
-
candidate.address_subtype,
|
|
2047
|
-
candidate.trust_level,
|
|
2048
|
-
candidate.risk_level,
|
|
2049
|
-
candidate.confidence_score,
|
|
2050
|
-
candidate.promotion_status,
|
|
2051
|
-
candidate.source,
|
|
2052
|
-
candidate.evidence.length
|
|
2053
|
-
].map(csvCell).join(","));
|
|
2054
|
-
return rows.join("\n") + "\n";
|
|
2055
|
-
}
|
|
2056
|
-
function buildScamTopologyReport(facts, files) {
|
|
2057
|
-
return [
|
|
2058
|
-
`# Scam Topology: ${facts.victim_address}`,
|
|
2059
|
-
"",
|
|
2060
|
-
`Network: \`${facts.network}\``,
|
|
2061
|
-
`Incident timestamp ms: \`${facts.incident_timestamp_ms}\``,
|
|
2062
|
-
`Activity policy: \`${facts.activity_policy_mode}\``,
|
|
2063
|
-
`Graph: \`${files.graph}\``,
|
|
2064
|
-
`Label candidates CSV: \`${files.labelCandidates}\``,
|
|
2065
|
-
"",
|
|
2066
|
-
"## Summary",
|
|
2067
|
-
"",
|
|
2068
|
-
`- Topology edges: ${facts.topology_edges.length}`,
|
|
2069
|
-
`- Terminal points: ${facts.terminal_points.length}`,
|
|
2070
|
-
`- Exchange deposits: ${facts.exchange_deposits.length}`,
|
|
2071
|
-
`- Scam labels: ${facts.scam_labels.length}`,
|
|
2072
|
-
`- Review candidates: ${facts.label_candidates.length}`,
|
|
2073
|
-
`- Safety decisions: ${facts.safety_decisions.length}`,
|
|
2074
|
-
"",
|
|
2075
|
-
"## Exchange Deposits",
|
|
2076
|
-
"",
|
|
2077
|
-
"| Deposit | Exchange | Names | Hop | amount_sum | tx_count |",
|
|
2078
|
-
"|---|---|---|---:|---:|---:|",
|
|
2079
|
-
...facts.exchange_deposits.map((entry) => {
|
|
2080
|
-
return `| \`${stringValue$1(entry["deposit_address"]) ?? ""}\` | \`${stringValue$1(entry["exchange_address"]) ?? ""}\` | ${stringArray(entry["exchange_names"]).join(", ") || ""} | ${entry["hop"] ?? ""} | ${entry["amount_sum"] ?? ""} | ${entry["tx_count"] ?? ""} |`;
|
|
2081
|
-
}),
|
|
2082
|
-
"",
|
|
2083
|
-
"## Label Candidates",
|
|
2084
|
-
"",
|
|
2085
|
-
"| Address | Subtype | Confidence | Status |",
|
|
2086
|
-
"|---|---|---:|---|",
|
|
2087
|
-
...facts.label_candidates.map((candidate) => `| \`${candidate.address}\` | ${candidate.address_subtype} | ${candidate.confidence_score} | ${candidate.promotion_status} |`),
|
|
2088
|
-
""
|
|
2089
|
-
].join("\n") + "\n";
|
|
2090
|
-
}
|
|
2091
|
-
function scamTopologyCompactEvidence(facts) {
|
|
2092
|
-
return {
|
|
2093
|
-
schema: "chain-insights.scam_topology_evidence.v1",
|
|
2094
|
-
source: "scam_topology",
|
|
2095
|
-
network: facts.network,
|
|
2096
|
-
victim_address: facts.victim_address,
|
|
2097
|
-
incident_timestamp_ms: facts.incident_timestamp_ms,
|
|
2098
|
-
topology_graphs: facts.topology_graphs,
|
|
2099
|
-
primary_activity_policy: facts.primary_activity_policy,
|
|
2100
|
-
activity_policy_mode: facts.activity_policy_mode,
|
|
2101
|
-
topology_edge_count: facts.topology_edges.length,
|
|
2102
|
-
terminal_points: facts.terminal_points,
|
|
2103
|
-
exchange_deposits: facts.exchange_deposits,
|
|
2104
|
-
scam_labels: facts.scam_labels,
|
|
2105
|
-
label_candidates: facts.label_candidates,
|
|
2106
|
-
safety_decisions: facts.safety_decisions
|
|
2107
|
-
};
|
|
2108
|
-
}
|
|
2109
|
-
async function writeScamTopologyCaseArtifacts(facts, graphData) {
|
|
2110
|
-
const paths = require_output_root.workspaceOutputPaths();
|
|
2111
|
-
await ensureScamTopologyDirs(paths);
|
|
2112
|
-
const slug = `${(/* @__PURE__ */ new Date()).toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z")}_scam-topology_${sanitizeSegment(facts.victim_address.slice(0, 16))}`;
|
|
2113
|
-
const compactEvidencePath = node_path.default.join(paths.reportTablesRoot, `${slug}.compact-evidence.json`);
|
|
2114
|
-
const graphPath = node_path.default.join(paths.reportGraphsRoot, `${slug}.graph.json`);
|
|
2115
|
-
const graphHtmlPath = node_path.default.join(paths.reportsRoot, `${slug}.graph.html`);
|
|
2116
|
-
const labelCandidatesPath = node_path.default.join(paths.reportTablesRoot, `${slug}.label-candidates.csv`);
|
|
2117
|
-
const reportPath = node_path.default.join(paths.reportsRoot, `${slug}.scam-topology-report.md`);
|
|
2118
|
-
const { generateInlineGraphHtml } = await Promise.resolve().then(() => require("./html-generator-Bx3UcLTB.cjs")).then((n) => n.html_generator_exports);
|
|
2119
|
-
const files = {
|
|
2120
|
-
compactEvidence: compactEvidencePath,
|
|
2121
|
-
graph: graphPath,
|
|
2122
|
-
graphHtml: graphHtmlPath,
|
|
2123
|
-
labelCandidates: labelCandidatesPath,
|
|
2124
|
-
report: reportPath
|
|
2125
|
-
};
|
|
2126
|
-
await (0, node_fs_promises.writeFile)(compactEvidencePath, JSON.stringify(scamTopologyCompactEvidence(facts), null, 2) + "\n", { mode: 384 });
|
|
2127
|
-
await (0, node_fs_promises.writeFile)(graphPath, JSON.stringify(graphData, null, 2) + "\n", { mode: 384 });
|
|
2128
|
-
await (0, node_fs_promises.writeFile)(graphHtmlPath, generateInlineGraphHtml(graphData), { mode: 384 });
|
|
2129
|
-
await (0, node_fs_promises.writeFile)(labelCandidatesPath, labelCandidatesCsv(facts.label_candidates), { mode: 384 });
|
|
2130
|
-
await (0, node_fs_promises.writeFile)(reportPath, buildScamTopologyReport(facts, files), { mode: 384 });
|
|
2131
|
-
return files;
|
|
2132
|
-
}
|
|
2133
|
-
function validateNonNegativeNumber(value, name) {
|
|
2134
|
-
if (value === void 0) return void 0;
|
|
2135
|
-
if (!Number.isFinite(value) || value < 0) throw new Error(`${name} must be a non-negative number`);
|
|
2136
|
-
return value;
|
|
2137
|
-
}
|
|
2138
|
-
function validateActivityPolicyMode(value) {
|
|
2139
|
-
if (value === void 0 || value === null || value === "") return "node_relative_only";
|
|
2140
|
-
if (value === "node_relative_only" || value === "global_incident_only") return value;
|
|
2141
|
-
throw new Error("activity_policy must be one of: node_relative_only, global_incident_only");
|
|
2142
|
-
}
|
|
2143
|
-
function activityPolicyForMode(mode) {
|
|
2144
|
-
return mode === "global_incident_only" ? "global_incident" : "node_relative";
|
|
2145
|
-
}
|
|
2146
|
-
async function scamTopology(remoteClient, config, options) {
|
|
2147
|
-
const network = options.network.trim();
|
|
2148
|
-
const legacyOptions = options;
|
|
2149
|
-
const victimAddresses = parseAddressList$1(options.victimAddress ?? legacyOptions.victimAddresses);
|
|
2150
|
-
const scammerAddresses = parseAddressList$1(legacyOptions.scammerAddresses);
|
|
2151
|
-
const incidentTimestampMs = validateNonNegativeNumber(options.incidentTimestampMs, "incident_timestamp_ms");
|
|
2152
|
-
const maxHops = clampInt$1(options.maxHops, SCAM_TOPOLOGY_DEFAULT_MAX_HOPS, 1, SCAM_TOPOLOGY_MAX_HOPS);
|
|
2153
|
-
const perAddressLimit = SCAM_TOPOLOGY_FRONTIER_LIMIT;
|
|
2154
|
-
const minAmountSum = void 0;
|
|
2155
|
-
const activityPolicyMode = validateActivityPolicyMode(options.activityPolicyMode);
|
|
2156
|
-
const primaryActivityPolicy = activityPolicyForMode(activityPolicyMode);
|
|
2157
|
-
const caseId = options.caseId ?? legacyOptions.caseId;
|
|
2158
|
-
if (!network) throw new Error("network is required");
|
|
2159
|
-
if (legacyOptions.scope !== void 0) throw new Error("scope is no longer accepted; scam_topology always runs the victim incident topology");
|
|
2160
|
-
if (legacyOptions.sinceTimestampMs !== void 0) throw new Error("since_timestamp_ms is no longer accepted; use incident_timestamp_ms");
|
|
2161
|
-
if (legacyOptions.perAddressLimit !== void 0) throw new Error("per_address_limit is no longer accepted; scam_topology uses its internal bounded frontier");
|
|
2162
|
-
if (legacyOptions.minAmountSum !== void 0) throw new Error("min_amount_sum is no longer accepted; scam_topology does not amount-filter scam topology expansion");
|
|
2163
|
-
if (scammerAddresses.length > 0) throw new Error("scammer_addresses is no longer accepted; scam_topology starts from a victim incident");
|
|
2164
|
-
if (victimAddresses.length === 0) throw new Error("victim_address is required");
|
|
2165
|
-
if (victimAddresses.length !== 1) throw new Error("victim_address must contain exactly one address");
|
|
2166
|
-
if (incidentTimestampMs === void 0) throw new Error("incident_timestamp_ms is required");
|
|
2167
|
-
const victimAddress = victimAddresses[0];
|
|
2168
|
-
const seeds = [{
|
|
2169
|
-
address: victimAddress,
|
|
2170
|
-
role: "victim"
|
|
2171
|
-
}];
|
|
2172
|
-
const primaryRunWithClusters = await expandDepositClusters(remoteClient, network, await runDirectedTraversal(remoteClient, network, seeds, "incident", primaryActivityPolicy, maxHops, perAddressLimit, minAmountSum, incidentTimestampMs), minAmountSum);
|
|
2173
|
-
const runs = [primaryRunWithClusters];
|
|
2174
|
-
const topologyEdges = primaryRunWithClusters.edges;
|
|
2175
|
-
const classification = classifyTopology(seeds, topologyEdges);
|
|
2176
|
-
const labelCandidates = classification.labelCandidates;
|
|
2177
|
-
const scamLabels = makeScamLabels(labelCandidates, victimAddress, incidentTimestampMs);
|
|
2178
|
-
const facts = {
|
|
2179
|
-
network,
|
|
2180
|
-
victim_address: victimAddress,
|
|
2181
|
-
incident_timestamp_ms: incidentTimestampMs,
|
|
2182
|
-
topology_graphs: ["live_topology"],
|
|
2183
|
-
primary_activity_policy: primaryActivityPolicy,
|
|
2184
|
-
activity_policy_mode: activityPolicyMode,
|
|
2185
|
-
topology_edges: topologyEdges,
|
|
2186
|
-
intermediaries: classification.intermediaries,
|
|
2187
|
-
terminal_points: classification.terminalPoints,
|
|
2188
|
-
exchange_deposits: classification.exchangeDeposits,
|
|
2189
|
-
investigation_hints: classification.investigationHints,
|
|
2190
|
-
scam_labels: scamLabels,
|
|
2191
|
-
label_candidates: labelCandidates,
|
|
2192
|
-
case_roles: classification.caseRoles,
|
|
2193
|
-
safety_decisions: classification.safetyDecisions,
|
|
2194
|
-
infrastructure_anchors: [],
|
|
2195
|
-
infrastructure_flows: [],
|
|
2196
|
-
runs: runs.map((run) => ({
|
|
2197
|
-
graph_scope: run.graphScope,
|
|
2198
|
-
topology_graph: run.topologyGraph,
|
|
2199
|
-
activity_policy: run.activityPolicy,
|
|
2200
|
-
edge_count: run.edges.length,
|
|
2201
|
-
primary: run.activityPolicy === primaryActivityPolicy,
|
|
2202
|
-
max_hops: maxHops,
|
|
2203
|
-
frontier_limit: perAddressLimit,
|
|
2204
|
-
frontier_source_limit_per_hop: SCAM_TOPOLOGY_MAX_FRONTIER_SOURCES_PER_HOP,
|
|
2205
|
-
skipped_query_errors: run.skippedQueryErrors
|
|
2206
|
-
}))
|
|
2207
|
-
};
|
|
2208
|
-
const graphData = buildGraph(seeds, topologyEdges, classification.rolesByAddress, facts);
|
|
2209
|
-
const summaryText = summarize(network, victimAddress, incidentTimestampMs, labelCandidates, scamLabels, classification.safetyDecisions, topologyEdges, classification.terminalPoints);
|
|
2210
|
-
if (caseId) {
|
|
2211
|
-
const files = await writeScamTopologyCaseArtifacts(facts, graphData);
|
|
2212
|
-
const { EvidenceStore } = await Promise.resolve().then(() => require("./cases-c0iV-XLI.cjs"));
|
|
2213
|
-
await EvidenceStore.append(caseId, {
|
|
2214
|
-
source: "scam_topology",
|
|
2215
|
-
queryParams: [
|
|
2216
|
-
`network=${network}`,
|
|
2217
|
-
`victim_address=${victimAddress}`,
|
|
2218
|
-
`incident_timestamp_ms=${incidentTimestampMs}`,
|
|
2219
|
-
`max_hops=${maxHops}`,
|
|
2220
|
-
`activity_policy=${activityPolicyMode}`
|
|
2221
|
-
].filter(Boolean).join(" "),
|
|
2222
|
-
content: JSON.stringify({
|
|
2223
|
-
schema: "chain-insights.evidence_pointer.v1",
|
|
2224
|
-
source: "scam_topology",
|
|
2225
|
-
network,
|
|
2226
|
-
victim_address: victimAddress,
|
|
2227
|
-
incident_timestamp_ms: incidentTimestampMs,
|
|
2228
|
-
topology_graphs: facts.topology_graphs,
|
|
2229
|
-
primary_activity_policy: primaryActivityPolicy,
|
|
2230
|
-
activity_policy_mode: activityPolicyMode,
|
|
2231
|
-
files,
|
|
2232
|
-
facts: {
|
|
2233
|
-
topology_edges: topologyEdges.length,
|
|
2234
|
-
terminal_points: classification.terminalPoints.length,
|
|
2235
|
-
exchange_deposits: classification.exchangeDeposits.length,
|
|
2236
|
-
scam_labels: scamLabels.length,
|
|
2237
|
-
label_candidates: labelCandidates.length,
|
|
2238
|
-
safety_decisions: classification.safetyDecisions.length
|
|
2239
|
-
}
|
|
2240
|
-
}, null, 2)
|
|
2241
|
-
});
|
|
2242
|
-
}
|
|
2243
|
-
return {
|
|
2244
|
-
summaryText,
|
|
2245
|
-
structuredContent: {
|
|
2246
|
-
schema: "chain-insights.result.v1",
|
|
2247
|
-
tool: "scam_topology",
|
|
2248
|
-
facts,
|
|
2249
|
-
hint: "Use scam_labels as ML-ready scam flags. Review label_candidates and safety_decisions before promoting addresses into core_address_labels."
|
|
2250
|
-
},
|
|
2251
|
-
graphData
|
|
2252
|
-
};
|
|
2253
|
-
}
|
|
2254
|
-
//#endregion
|
|
2255
941
|
//#region src/investigation/stake-insights.ts
|
|
2256
942
|
const STAKE_INSIGHTS_QUERY_TIMEOUT_SECONDS = 120;
|
|
2257
943
|
const STAKE_INSIGHTS_REQUEST_TIMEOUT_MS = 300 * 1e3;
|
|
@@ -2282,7 +968,7 @@ function nonZeroNumber(value) {
|
|
|
2282
968
|
const parsed = numberValue$1(value);
|
|
2283
969
|
return parsed !== void 0 && parsed !== 0 ? parsed : void 0;
|
|
2284
970
|
}
|
|
2285
|
-
function clampInt(value, fallback, min, max) {
|
|
971
|
+
function clampInt$1(value, fallback, min, max) {
|
|
2286
972
|
if (!Number.isFinite(value)) return fallback;
|
|
2287
973
|
return Math.max(min, Math.min(max, Math.trunc(value)));
|
|
2288
974
|
}
|
|
@@ -2305,7 +991,7 @@ function validateOptions(options) {
|
|
|
2305
991
|
return {
|
|
2306
992
|
network,
|
|
2307
993
|
subject: resolveSubject(options),
|
|
2308
|
-
depth: clampInt(options.depth ?? options.maxHops, 1, 1, 3)
|
|
994
|
+
depth: clampInt$1(options.depth ?? options.maxHops, 1, 1, 3)
|
|
2309
995
|
};
|
|
2310
996
|
}
|
|
2311
997
|
function subjectPredicate(subject) {
|
|
@@ -2713,10 +1399,6 @@ async function callGraphBatch(remoteClient, network, queries) {
|
|
|
2713
1399
|
function parseAddressList(value) {
|
|
2714
1400
|
return (Array.isArray(value) ? value.join(",") : value ?? "").split(",").map((entry) => entry.trim()).filter(Boolean);
|
|
2715
1401
|
}
|
|
2716
|
-
function graphArray(graphData, key) {
|
|
2717
|
-
const value = graphData[key];
|
|
2718
|
-
return Array.isArray(value) ? value.filter((item) => typeof item === "object" && item !== null && !Array.isArray(item)) : [];
|
|
2719
|
-
}
|
|
2720
1402
|
function addressProfileQuery(address) {
|
|
2721
1403
|
return {
|
|
2722
1404
|
id: "address_profile",
|
|
@@ -2753,7 +1435,7 @@ function flowEdgeMap(variableName) {
|
|
|
2753
1435
|
return `{amount_sum: ${variableName}.amount_sum, amount_usd_sum: ${variableName}.amount_usd_sum, tx_count: ${variableName}.tx_count, first_tx_id: ${variableName}.first_tx_id, last_tx_id: ${variableName}.last_tx_id}`;
|
|
2754
1436
|
}
|
|
2755
1437
|
function pathNodeMap(variableName) {
|
|
2756
|
-
return `{address: ${variableName}.address, labels: ${variableName}.labels, system_labels: ${variableName}.labels, address_type: ${variableName}.address_type, address_subtypes: ${variableName}.address_subtypes}`;
|
|
1438
|
+
return `{address: ${variableName}.address, labels: ${variableName}.labels, system_labels: ${variableName}.labels, address_type: ${variableName}.address_type, address_subtypes: ${variableName}.address_subtypes, is_exchange: ${variableName}.is_exchange}`;
|
|
2757
1439
|
}
|
|
2758
1440
|
function exchangeOutflowQueries(address) {
|
|
2759
1441
|
return Array.from({ length: 3 }, (_, index) => exchangeOutflowQueryAtDepth(address, index + 1));
|
|
@@ -2836,6 +1518,19 @@ function numberValue(value) {
|
|
|
2836
1518
|
return Number.isFinite(parsed) ? parsed : void 0;
|
|
2837
1519
|
}
|
|
2838
1520
|
}
|
|
1521
|
+
function isExchangeFlag(value) {
|
|
1522
|
+
if (value === true) return true;
|
|
1523
|
+
if (value === false || value === null || value === void 0) return false;
|
|
1524
|
+
if (typeof value === "string") {
|
|
1525
|
+
const normalized = value.trim().toLowerCase();
|
|
1526
|
+
return normalized === "true" || normalized === "1";
|
|
1527
|
+
}
|
|
1528
|
+
if (typeof value === "number") return value === 1;
|
|
1529
|
+
return false;
|
|
1530
|
+
}
|
|
1531
|
+
function hasExactExchangeLabel(labels) {
|
|
1532
|
+
return (labels ?? []).some((label) => label.trim().toLowerCase() === "exchange");
|
|
1533
|
+
}
|
|
2839
1534
|
function firstNumber(...values) {
|
|
2840
1535
|
for (const value of values) {
|
|
2841
1536
|
const parsed = numberValue(value);
|
|
@@ -3078,19 +1773,252 @@ async function addressRisk(remoteClient, options) {
|
|
|
3078
1773
|
graphData
|
|
3079
1774
|
};
|
|
3080
1775
|
}
|
|
3081
|
-
|
|
1776
|
+
function uniqueStrings(values) {
|
|
1777
|
+
return [...new Set(values.filter((value) => typeof value === "string" && value.length > 0))];
|
|
1778
|
+
}
|
|
1779
|
+
function clampInt(value, fallback, min, max) {
|
|
1780
|
+
if (!Number.isFinite(value)) return fallback;
|
|
1781
|
+
return Math.max(min, Math.min(max, Math.trunc(value)));
|
|
1782
|
+
}
|
|
1783
|
+
function graphRecords(graphData, key) {
|
|
1784
|
+
const value = graphData[key];
|
|
1785
|
+
return Array.isArray(value) ? value.filter((item) => typeof item === "object" && item !== null && !Array.isArray(item)) : [];
|
|
1786
|
+
}
|
|
1787
|
+
function normalizeTraceGraphData(runs, network) {
|
|
1788
|
+
return require_graph_normalizer.normalizeGraphPayload({
|
|
1789
|
+
schema: "chain-insights.graph.v1",
|
|
1790
|
+
nodes: runs.flatMap((run) => graphRecords(run.result.graphData, "nodes")),
|
|
1791
|
+
edges: runs.flatMap((run) => graphRecords(run.result.graphData, "edges")),
|
|
1792
|
+
flows: runs.flatMap((run) => graphRecords(run.result.graphData, "flows")),
|
|
1793
|
+
deposits: runs.flatMap((run) => graphRecords(run.result.graphData, "deposits").map((item) => ({
|
|
1794
|
+
...item,
|
|
1795
|
+
run_role: run.role,
|
|
1796
|
+
run_address: run.address
|
|
1797
|
+
}))),
|
|
1798
|
+
source_matches: runs.flatMap((run) => graphRecords(run.result.graphData, "source_matches").map((item) => ({
|
|
1799
|
+
...item,
|
|
1800
|
+
run_role: run.role,
|
|
1801
|
+
run_address: run.address
|
|
1802
|
+
}))),
|
|
1803
|
+
reverse_leads: runs.flatMap((run) => graphRecords(run.result.graphData, "reverse_leads").map((item) => ({
|
|
1804
|
+
...item,
|
|
1805
|
+
run_role: run.role,
|
|
1806
|
+
run_address: run.address
|
|
1807
|
+
}))),
|
|
1808
|
+
edge_anchors: [],
|
|
1809
|
+
metadata: {
|
|
1810
|
+
network,
|
|
1811
|
+
generated_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1812
|
+
trace_tools: true
|
|
1813
|
+
}
|
|
1814
|
+
});
|
|
1815
|
+
}
|
|
1816
|
+
function traceArtifactPointersFromRun(run) {
|
|
1817
|
+
if (!run) return {};
|
|
1818
|
+
return {
|
|
1819
|
+
graph_json: run.files.graph,
|
|
1820
|
+
graph_html: run.files.graphHtml,
|
|
1821
|
+
table_json: run.files.compactEvidence,
|
|
1822
|
+
flows_csv: run.files.table,
|
|
1823
|
+
table_html: run.files.tableHtml,
|
|
1824
|
+
report_md: run.files.report
|
|
1825
|
+
};
|
|
1826
|
+
}
|
|
1827
|
+
function artifactEvidence(artifacts) {
|
|
1828
|
+
return Object.entries(artifacts).filter((entry) => typeof entry[1] === "string" && entry[1].length > 0).map(([kind, filePath]) => ({
|
|
1829
|
+
evidence_type: "artifact_pointer",
|
|
1830
|
+
path: filePath,
|
|
1831
|
+
summary: `${kind} artifact`
|
|
1832
|
+
}));
|
|
1833
|
+
}
|
|
1834
|
+
function traceAddressRoleForSeed(seedRole) {
|
|
1835
|
+
if (seedRole === "victim") return "seed_victim";
|
|
1836
|
+
if (seedRole === "suspect") return "seed_suspect";
|
|
1837
|
+
return "seed_deposit";
|
|
1838
|
+
}
|
|
1839
|
+
function addTraceAddress(addresses, address, role, rationale, labels = []) {
|
|
1840
|
+
if (!address) return;
|
|
1841
|
+
const existing = addresses.get(address);
|
|
1842
|
+
if (existing) {
|
|
1843
|
+
existing.roles.add(role);
|
|
1844
|
+
existing.labels = uniqueStrings([...existing.labels, ...labels]);
|
|
1845
|
+
if (role === "exchange") existing.is_exchange = true;
|
|
1846
|
+
if (!existing.rationale.includes(rationale)) existing.rationale.push(rationale);
|
|
1847
|
+
return;
|
|
1848
|
+
}
|
|
1849
|
+
addresses.set(address, {
|
|
1850
|
+
address,
|
|
1851
|
+
roles: new Set([role]),
|
|
1852
|
+
labels,
|
|
1853
|
+
is_exchange: role === "exchange" ? true : void 0,
|
|
1854
|
+
confidence: role.startsWith("seed_") || role === "exchange" ? "high" : "medium",
|
|
1855
|
+
rationale: [rationale]
|
|
1856
|
+
});
|
|
1857
|
+
}
|
|
1858
|
+
function edgeKey(from, to) {
|
|
1859
|
+
return `${from}\u0000${to}`;
|
|
1860
|
+
}
|
|
1861
|
+
function traceResultFromFundRuns(tool, seedRole, network, runs, options = {}) {
|
|
1862
|
+
const graphData = normalizeTraceGraphData(runs, network);
|
|
1863
|
+
const flows = graphRecords(graphData, "flows");
|
|
1864
|
+
const deposits = graphRecords(graphData, "deposits");
|
|
1865
|
+
const addresses = /* @__PURE__ */ new Map();
|
|
1866
|
+
for (const run of runs) addTraceAddress(addresses, run.address, traceAddressRoleForSeed(seedRole), `${seedRole} seed provided by caller`);
|
|
1867
|
+
const edgeIdsByPair = /* @__PURE__ */ new Map();
|
|
1868
|
+
const edges = flows.map((flow, index) => {
|
|
1869
|
+
const src = typeof flow["src"] === "string" ? flow["src"] : "";
|
|
1870
|
+
const dst = typeof flow["dst"] === "string" ? flow["dst"] : "";
|
|
1871
|
+
const edgeId = `e${index + 1}`;
|
|
1872
|
+
edgeIdsByPair.set(edgeKey(src, dst), edgeId);
|
|
1873
|
+
const terminalExchange = flow["terminal_exchange"] === true;
|
|
1874
|
+
addTraceAddress(addresses, src, runs.some((run) => run.address === src) ? traceAddressRoleForSeed(seedRole) : "candidate_intermediate", "Address appears in traced FLOWS_TO path");
|
|
1875
|
+
addTraceAddress(addresses, dst, terminalExchange ? "exchange" : "candidate_intermediate", terminalExchange ? "Terminal exchange endpoint reached" : "Address appears in traced FLOWS_TO path");
|
|
1876
|
+
return {
|
|
1877
|
+
edge_id: edgeId,
|
|
1878
|
+
from_address: src,
|
|
1879
|
+
to_address: dst,
|
|
1880
|
+
edge_type: "FLOWS_TO",
|
|
1881
|
+
amount_sum: numberValue(flow["amount_sum"]),
|
|
1882
|
+
amount_usd_sum: numberValue(flow["amount_usd_sum"]),
|
|
1883
|
+
tx_count: numberValue(flow["tx_count"]),
|
|
1884
|
+
first_tx_id: typeof flow["first_tx_id"] === "string" ? flow["first_tx_id"] : void 0,
|
|
1885
|
+
last_tx_id: typeof flow["last_tx_id"] === "string" ? flow["last_tx_id"] : void 0
|
|
1886
|
+
};
|
|
1887
|
+
}).filter((edge) => edge.from_address && edge.to_address);
|
|
1888
|
+
const paths = deposits.map((deposit, index) => {
|
|
1889
|
+
const depositAddress = typeof deposit["address"] === "string" ? deposit["address"] : typeof deposit["deposit_address"] === "string" ? deposit["deposit_address"] : "";
|
|
1890
|
+
const exchangeAddress = typeof deposit["exchangeAddress"] === "string" ? deposit["exchangeAddress"] : typeof deposit["exchange_address"] === "string" ? deposit["exchange_address"] : "";
|
|
1891
|
+
const pathAddresses = stringArrayValue(deposit["path"]) ?? [
|
|
1892
|
+
typeof deposit["run_address"] === "string" ? deposit["run_address"] : runs[0]?.address ?? "",
|
|
1893
|
+
depositAddress,
|
|
1894
|
+
exchangeAddress
|
|
1895
|
+
].filter(Boolean);
|
|
1896
|
+
addTraceAddress(addresses, depositAddress, "candidate_deposit", "Penultimate address before an exchange endpoint");
|
|
1897
|
+
if (exchangeAddress) addTraceAddress(addresses, exchangeAddress, "exchange", "Exchange endpoint reached");
|
|
1898
|
+
const edgeIds = [];
|
|
1899
|
+
for (let offset = 0; offset < pathAddresses.length - 1; offset += 1) {
|
|
1900
|
+
const id = edgeIdsByPair.get(edgeKey(pathAddresses[offset], pathAddresses[offset + 1]));
|
|
1901
|
+
if (id) edgeIds.push(id);
|
|
1902
|
+
}
|
|
1903
|
+
return {
|
|
1904
|
+
path_id: `p${index + 1}`,
|
|
1905
|
+
direction: "forward",
|
|
1906
|
+
source: pathAddresses[0] ?? "",
|
|
1907
|
+
target: exchangeAddress || depositAddress,
|
|
1908
|
+
addresses: pathAddresses,
|
|
1909
|
+
edge_ids: edgeIds,
|
|
1910
|
+
hops: numberValue(deposit["hops"]) ?? Math.max(pathAddresses.length - 1, 0),
|
|
1911
|
+
terminal_role: exchangeAddress ? "exchange" : "deposit",
|
|
1912
|
+
amount_sum: numberValue(deposit["amount_sum"]),
|
|
1913
|
+
amount_usd_sum: numberValue(deposit["amount_usd_sum"])
|
|
1914
|
+
};
|
|
1915
|
+
});
|
|
1916
|
+
const depositAddresses = uniqueStrings(deposits.map((deposit) => typeof deposit["address"] === "string" ? deposit["address"] : typeof deposit["deposit_address"] === "string" ? deposit["deposit_address"] : void 0));
|
|
1917
|
+
const exchangeAddresses = uniqueStrings(deposits.map((deposit) => typeof deposit["exchangeAddress"] === "string" ? deposit["exchangeAddress"] : typeof deposit["exchange_address"] === "string" ? deposit["exchange_address"] : void 0));
|
|
1918
|
+
const convergence = [...new Map(depositAddresses.map((address) => {
|
|
1919
|
+
const pathIds = paths.filter((path) => path.addresses.includes(address)).map((path) => path.path_id);
|
|
1920
|
+
return [address, {
|
|
1921
|
+
address,
|
|
1922
|
+
role: "candidate_deposit",
|
|
1923
|
+
path_ids: pathIds,
|
|
1924
|
+
reason: pathIds.length > 1 ? "Multiple traced paths converge into this deposit candidate." : "Single traced path reached this deposit candidate."
|
|
1925
|
+
}];
|
|
1926
|
+
})).values()].filter((entry) => entry.path_ids.length > 1);
|
|
1927
|
+
const candidateLabels = depositAddresses.map((address) => ({
|
|
1928
|
+
address,
|
|
1929
|
+
candidate_label: "candidate_deposit",
|
|
1930
|
+
confidence: "medium",
|
|
1931
|
+
evidence_path_ids: paths.filter((path) => path.addresses.includes(address)).map((path) => path.path_id),
|
|
1932
|
+
reason: "Penultimate address before an exchange endpoint in bounded FLOWS_TO trace.",
|
|
1933
|
+
promote_to_core_label: false
|
|
1934
|
+
}));
|
|
1935
|
+
const runArtifacts = runs.map((run, index) => ({
|
|
1936
|
+
run_id: `run_${index + 1}`,
|
|
1937
|
+
role: run.role,
|
|
1938
|
+
address: run.address,
|
|
1939
|
+
...traceArtifactPointersFromRun(run.result)
|
|
1940
|
+
}));
|
|
1941
|
+
const artifacts = {
|
|
1942
|
+
...traceArtifactPointersFromRun(runs[0]?.result),
|
|
1943
|
+
runs: runArtifacts
|
|
1944
|
+
};
|
|
1945
|
+
const artifactEvidenceEntries = runs.flatMap((run) => artifactEvidence(traceArtifactPointersFromRun(run.result)).map((entry) => ({
|
|
1946
|
+
...entry,
|
|
1947
|
+
run_role: run.role,
|
|
1948
|
+
address: run.address
|
|
1949
|
+
})));
|
|
1950
|
+
const recommendedNextTools = depositAddresses.length > 0 ? ["trace_deposit_sources", "address_risk"] : ["address_risk", "graph_query_batch"];
|
|
1951
|
+
const structuredContent = {
|
|
1952
|
+
schema: "chain-insights.trace.v1",
|
|
1953
|
+
tool,
|
|
1954
|
+
network,
|
|
1955
|
+
input: {
|
|
1956
|
+
addresses: runs.map((run) => run.address),
|
|
1957
|
+
seed_role: seedRole,
|
|
1958
|
+
...options.incidentTimestampMs !== void 0 ? { incident_timestamp_ms: options.incidentTimestampMs } : {},
|
|
1959
|
+
...options.timeRange ? { time_range: options.timeRange } : {},
|
|
1960
|
+
max_hops: options.maxHops ?? 3
|
|
1961
|
+
},
|
|
1962
|
+
summary: {
|
|
1963
|
+
seed_count: runs.length,
|
|
1964
|
+
path_count: paths.length,
|
|
1965
|
+
edge_count: edges.length,
|
|
1966
|
+
candidate_suspect_count: seedRole === "suspect" ? runs.length : 0,
|
|
1967
|
+
candidate_intermediate_count: [...addresses.values()].filter((entry) => entry.roles.has("candidate_intermediate")).length,
|
|
1968
|
+
candidate_deposit_count: depositAddresses.length,
|
|
1969
|
+
exchange_count: exchangeAddresses.length
|
|
1970
|
+
},
|
|
1971
|
+
addresses: [...addresses.values()].map((entry) => ({
|
|
1972
|
+
address: entry.address,
|
|
1973
|
+
roles: [...entry.roles],
|
|
1974
|
+
...entry.labels.length > 0 ? { labels: entry.labels } : {},
|
|
1975
|
+
...entry.is_exchange !== void 0 ? { is_exchange: entry.is_exchange } : {},
|
|
1976
|
+
confidence: entry.confidence,
|
|
1977
|
+
rationale: entry.rationale
|
|
1978
|
+
})),
|
|
1979
|
+
edges,
|
|
1980
|
+
paths,
|
|
1981
|
+
convergence,
|
|
1982
|
+
exchange_exposure: deposits.map((deposit) => ({
|
|
1983
|
+
deposit_address: typeof deposit["address"] === "string" ? deposit["address"] : deposit["deposit_address"],
|
|
1984
|
+
exchange_address: typeof deposit["exchangeAddress"] === "string" ? deposit["exchangeAddress"] : deposit["exchange_address"],
|
|
1985
|
+
path_ids: paths.filter((path) => path.addresses.includes(String(deposit["address"] ?? deposit["deposit_address"] ?? ""))).map((path) => path.path_id)
|
|
1986
|
+
})),
|
|
1987
|
+
candidate_labels: candidateLabels,
|
|
1988
|
+
artifacts,
|
|
1989
|
+
evidence: [...artifactEvidenceEntries, ...options.caseId ? [{
|
|
1990
|
+
evidence_type: "case_pointer",
|
|
1991
|
+
summary: `case_id=${options.caseId}`
|
|
1992
|
+
}] : []],
|
|
1993
|
+
continuation: {
|
|
1994
|
+
candidate_deposit_addresses: depositAddresses,
|
|
1995
|
+
candidate_suspect_addresses: seedRole === "suspect" ? runs.map((run) => run.address) : [],
|
|
1996
|
+
candidate_victim_addresses: [],
|
|
1997
|
+
recommended_next_tools: recommendedNextTools
|
|
1998
|
+
},
|
|
1999
|
+
warnings: depositAddresses.length === 0 ? ["No exchange deposit candidates were connected in the queried topology."] : []
|
|
2000
|
+
};
|
|
2001
|
+
return {
|
|
2002
|
+
summaryText: [
|
|
2003
|
+
`${seedRole === "victim" ? "Trace victim funds" : "Trace suspect funds"} complete for ${network}`,
|
|
2004
|
+
"",
|
|
2005
|
+
...runs.map((run) => `## ${run.role}: ${run.address}\n${run.result.summaryText}`)
|
|
2006
|
+
].join("\n"),
|
|
2007
|
+
structuredContent,
|
|
2008
|
+
graphData
|
|
2009
|
+
};
|
|
2010
|
+
}
|
|
2011
|
+
async function traceVictimFunds(remoteClient, config, options) {
|
|
3082
2012
|
const network = options.network.trim();
|
|
3083
|
-
const
|
|
3084
|
-
const
|
|
2013
|
+
const victims = parseAddressList(options.victimAddresses);
|
|
2014
|
+
const knownSuspects = parseAddressList(options.knownSuspectAddresses);
|
|
3085
2015
|
if (!network) throw new Error("network is required");
|
|
3086
|
-
if (
|
|
3087
|
-
if (
|
|
3088
|
-
if (
|
|
3089
|
-
const overlap = trusted.filter((address) => untrusted.includes(address));
|
|
3090
|
-
if (overlap.length > 0) throw new Error(`Address(es) appear in both trusted and untrusted lists: ${overlap.join(", ")}`);
|
|
2016
|
+
if (victims.length < 1) throw new Error("victim_addresses must contain at least 1 address");
|
|
2017
|
+
if (victims.length > 5) throw new Error("victim_addresses cannot exceed 5 addresses");
|
|
2018
|
+
if (knownSuspects.length > 5) throw new Error("known_suspect_addresses cannot exceed 5 addresses");
|
|
3091
2019
|
const runs = [];
|
|
3092
|
-
for (const address of
|
|
3093
|
-
role: "
|
|
2020
|
+
for (const address of victims) runs.push({
|
|
2021
|
+
role: "victim",
|
|
3094
2022
|
address,
|
|
3095
2023
|
result: await runFundFlowProbe(remoteClient, config, {
|
|
3096
2024
|
seedAddress: address,
|
|
@@ -3098,11 +2026,27 @@ async function trackFunds(remoteClient, config, options) {
|
|
|
3098
2026
|
caseId: options.caseId,
|
|
3099
2027
|
maxHops: options.maxHops,
|
|
3100
2028
|
perAddressLimit: options.perAddressLimit,
|
|
3101
|
-
minAmountSum: options.minAmountSum
|
|
2029
|
+
minAmountSum: options.minAmountSum,
|
|
2030
|
+
includeDepositTraceback: false,
|
|
2031
|
+
evidenceSource: "trace_victim_funds"
|
|
3102
2032
|
})
|
|
3103
2033
|
});
|
|
3104
|
-
|
|
3105
|
-
|
|
2034
|
+
return traceResultFromFundRuns("trace_victim_funds", "victim", network, runs, {
|
|
2035
|
+
incidentTimestampMs: options.incidentTimestampMs,
|
|
2036
|
+
timeRange: options.timeRange,
|
|
2037
|
+
maxHops: options.maxHops,
|
|
2038
|
+
caseId: options.caseId
|
|
2039
|
+
});
|
|
2040
|
+
}
|
|
2041
|
+
async function traceSuspectFunds(remoteClient, config, options) {
|
|
2042
|
+
const network = options.network.trim();
|
|
2043
|
+
const suspects = parseAddressList(options.suspectAddresses);
|
|
2044
|
+
if (!network) throw new Error("network is required");
|
|
2045
|
+
if (suspects.length < 1) throw new Error("suspect_addresses must contain at least 1 address");
|
|
2046
|
+
if (suspects.length > 5) throw new Error("suspect_addresses cannot exceed 5 addresses");
|
|
2047
|
+
const runs = [];
|
|
2048
|
+
for (const address of suspects) runs.push({
|
|
2049
|
+
role: "suspect",
|
|
3106
2050
|
address,
|
|
3107
2051
|
result: await runFundFlowProbe(remoteClient, config, {
|
|
3108
2052
|
seedAddress: address,
|
|
@@ -3110,67 +2054,345 @@ async function trackFunds(remoteClient, config, options) {
|
|
|
3110
2054
|
caseId: options.caseId,
|
|
3111
2055
|
maxHops: options.maxHops,
|
|
3112
2056
|
perAddressLimit: options.perAddressLimit,
|
|
3113
|
-
minAmountSum: options.minAmountSum
|
|
2057
|
+
minAmountSum: options.minAmountSum,
|
|
2058
|
+
includeDepositTraceback: false,
|
|
2059
|
+
evidenceSource: "trace_suspect_funds"
|
|
3114
2060
|
})
|
|
3115
2061
|
});
|
|
2062
|
+
return traceResultFromFundRuns("trace_suspect_funds", "suspect", network, runs, {
|
|
2063
|
+
incidentTimestampMs: options.incidentTimestampMs,
|
|
2064
|
+
timeRange: options.timeRange,
|
|
2065
|
+
maxHops: options.maxHops,
|
|
2066
|
+
caseId: options.caseId
|
|
2067
|
+
});
|
|
2068
|
+
}
|
|
2069
|
+
function reverseDepositSourceQueryAtDepth(depositAddresses, depth) {
|
|
2070
|
+
const intermediateVariables = Array.from({ length: Math.max(depth - 1, 0) }, (_, index) => `n${index + 1}`);
|
|
2071
|
+
const nodeVariables = [
|
|
2072
|
+
"source",
|
|
2073
|
+
...intermediateVariables,
|
|
2074
|
+
"deposit"
|
|
2075
|
+
];
|
|
2076
|
+
const edgeVariables = Array.from({ length: depth }, (_, index) => `r${index + 1}`);
|
|
2077
|
+
const relationshipChain = edgeVariables.map((edgeVariable, index) => {
|
|
2078
|
+
return `-[${edgeVariable}:FLOWS_TO]->(${index === edgeVariables.length - 1 ? "deposit" : intermediateVariables[index]}:Address)`;
|
|
2079
|
+
}).join("");
|
|
2080
|
+
const depositPredicates = depositAddresses.map((address) => `deposit.address = "${escapeCypherString(address)}"`);
|
|
2081
|
+
const nonExchangePredicates = [
|
|
2082
|
+
"source",
|
|
2083
|
+
...intermediateVariables,
|
|
2084
|
+
"deposit"
|
|
2085
|
+
].map((nodeVariable) => `${nodeVariable}.is_exchange IS NULL`);
|
|
2086
|
+
return {
|
|
2087
|
+
id: `reverse_deposit_sources_${depth}`,
|
|
2088
|
+
query: [
|
|
2089
|
+
`MATCH (source:Address)${relationshipChain}`,
|
|
2090
|
+
`WHERE (${depositPredicates.join(" OR ")}) AND source.address <> deposit.address AND ${nonExchangePredicates.join(" AND ")}`,
|
|
2091
|
+
`RETURN DISTINCT source.address AS source_address, source.is_exchange AS source_is_exchange, deposit.address AS deposit_address, deposit.is_exchange AS deposit_is_exchange, ${depth} AS hop, [${nodeVariables.map((nodeVariable) => `${nodeVariable}.address`).join(", ")}] AS addresses, [${nodeVariables.map(pathNodeMap).join(", ")}] AS path_nodes, [${edgeVariables.map(flowEdgeMap).join(", ")}] AS edge_props`,
|
|
2092
|
+
"LIMIT 500"
|
|
2093
|
+
].join(" ")
|
|
2094
|
+
};
|
|
2095
|
+
}
|
|
2096
|
+
function rowNodeIsExchange(value) {
|
|
2097
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
2098
|
+
const record = value;
|
|
2099
|
+
return isExchangeFlag(record["is_exchange"]) || hasExactExchangeLabel(stringArrayValue(record["labels"])) || hasExactExchangeLabel(stringArrayValue(record["system_labels"]));
|
|
2100
|
+
}
|
|
2101
|
+
function reverseDepositSourceRowUsesExchange(row) {
|
|
2102
|
+
if (isExchangeFlag(row["source_is_exchange"]) || isExchangeFlag(row["deposit_is_exchange"])) return true;
|
|
2103
|
+
if (!Array.isArray(row["path_nodes"])) return false;
|
|
2104
|
+
return row["path_nodes"].some(rowNodeIsExchange);
|
|
2105
|
+
}
|
|
2106
|
+
function htmlEscape(value) {
|
|
2107
|
+
return String(value ?? "").replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll("\"", """).replaceAll("'", "'");
|
|
2108
|
+
}
|
|
2109
|
+
function buildTraceSourceTableHtml(tool, network, rows) {
|
|
2110
|
+
const headers = [
|
|
2111
|
+
"path_id",
|
|
2112
|
+
"source_address",
|
|
2113
|
+
"deposit_address",
|
|
2114
|
+
"hop",
|
|
2115
|
+
"amount_sum",
|
|
2116
|
+
"first_tx_id"
|
|
2117
|
+
];
|
|
2118
|
+
const body = rows.map((row) => `<tr>${headers.map((header) => `<td>${htmlEscape(row[header])}</td>`).join("")}</tr>`).join("\n");
|
|
2119
|
+
return `<!doctype html>
|
|
2120
|
+
<html lang="en">
|
|
2121
|
+
<head>
|
|
2122
|
+
<meta charset="utf-8">
|
|
2123
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
2124
|
+
<title>${htmlEscape(tool)} Table</title>
|
|
2125
|
+
<style>
|
|
2126
|
+
:root { color-scheme: dark; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #0b0d12; color: #f4f2ea; }
|
|
2127
|
+
body { margin: 0; background: #0b0d12; color: #f4f2ea; }
|
|
2128
|
+
main { padding: 24px; }
|
|
2129
|
+
h1 { font-size: 20px; margin: 0 0 8px; font-weight: 650; }
|
|
2130
|
+
.meta { display: grid; gap: 6px; margin: 0 0 20px; color: rgba(244,242,234,.72); font-size: 13px; }
|
|
2131
|
+
.table-wrap { overflow: auto; border: 1px solid rgba(255,255,255,.1); border-radius: 8px; background: #10131b; }
|
|
2132
|
+
table { border-collapse: collapse; width: 100%; min-width: 980px; font-size: 12px; }
|
|
2133
|
+
th, td { border-bottom: 1px solid rgba(255,255,255,.08); padding: 8px 10px; text-align: left; vertical-align: top; }
|
|
2134
|
+
th { position: sticky; top: 0; background: #161a24; color: #f2dda6; font-weight: 600; z-index: 1; }
|
|
2135
|
+
td { color: rgba(244,242,234,.86); font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
|
2136
|
+
tr:hover td { background: rgba(242,221,166,.045); }
|
|
2137
|
+
</style>
|
|
2138
|
+
</head>
|
|
2139
|
+
<body>
|
|
2140
|
+
<main>
|
|
2141
|
+
<h1>${htmlEscape(tool)} Table</h1>
|
|
2142
|
+
<div class="meta">
|
|
2143
|
+
<div>Network: <strong>${htmlEscape(network)}</strong></div>
|
|
2144
|
+
<div>Generated: <strong>${htmlEscape((/* @__PURE__ */ new Date()).toISOString())}</strong></div>
|
|
2145
|
+
<div>Rows: <strong>${rows.length}</strong></div>
|
|
2146
|
+
</div>
|
|
2147
|
+
<div class="table-wrap">
|
|
2148
|
+
<table>
|
|
2149
|
+
<thead><tr>${headers.map((header) => `<th>${htmlEscape(header)}</th>`).join("")}</tr></thead>
|
|
2150
|
+
<tbody>
|
|
2151
|
+
${body}
|
|
2152
|
+
</tbody>
|
|
2153
|
+
</table>
|
|
2154
|
+
</div>
|
|
2155
|
+
</main>
|
|
2156
|
+
</body>
|
|
2157
|
+
</html>
|
|
2158
|
+
`;
|
|
2159
|
+
}
|
|
2160
|
+
async function writeTraceSourceArtifacts(tool, network, graphData, rows, summaryText) {
|
|
2161
|
+
const paths = require_output_root.workspaceOutputPaths();
|
|
2162
|
+
await Promise.all([
|
|
2163
|
+
(0, node_fs_promises.mkdir)(paths.reportsRoot, { recursive: true }),
|
|
2164
|
+
(0, node_fs_promises.mkdir)(paths.reportGraphsRoot, { recursive: true }),
|
|
2165
|
+
(0, node_fs_promises.mkdir)(paths.reportTablesRoot, { recursive: true })
|
|
2166
|
+
]);
|
|
2167
|
+
const slug = `${(/* @__PURE__ */ new Date()).toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z")}_${tool}`;
|
|
2168
|
+
const graphPath = node_path.default.join(paths.reportGraphsRoot, `${slug}.graph.json`);
|
|
2169
|
+
const tableJsonPath = node_path.default.join(paths.reportTablesRoot, `${slug}.compact-evidence.json`);
|
|
2170
|
+
const csvPath = node_path.default.join(paths.reportTablesRoot, `${slug}.flows.csv`);
|
|
2171
|
+
const tableHtmlPath = node_path.default.join(paths.reportsRoot, `${slug}.table.html`);
|
|
2172
|
+
const reportPath = node_path.default.join(paths.reportsRoot, `${slug}.trace-report.md`);
|
|
2173
|
+
const graphHtmlPath = node_path.default.join(paths.reportsRoot, `${slug}.graph.html`);
|
|
2174
|
+
const { generateInlineGraphHtml } = await Promise.resolve().then(() => require("./html-generator-Bx3UcLTB.cjs")).then((n) => n.html_generator_exports);
|
|
2175
|
+
const csv = ["path_id,source_address,deposit_address,hop,amount_sum,first_tx_id", ...rows.map((row) => [
|
|
2176
|
+
row["path_id"] ?? "",
|
|
2177
|
+
row["source_address"] ?? "",
|
|
2178
|
+
row["deposit_address"] ?? "",
|
|
2179
|
+
row["hop"] ?? "",
|
|
2180
|
+
row["amount_sum"] ?? "",
|
|
2181
|
+
row["first_tx_id"] ?? ""
|
|
2182
|
+
].map((value) => JSON.stringify(String(value))).join(","))].join("\n") + "\n";
|
|
2183
|
+
await (0, node_fs_promises.writeFile)(graphPath, JSON.stringify(graphData, null, 2) + "\n", { mode: 384 });
|
|
2184
|
+
await (0, node_fs_promises.writeFile)(tableJsonPath, JSON.stringify(rows, null, 2) + "\n", { mode: 384 });
|
|
2185
|
+
await (0, node_fs_promises.writeFile)(csvPath, csv, { mode: 384 });
|
|
2186
|
+
await (0, node_fs_promises.writeFile)(tableHtmlPath, buildTraceSourceTableHtml(tool, network, rows), { mode: 384 });
|
|
2187
|
+
await (0, node_fs_promises.writeFile)(reportPath, summaryText + "\n", { mode: 384 });
|
|
2188
|
+
await (0, node_fs_promises.writeFile)(graphHtmlPath, generateInlineGraphHtml(graphData), { mode: 384 });
|
|
2189
|
+
return {
|
|
2190
|
+
graph_json: graphPath,
|
|
2191
|
+
graph_html: graphHtmlPath,
|
|
2192
|
+
table_json: tableJsonPath,
|
|
2193
|
+
flows_csv: csvPath,
|
|
2194
|
+
table_html: tableHtmlPath,
|
|
2195
|
+
report_md: reportPath
|
|
2196
|
+
};
|
|
2197
|
+
}
|
|
2198
|
+
async function traceDepositSources(remoteClient, _config, options) {
|
|
2199
|
+
const network = options.network.trim();
|
|
2200
|
+
const deposits = parseAddressList(options.depositAddresses);
|
|
2201
|
+
if (!network) throw new Error("network is required");
|
|
2202
|
+
if (deposits.length < 1) throw new Error("deposit_addresses must contain at least 1 address");
|
|
2203
|
+
if (deposits.length > 5) throw new Error("deposit_addresses cannot exceed 5 addresses");
|
|
2204
|
+
const maxHops = clampInt(options.maxHops, 2, 1, 5);
|
|
2205
|
+
const batch = await callGraphBatch(remoteClient, network, Array.from({ length: maxHops }, (_, index) => reverseDepositSourceQueryAtDepth(deposits, index + 1)));
|
|
2206
|
+
const failures = [];
|
|
2207
|
+
const rows = optionalResultsWithPrefix(batch, "reverse_deposit_sources_", failures).filter((row) => !reverseDepositSourceRowUsesExchange(row)).map((row, index) => ({
|
|
2208
|
+
...row,
|
|
2209
|
+
path_id: `p${index + 1}`
|
|
2210
|
+
}));
|
|
2211
|
+
const addresses = /* @__PURE__ */ new Map();
|
|
2212
|
+
for (const deposit of deposits) addTraceAddress(addresses, deposit, "seed_deposit", "Deposit/cashout seed provided by caller");
|
|
2213
|
+
const edges = [];
|
|
2214
|
+
const paths = [];
|
|
2215
|
+
for (const row of rows) {
|
|
2216
|
+
const sourceAddress = typeof row["source_address"] === "string" ? row["source_address"] : "";
|
|
2217
|
+
const depositAddress = typeof row["deposit_address"] === "string" ? row["deposit_address"] : "";
|
|
2218
|
+
const pathAddresses = stringArrayValue(row["addresses"]) ?? [sourceAddress, depositAddress].filter(Boolean);
|
|
2219
|
+
addTraceAddress(addresses, sourceAddress, "candidate_suspect", "Upstream address funds a suspected deposit/cashout seed");
|
|
2220
|
+
addTraceAddress(addresses, depositAddress, "seed_deposit", "Deposit/cashout seed provided by caller");
|
|
2221
|
+
const edgeProps = Array.isArray(row["edge_props"]) ? row["edge_props"] : [];
|
|
2222
|
+
const edgeIds = [];
|
|
2223
|
+
for (let index = 0; index < pathAddresses.length - 1; index += 1) {
|
|
2224
|
+
const props = edgeProps[index] ?? {};
|
|
2225
|
+
const edgeId = `e${edges.length + 1}`;
|
|
2226
|
+
edgeIds.push(edgeId);
|
|
2227
|
+
edges.push({
|
|
2228
|
+
edge_id: edgeId,
|
|
2229
|
+
from_address: pathAddresses[index],
|
|
2230
|
+
to_address: pathAddresses[index + 1],
|
|
2231
|
+
edge_type: "FLOWS_TO",
|
|
2232
|
+
amount_sum: numberValue(props["amount_sum"]) ?? numberValue(row["amount_sum"]),
|
|
2233
|
+
amount_usd_sum: numberValue(props["amount_usd_sum"]) ?? numberValue(row["amount_usd_sum"]),
|
|
2234
|
+
tx_count: numberValue(props["tx_count"]) ?? numberValue(row["tx_count"]),
|
|
2235
|
+
first_seen_timestamp: numberValue(props["first_seen_timestamp"]) ?? numberValue(row["first_seen_timestamp"]),
|
|
2236
|
+
last_seen_timestamp: numberValue(props["last_seen_timestamp"]) ?? numberValue(row["last_seen_timestamp"]),
|
|
2237
|
+
first_tx_id: typeof props["first_tx_id"] === "string" ? props["first_tx_id"] : typeof row["first_tx_id"] === "string" ? row["first_tx_id"] : void 0,
|
|
2238
|
+
last_tx_id: typeof props["last_tx_id"] === "string" ? props["last_tx_id"] : typeof row["last_tx_id"] === "string" ? row["last_tx_id"] : void 0
|
|
2239
|
+
});
|
|
2240
|
+
}
|
|
2241
|
+
paths.push({
|
|
2242
|
+
path_id: row["path_id"],
|
|
2243
|
+
direction: "reverse",
|
|
2244
|
+
source: depositAddress,
|
|
2245
|
+
target: sourceAddress,
|
|
2246
|
+
addresses: [...pathAddresses].reverse(),
|
|
2247
|
+
edge_ids: [...edgeIds].reverse(),
|
|
2248
|
+
hops: numberValue(row["hop"]) ?? Math.max(pathAddresses.length - 1, 0),
|
|
2249
|
+
terminal_role: "source",
|
|
2250
|
+
amount_sum: numberValue(row["amount_sum"]),
|
|
2251
|
+
amount_usd_sum: numberValue(row["amount_usd_sum"]),
|
|
2252
|
+
first_seen_ms: numberValue(row["first_seen_timestamp"]),
|
|
2253
|
+
last_seen_ms: numberValue(row["last_seen_timestamp"])
|
|
2254
|
+
});
|
|
2255
|
+
}
|
|
2256
|
+
const sourceToPathIds = /* @__PURE__ */ new Map();
|
|
2257
|
+
const sourceToDeposits = /* @__PURE__ */ new Map();
|
|
2258
|
+
for (const row of rows) {
|
|
2259
|
+
const source = typeof row["source_address"] === "string" ? row["source_address"] : "";
|
|
2260
|
+
const deposit = typeof row["deposit_address"] === "string" ? row["deposit_address"] : "";
|
|
2261
|
+
if (!source) continue;
|
|
2262
|
+
sourceToPathIds.set(source, [...sourceToPathIds.get(source) ?? [], String(row["path_id"])]);
|
|
2263
|
+
if (!sourceToDeposits.has(source)) sourceToDeposits.set(source, /* @__PURE__ */ new Set());
|
|
2264
|
+
if (deposit) sourceToDeposits.get(source).add(deposit);
|
|
2265
|
+
}
|
|
2266
|
+
const convergence = [...sourceToPathIds.entries()].filter(([address]) => (sourceToDeposits.get(address)?.size ?? 0) > 1).map(([address, pathIds]) => ({
|
|
2267
|
+
address,
|
|
2268
|
+
role: "candidate_suspect",
|
|
2269
|
+
path_ids: pathIds,
|
|
2270
|
+
reason: "Same upstream source funds multiple provided deposit/cashout seeds."
|
|
2271
|
+
}));
|
|
2272
|
+
const candidateSuspects = convergence.map((entry) => entry.address);
|
|
2273
|
+
const candidateLabels = [...sourceToPathIds.keys()].map((address) => ({
|
|
2274
|
+
address,
|
|
2275
|
+
candidate_label: "candidate_suspect",
|
|
2276
|
+
confidence: candidateSuspects.includes(address) ? "high" : "medium",
|
|
2277
|
+
evidence_path_ids: sourceToPathIds.get(address) ?? [],
|
|
2278
|
+
reason: candidateSuspects.includes(address) ? "Upstream source converges into multiple provided deposit/cashout seeds." : "Upstream source funds a provided deposit/cashout seed.",
|
|
2279
|
+
promote_to_core_label: false
|
|
2280
|
+
}));
|
|
3116
2281
|
const graphData = require_graph_normalizer.normalizeGraphPayload({
|
|
3117
2282
|
schema: "chain-insights.graph.v1",
|
|
3118
|
-
nodes:
|
|
3119
|
-
|
|
3120
|
-
|
|
3121
|
-
|
|
3122
|
-
...
|
|
3123
|
-
|
|
3124
|
-
|
|
3125
|
-
|
|
3126
|
-
|
|
3127
|
-
|
|
3128
|
-
|
|
3129
|
-
|
|
3130
|
-
|
|
3131
|
-
|
|
3132
|
-
|
|
3133
|
-
|
|
3134
|
-
|
|
3135
|
-
|
|
2283
|
+
nodes: [...addresses.values()].map((entry) => ({
|
|
2284
|
+
id: entry.address,
|
|
2285
|
+
address: entry.address,
|
|
2286
|
+
node_type: "address",
|
|
2287
|
+
roles: [...entry.roles],
|
|
2288
|
+
labels: entry.labels
|
|
2289
|
+
})),
|
|
2290
|
+
edges: edges.map((edge) => ({
|
|
2291
|
+
source: edge["from_address"],
|
|
2292
|
+
target: edge["to_address"],
|
|
2293
|
+
edge_type: "flows_to",
|
|
2294
|
+
amount_sum: edge["amount_sum"],
|
|
2295
|
+
tx_count: edge["tx_count"],
|
|
2296
|
+
first_tx_id: edge["first_tx_id"],
|
|
2297
|
+
last_tx_id: edge["last_tx_id"],
|
|
2298
|
+
direction: "traceback"
|
|
2299
|
+
})),
|
|
2300
|
+
flows: edges.map((edge, index) => ({
|
|
2301
|
+
hop: index + 1,
|
|
2302
|
+
src: edge["from_address"],
|
|
2303
|
+
dst: edge["to_address"],
|
|
2304
|
+
amount_sum: edge["amount_sum"] ?? 0,
|
|
2305
|
+
terminal_exchange: false
|
|
2306
|
+
})),
|
|
3136
2307
|
edge_anchors: [],
|
|
3137
2308
|
metadata: {
|
|
3138
2309
|
network,
|
|
3139
|
-
|
|
3140
|
-
untrusted_addresses: untrusted,
|
|
2310
|
+
deposit_addresses: deposits,
|
|
3141
2311
|
generated_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
3142
2312
|
}
|
|
3143
2313
|
});
|
|
2314
|
+
const summaryText = [
|
|
2315
|
+
`Trace deposit sources complete for ${network}`,
|
|
2316
|
+
"",
|
|
2317
|
+
`Deposit seeds: ${deposits.join(", ")}`,
|
|
2318
|
+
`Reverse path(s): ${paths.length}`,
|
|
2319
|
+
`Shared upstream convergence: ${convergence.length}`
|
|
2320
|
+
].join("\n");
|
|
2321
|
+
const artifacts = await writeTraceSourceArtifacts("trace_deposit_sources", network, graphData, rows, summaryText);
|
|
2322
|
+
const evidence = artifactEvidence(artifacts);
|
|
2323
|
+
if (options.caseId) {
|
|
2324
|
+
const { EvidenceStore } = await Promise.resolve().then(() => require("./cases-sTY5aXav.cjs"));
|
|
2325
|
+
await EvidenceStore.append(options.caseId, {
|
|
2326
|
+
source: "trace_deposit_sources",
|
|
2327
|
+
queryParams: `network=${network} deposit_addresses=${deposits.join(",")} max_hops=${maxHops}`,
|
|
2328
|
+
content: JSON.stringify({
|
|
2329
|
+
schema: "chain-insights.evidence_pointer.v1",
|
|
2330
|
+
source: "trace_deposit_sources",
|
|
2331
|
+
network,
|
|
2332
|
+
deposit_addresses: deposits,
|
|
2333
|
+
files: artifacts,
|
|
2334
|
+
compact_sha256: (0, node_crypto.createHash)("sha256").update(JSON.stringify({
|
|
2335
|
+
rows,
|
|
2336
|
+
convergence
|
|
2337
|
+
})).digest("hex")
|
|
2338
|
+
}, null, 2)
|
|
2339
|
+
});
|
|
2340
|
+
evidence.push({
|
|
2341
|
+
evidence_type: "case_pointer",
|
|
2342
|
+
summary: `case_id=${options.caseId}`
|
|
2343
|
+
});
|
|
2344
|
+
}
|
|
3144
2345
|
return {
|
|
3145
|
-
summaryText
|
|
3146
|
-
`Track funds complete for ${network}`,
|
|
3147
|
-
"",
|
|
3148
|
-
`Trusted addresses: ${trusted.join(", ")}`,
|
|
3149
|
-
`Untrusted addresses: ${untrusted.join(", ") || "none"}`,
|
|
3150
|
-
"",
|
|
3151
|
-
...runs.map((run) => `## ${run.role}: ${run.address}\n${run.result.summaryText}`)
|
|
3152
|
-
].join("\n"),
|
|
2346
|
+
summaryText,
|
|
3153
2347
|
structuredContent: {
|
|
3154
|
-
schema: "chain-insights.
|
|
3155
|
-
tool: "
|
|
3156
|
-
|
|
3157
|
-
|
|
3158
|
-
|
|
3159
|
-
|
|
3160
|
-
|
|
3161
|
-
|
|
3162
|
-
|
|
3163
|
-
|
|
3164
|
-
|
|
3165
|
-
|
|
3166
|
-
|
|
3167
|
-
|
|
2348
|
+
schema: "chain-insights.trace.v1",
|
|
2349
|
+
tool: "trace_deposit_sources",
|
|
2350
|
+
network,
|
|
2351
|
+
input: {
|
|
2352
|
+
addresses: deposits,
|
|
2353
|
+
seed_role: "deposit",
|
|
2354
|
+
...options.timeRange ? { time_range: options.timeRange } : {},
|
|
2355
|
+
max_hops: maxHops
|
|
2356
|
+
},
|
|
2357
|
+
summary: {
|
|
2358
|
+
seed_count: deposits.length,
|
|
2359
|
+
path_count: paths.length,
|
|
2360
|
+
edge_count: edges.length,
|
|
2361
|
+
candidate_suspect_count: sourceToPathIds.size,
|
|
2362
|
+
candidate_intermediate_count: 0,
|
|
2363
|
+
candidate_deposit_count: deposits.length,
|
|
2364
|
+
exchange_count: 0
|
|
2365
|
+
},
|
|
2366
|
+
addresses: [...addresses.values()].map((entry) => ({
|
|
2367
|
+
address: entry.address,
|
|
2368
|
+
roles: [...entry.roles],
|
|
2369
|
+
confidence: entry.confidence,
|
|
2370
|
+
rationale: entry.rationale
|
|
2371
|
+
})),
|
|
2372
|
+
edges,
|
|
2373
|
+
paths,
|
|
2374
|
+
convergence,
|
|
2375
|
+
exchange_exposure: [],
|
|
2376
|
+
candidate_labels: candidateLabels,
|
|
2377
|
+
artifacts,
|
|
2378
|
+
evidence: [...evidence, ...failures.length > 0 ? [{
|
|
2379
|
+
evidence_type: "query_summary",
|
|
2380
|
+
summary: `partial query failures: ${failures.length}`
|
|
2381
|
+
}] : []],
|
|
2382
|
+
continuation: {
|
|
2383
|
+
candidate_deposit_addresses: deposits,
|
|
2384
|
+
candidate_suspect_addresses: candidateSuspects,
|
|
2385
|
+
candidate_victim_addresses: [],
|
|
2386
|
+
recommended_next_tools: candidateSuspects.length > 0 ? ["trace_suspect_funds", "address_risk"] : ["address_risk", "graph_query_batch"]
|
|
2387
|
+
},
|
|
2388
|
+
warnings: paths.length === 0 ? ["No upstream sources were connected in the queried topology."] : []
|
|
3168
2389
|
},
|
|
3169
2390
|
graphData
|
|
3170
2391
|
};
|
|
3171
2392
|
}
|
|
3172
2393
|
//#endregion
|
|
3173
2394
|
exports.addressRisk = addressRisk;
|
|
3174
|
-
exports.scamTopology = scamTopology;
|
|
3175
2395
|
exports.stakeInsights = stakeInsights;
|
|
3176
|
-
exports.
|
|
2396
|
+
exports.traceDepositSources = traceDepositSources;
|
|
2397
|
+
exports.traceSuspectFunds = traceSuspectFunds;
|
|
2398
|
+
exports.traceVictimFunds = traceVictimFunds;
|