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.
Files changed (67) hide show
  1. package/README.md +40 -14
  2. package/dist/cases-Cp9DUbEV.mjs +6 -0
  3. package/dist/{cases-c0iV-XLI.cjs → cases-sTY5aXav.cjs} +3 -3
  4. package/dist/cli.cjs +122 -66
  5. package/dist/cli.mjs +122 -66
  6. package/dist/cli.mjs.map +1 -1
  7. package/dist/{viz-Da9YWN_I.cjs → data-extractor-Cavd7wHk.cjs} +11 -34
  8. package/dist/{viz-DkJyqlUu.mjs → data-extractor-DZUJu1Bz.mjs} +3 -32
  9. package/dist/data-extractor-DZUJu1Bz.mjs.map +1 -0
  10. package/dist/{dossier-Br62hCG7.cjs → dossier-BXy57V4-.cjs} +13 -1
  11. package/dist/{dossier-Bl0NkJKC.mjs → dossier-Bjpcbcxa.mjs} +4 -2
  12. package/dist/{dossier-Bl0NkJKC.mjs.map → dossier-Bjpcbcxa.mjs.map} +1 -1
  13. package/dist/export-BqTCO9lP.mjs +591 -0
  14. package/dist/export-BqTCO9lP.mjs.map +1 -0
  15. package/dist/export-DsXgtCwO.cjs +592 -0
  16. package/dist/index.cjs +1 -1
  17. package/dist/index.mjs +1 -1
  18. package/dist/{init-DBC9Ml33.mjs → init-DLBL_nVG.mjs} +27 -1
  19. package/dist/{init-DBC9Ml33.mjs.map → init-DLBL_nVG.mjs.map} +1 -1
  20. package/dist/{init-CFaUWgjK.cjs → init-zqbd7i-_.cjs} +26 -0
  21. package/dist/mcp-proxy.cjs +215 -77
  22. package/dist/mcp-proxy.d.cts.map +1 -1
  23. package/dist/mcp-proxy.d.mts.map +1 -1
  24. package/dist/mcp-proxy.mjs +215 -77
  25. package/dist/mcp-proxy.mjs.map +1 -1
  26. package/dist/{public-tools-BwguvIsf.cjs → public-tools-BvMb3H2P.cjs} +701 -1479
  27. package/dist/{public-tools-DoRNhMn9.mjs → public-tools-wJoAFDFa.mjs} +700 -1479
  28. package/dist/public-tools-wJoAFDFa.mjs.map +1 -0
  29. package/dist/{resolver-D7VBb0uB.mjs → resolver-2jXNtWQO.mjs} +12 -29
  30. package/dist/resolver-2jXNtWQO.mjs.map +1 -0
  31. package/dist/{resolver-BUU7ZgW-.cjs → resolver-CZdQwKvh.cjs} +11 -28
  32. package/dist/{runner-BCDeBYsR.cjs → runner-BhZ4lnF1.cjs} +2 -2
  33. package/dist/{runner-CTFK0Qcg.mjs → runner-DIJSbkjc.mjs} +3 -3
  34. package/dist/{runner-CTFK0Qcg.mjs.map → runner-DIJSbkjc.mjs.map} +1 -1
  35. package/dist/{selector-CTUiQrzI.mjs → selector-CF2o5gxN.mjs} +2 -2
  36. package/dist/{selector-CTUiQrzI.mjs.map → selector-CF2o5gxN.mjs.map} +1 -1
  37. package/dist/{selector-DBS2jYH4.cjs → selector-DfAMZEC9.cjs} +1 -1
  38. package/dist/{session-DwyikazY.cjs → session-BT7VpbAd.cjs} +13 -1
  39. package/dist/{session-Bha3zFrx.mjs → session-DROyhebe.mjs} +4 -2
  40. package/dist/{session-Bha3zFrx.mjs.map → session-DROyhebe.mjs.map} +1 -1
  41. package/dist/{store-BT2SCcQr.mjs → store-CTtqQtaE.mjs} +10 -4
  42. package/dist/{store-BT2SCcQr.mjs.map → store-CTtqQtaE.mjs.map} +1 -1
  43. package/dist/{store-DogLawSj.cjs → store-CqPfs47P.cjs} +37 -7
  44. package/dist/{tool-visibility-BHRFLXuU.mjs → tool-visibility-BpyZHRBi.mjs} +4 -2
  45. package/dist/tool-visibility-BpyZHRBi.mjs.map +1 -0
  46. package/dist/{tool-visibility-iAVQV3t0.cjs → tool-visibility-Buq7YdUZ.cjs} +3 -1
  47. package/dist/viz-5y24S5X1.mjs +35 -0
  48. package/dist/viz-5y24S5X1.mjs.map +1 -0
  49. package/dist/viz-Dqp3C5kb.cjs +44 -0
  50. package/docs/contributing.md +3 -2
  51. package/docs/graph-tools.md +129 -118
  52. package/docs/investigation-workspaces.md +14 -0
  53. package/docs/mcp-proxy.md +20 -2
  54. package/package.json +1 -1
  55. package/skills/chain-insights-bittensor-cypher/SKILL.md +24 -0
  56. package/skills/chain-insights-cypher/SKILL.md +16 -1
  57. package/skills/chain-insights-cypher/references/memgraph-examples.md +193 -0
  58. package/skills/chain-insights-developer-experience/SKILL.md +26 -6
  59. package/skills/chain-insights-investigation/SKILL.md +64 -48
  60. package/skills/chain-insights-trace-funds/SKILL.md +80 -197
  61. package/skills/test-chain-insights-graphrag-mcp/SKILL.md +1 -1
  62. package/skills/test-chain-insights-graphrag-mcp/scripts/run-uat.sh +4 -4
  63. package/dist/cases-qjPtbnUd.mjs +0 -6
  64. package/dist/public-tools-DoRNhMn9.mjs.map +0 -1
  65. package/dist/resolver-D7VBb0uB.mjs.map +0 -1
  66. package/dist/tool-visibility-BHRFLXuU.mjs.map +0 -1
  67. 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$3(value) {
72
+ function escapeCypherString$2(value) {
72
73
  return value.replaceAll("\\", "\\\\").replaceAll("\"", "\\\"");
73
74
  }
74
- function sanitizeSegment$1(value) {
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$3(result) {
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$3(result) {
103
- const text = textFromToolResult$3(result).trim();
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$3(remoteClient, network, queries) {
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$3(result) || "graph_query_batch failed");
130
- return parseGraphBatchResult$3(result);
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$1(network)}.graph-schema.json`);
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$3(remoteClient, network, SCHEMA_QUERY_SET));
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$3(address)}"})${relationshipChain}`,
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$3(depositAddress)}"})`,
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$3(address)}"`).join(" OR ")}) AND sender.is_exchange IS NULL AND sender.address <> deposit.address`,
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$3(pair.src)}" AND b.address = "${escapeCypherString$3(pair.dst)}")`).join(" OR ")})`,
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$3(value) {
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$3(terminalEdge["amount_sum"]) ?? numberValue$3(terminalEdge["amount_usd_sum"]);
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.dst_labels?.includes("Exchange") === true || flow.dst_node?.system_labels?.includes("Exchange") === true;
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$3(terminalEdge["amount_sum"]),
334
- amount_usd_sum: numberValue$3(terminalEdge["amount_usd_sum"]),
335
- hops: numberValue$3(row["hops"]) ?? pathAddresses.length - 1,
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$3(edge["amount_sum"]) ?? numberValue$3(edge["amount_usd_sum"]) ?? 0;
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$3(edge["amount_usd_sum"]),
366
- tx_count: numberValue$3(edge["tx_count"]),
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$3(remoteClient, network, [query]);
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$3(props["amount_sum"]) ?? flow.amount_sum;
397
- flow.amount_usd_sum = numberValue$3(props["amount_usd_sum"]);
398
- flow.tx_count = numberValue$3(props["tx_count"]);
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$3(props["amount_sum"]);
406
- deposit.amount_usd_sum = numberValue$3(props["amount_usd_sum"]);
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$3(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) => {
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$3(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)));
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$3(row["hops"]) ?? Math.max(pathAddresses.length - 1, 0),
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$3(remoteClient, options.network, [reverseLeadsQuery(uniqueDepositAddresses)]);
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$3(row["degree_in"]) ?? 0;
452
- const degreeOut = numberValue$3(row["degree_out"]) ?? 0;
453
- const totalVolume = numberValue$3(row["total_volume_usd"]) ?? 0;
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$3(row["amount_usd"]),
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$1(seedAddress, network, flows, deposits, sourceMatches, reverseLeads) {
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: "track_funds",
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("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\"", "&quot;").replaceAll("'", "&#39;");
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$1(seedAddress, network, flows, sourceMatches, reverseLeads, aliases, files, continuation) {
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$1(seedAddress.slice(0, 16))}`;
839
- const compact = probeEvidence(seedAddress, network, schemaResult.filePath, aliases, flows, deposits, sourceMatches, reverseLeads);
840
- const graph = buildGraph$1(seedAddress, network, flows, deposits, sourceMatches, reverseLeads);
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-c0iV-XLI.cjs"));
885
+ const { EvidenceStore } = await Promise.resolve().then(() => require("./cases-sTY5aXav.cjs"));
856
886
  await EvidenceStore.append(options.caseId, {
857
- source: "track_funds",
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: "track_funds",
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$1(seedAddress, network, flows, sourceMatches, reverseLeads, aliases, files, continuation),
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
- async function trackFunds(remoteClient, config, options) {
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 trusted = parseAddressList(options.trustedAddresses);
3084
- const untrusted = parseAddressList(options.untrustedAddresses);
2013
+ const victims = parseAddressList(options.victimAddresses);
2014
+ const knownSuspects = parseAddressList(options.knownSuspectAddresses);
3085
2015
  if (!network) throw new Error("network is required");
3086
- if (trusted.length < 1) throw new Error("trusted_addresses must contain at least 1 address");
3087
- if (trusted.length > 5) throw new Error("trusted_addresses cannot exceed 5 addresses");
3088
- if (untrusted.length > 5) throw new Error("untrusted_addresses cannot exceed 5 addresses");
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 trusted) runs.push({
3093
- role: "trusted",
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
- for (const address of untrusted) runs.push({
3105
- role: "untrusted",
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("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\"", "&quot;").replaceAll("'", "&#39;");
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: runs.flatMap((run) => Array.isArray(run.result.graphData.nodes) ? run.result.graphData.nodes : []),
3119
- edges: runs.flatMap((run) => Array.isArray(run.result.graphData.edges) ? run.result.graphData.edges : []),
3120
- flows: runs.flatMap((run) => Array.isArray(run.result.graphData.flows) ? run.result.graphData.flows : []),
3121
- deposits: runs.flatMap((run) => graphArray(run.result.graphData, "deposits").map((item) => ({
3122
- ...item,
3123
- run_role: run.role,
3124
- run_address: run.address
3125
- }))),
3126
- source_matches: runs.flatMap((run) => graphArray(run.result.graphData, "source_matches").map((item) => ({
3127
- ...item,
3128
- run_role: run.role,
3129
- run_address: run.address
3130
- }))),
3131
- reverse_leads: runs.flatMap((run) => graphArray(run.result.graphData, "reverse_leads").map((item) => ({
3132
- ...item,
3133
- run_role: run.role,
3134
- run_address: run.address
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
- trusted_addresses: trusted,
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.result.v1",
3155
- tool: "track_funds",
3156
- facts: {
3157
- network,
3158
- trusted_addresses: trusted,
3159
- untrusted_addresses: untrusted,
3160
- runs: runs.map((run) => ({
3161
- role: run.role,
3162
- address: run.address,
3163
- files: run.result.files,
3164
- continuation: run.result.continuation,
3165
- address_map: run.result.addressMap
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.trackFunds = trackFunds;
2396
+ exports.traceDepositSources = traceDepositSources;
2397
+ exports.traceSuspectFunds = traceSuspectFunds;
2398
+ exports.traceVictimFunds = traceVictimFunds;