chain-insights 0.2.16

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 (153) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +165 -0
  3. package/bin/cli.js +10 -0
  4. package/bin/install.cjs +252 -0
  5. package/bin/mcp-proxy.cjs +10 -0
  6. package/dist/active-BSrxLKwn.mjs +50 -0
  7. package/dist/active-BSrxLKwn.mjs.map +1 -0
  8. package/dist/active-Dv7Tu-O4.cjs +68 -0
  9. package/dist/app-BjjuQM0B.mjs +155 -0
  10. package/dist/app-BjjuQM0B.mjs.map +1 -0
  11. package/dist/app-Dq1TdB6p.cjs +161 -0
  12. package/dist/artifact-server-DoxJ7fCx.cjs +47 -0
  13. package/dist/artifact-server-Dxz5YbuQ.mjs +48 -0
  14. package/dist/artifact-server-Dxz5YbuQ.mjs.map +1 -0
  15. package/dist/assets/bg-pattern.png +0 -0
  16. package/dist/assets/logo.png +0 -0
  17. package/dist/call-args-DQA2QcRA.cjs +27 -0
  18. package/dist/call-args-Lk_wOJxd.mjs +29 -0
  19. package/dist/call-args-Lk_wOJxd.mjs.map +1 -0
  20. package/dist/capabilities-CB97WMA5.cjs +83 -0
  21. package/dist/capabilities-DliMBim-.mjs +84 -0
  22. package/dist/capabilities-DliMBim-.mjs.map +1 -0
  23. package/dist/cases-By7INiOa.mjs +6 -0
  24. package/dist/cases-CDcNU91B.cjs +9 -0
  25. package/dist/chunk-CZWwpsFl.cjs +43 -0
  26. package/dist/cli.cjs +752 -0
  27. package/dist/cli.d.cts +1 -0
  28. package/dist/cli.d.mts +1 -0
  29. package/dist/cli.mjs +753 -0
  30. package/dist/cli.mjs.map +1 -0
  31. package/dist/client-D4Bq0rp9.mjs +111 -0
  32. package/dist/client-D4Bq0rp9.mjs.map +1 -0
  33. package/dist/client-D4fZgIaO.cjs +132 -0
  34. package/dist/config-Bmdl5hdk.cjs +67 -0
  35. package/dist/config-BwrBYmiC.mjs +44 -0
  36. package/dist/config-BwrBYmiC.mjs.map +1 -0
  37. package/dist/data-extractor-BNGj7ECT.cjs +347 -0
  38. package/dist/data-extractor-DFzsa5CS.mjs +336 -0
  39. package/dist/data-extractor-DFzsa5CS.mjs.map +1 -0
  40. package/dist/dossier-BsroDgD3.mjs +76 -0
  41. package/dist/dossier-BsroDgD3.mjs.map +1 -0
  42. package/dist/dossier-DtxREpPm.cjs +76 -0
  43. package/dist/evidence-BGcdKxuV.cjs +200 -0
  44. package/dist/evidence-BhvFW-y_.mjs +195 -0
  45. package/dist/evidence-BhvFW-y_.mjs.map +1 -0
  46. package/dist/format-Ce1RObVl.mjs +22 -0
  47. package/dist/format-Ce1RObVl.mjs.map +1 -0
  48. package/dist/format-DOrPvXEr.cjs +20 -0
  49. package/dist/frontmatter-D8wWCeOa.mjs +26 -0
  50. package/dist/frontmatter-D8wWCeOa.mjs.map +1 -0
  51. package/dist/frontmatter-DgAuai7E.cjs +35 -0
  52. package/dist/graph-normalizer-Cv9yK9Pg.mjs +130 -0
  53. package/dist/graph-normalizer-Cv9yK9Pg.mjs.map +1 -0
  54. package/dist/graph-normalizer-DeIj6Ses.cjs +133 -0
  55. package/dist/graph-reports-C4TBjCkM.mjs +63 -0
  56. package/dist/graph-reports-C4TBjCkM.mjs.map +1 -0
  57. package/dist/graph-reports-DU05YCei.cjs +64 -0
  58. package/dist/html-generator-CAv81IWH.cjs +85 -0
  59. package/dist/html-generator-V6Bp0uRb.mjs +68 -0
  60. package/dist/html-generator-V6Bp0uRb.mjs.map +1 -0
  61. package/dist/index.cjs +31 -0
  62. package/dist/index.d.cts +187 -0
  63. package/dist/index.d.cts.map +1 -0
  64. package/dist/index.d.mts +187 -0
  65. package/dist/index.d.mts.map +1 -0
  66. package/dist/index.mjs +9 -0
  67. package/dist/init-BjuFt54X.cjs +232 -0
  68. package/dist/init-CaOsHTIo.mjs +232 -0
  69. package/dist/init-CaOsHTIo.mjs.map +1 -0
  70. package/dist/mcp-proxy.cjs +1257 -0
  71. package/dist/mcp-proxy.d.cts +12 -0
  72. package/dist/mcp-proxy.d.cts.map +1 -0
  73. package/dist/mcp-proxy.d.mts +12 -0
  74. package/dist/mcp-proxy.d.mts.map +1 -0
  75. package/dist/mcp-proxy.mjs +1255 -0
  76. package/dist/mcp-proxy.mjs.map +1 -0
  77. package/dist/output-root-CFYms3ad.cjs +43 -0
  78. package/dist/output-root-CmWM7aV2.mjs +33 -0
  79. package/dist/output-root-CmWM7aV2.mjs.map +1 -0
  80. package/dist/parser-BUIWW1OH.cjs +182 -0
  81. package/dist/parser-DO0_SssG.mjs +182 -0
  82. package/dist/parser-DO0_SssG.mjs.map +1 -0
  83. package/dist/public-tools-D4UI-Zb0.mjs +2554 -0
  84. package/dist/public-tools-D4UI-Zb0.mjs.map +1 -0
  85. package/dist/public-tools-XSpkz2ky.cjs +2556 -0
  86. package/dist/resolver-C2ZS7oC8.mjs +201 -0
  87. package/dist/resolver-C2ZS7oC8.mjs.map +1 -0
  88. package/dist/resolver-zYbu4wDV.cjs +203 -0
  89. package/dist/rolldown-runtime-wcPFST8Q.mjs +13 -0
  90. package/dist/runner-1Eq55OYb.cjs +148 -0
  91. package/dist/runner-BhUHbiHG.mjs +149 -0
  92. package/dist/runner-BhUHbiHG.mjs.map +1 -0
  93. package/dist/schema-4XpzDFQM.cjs +55 -0
  94. package/dist/schema-8d0rVIdZ.mjs +37 -0
  95. package/dist/schema-8d0rVIdZ.mjs.map +1 -0
  96. package/dist/schema-cache-9CksD7tX.mjs +34 -0
  97. package/dist/schema-cache-9CksD7tX.mjs.map +1 -0
  98. package/dist/schema-cache-CgWRCN2N.cjs +36 -0
  99. package/dist/selector-CkFcTXzz.cjs +10 -0
  100. package/dist/selector-xjm6NTHI.mjs +12 -0
  101. package/dist/selector-xjm6NTHI.mjs.map +1 -0
  102. package/dist/server-BkM5xrXb.mjs +45 -0
  103. package/dist/server-BkM5xrXb.mjs.map +1 -0
  104. package/dist/server-DXowbpfi.cjs +54 -0
  105. package/dist/session-BpNylyuJ.cjs +115 -0
  106. package/dist/session-CcTgYxsj.mjs +115 -0
  107. package/dist/session-CcTgYxsj.mjs.map +1 -0
  108. package/dist/setup-DOpKPrlx.cjs +81 -0
  109. package/dist/setup-DyrWHuwQ.mjs +80 -0
  110. package/dist/setup-DyrWHuwQ.mjs.map +1 -0
  111. package/dist/store-BiUhQOIf.cjs +230 -0
  112. package/dist/store-BoWE-Gtl.mjs +225 -0
  113. package/dist/store-BoWE-Gtl.mjs.map +1 -0
  114. package/dist/templates/graph.html +1406 -0
  115. package/dist/tool-visibility-3Z_KvO9Q.mjs +28 -0
  116. package/dist/tool-visibility-3Z_KvO9Q.mjs.map +1 -0
  117. package/dist/tool-visibility-CwgY205r.cjs +36 -0
  118. package/dist/tools-Cp2jAAAb.mjs +100 -0
  119. package/dist/tools-Cp2jAAAb.mjs.map +1 -0
  120. package/dist/tools-f_vJUZAF.cjs +139 -0
  121. package/dist/topup-server-BZuQifvh.cjs +940 -0
  122. package/dist/topup-server-DUjyFftI.mjs +919 -0
  123. package/dist/topup-server-DUjyFftI.mjs.map +1 -0
  124. package/dist/version-1gP19Lhi.mjs +8 -0
  125. package/dist/version-1gP19Lhi.mjs.map +1 -0
  126. package/dist/version-BNGtdpmH.cjs +18 -0
  127. package/dist/viz-BlCJe6Tk.mjs +35 -0
  128. package/dist/viz-BlCJe6Tk.mjs.map +1 -0
  129. package/dist/viz-ClezVXrJ.cjs +44 -0
  130. package/dist/wallet-BMelXBYP.mjs +104 -0
  131. package/dist/wallet-BMelXBYP.mjs.map +1 -0
  132. package/dist/wallet-RnvvSpV2.cjs +146 -0
  133. package/docs/architecture.md +145 -0
  134. package/docs/contributing.md +68 -0
  135. package/docs/debugging.md +68 -0
  136. package/docs/development.md +44 -0
  137. package/docs/graph-tools.md +251 -0
  138. package/docs/images/graph-mcp-iframe.png +0 -0
  139. package/docs/images/graph-visualization.png +0 -0
  140. package/docs/images/topup-page.png +0 -0
  141. package/docs/investigation-workspaces.md +151 -0
  142. package/docs/mcp-proxy.md +180 -0
  143. package/package.json +59 -0
  144. package/skills/chain-insights-developer-experience/SKILL.md +101 -0
  145. package/skills/chain-insights-investigation/SKILL.md +285 -0
  146. package/skills/chain-insights-investigation/agents/openai.yaml +4 -0
  147. package/skills/chain-insights-investigation/scripts/run-target-uat.sh +197 -0
  148. package/skills/chain-insights-trace-funds/SKILL.md +249 -0
  149. package/skills/ci-case/SKILL.md +43 -0
  150. package/skills/ci-status/SKILL.md +45 -0
  151. package/skills/test-chain-insights-graphrag-mcp/SKILL.md +75 -0
  152. package/skills/test-chain-insights-graphrag-mcp/agents/openai.yaml +4 -0
  153. package/skills/test-chain-insights-graphrag-mcp/scripts/run-uat.sh +414 -0
@@ -0,0 +1,2556 @@
1
+ const require_chunk = require("./chunk-CZWwpsFl.cjs");
2
+ const require_output_root = require("./output-root-CFYms3ad.cjs");
3
+ const require_graph_normalizer = require("./graph-normalizer-DeIj6Ses.cjs");
4
+ let node_path = require("node:path");
5
+ node_path = require_chunk.__toESM(node_path, 1);
6
+ let node_fs_promises = require("node:fs/promises");
7
+ //#region src/investigation/trace-funds.ts
8
+ var AliasTracker = class {
9
+ byAddress = /* @__PURE__ */ new Map();
10
+ byAlias = /* @__PURE__ */ new Map();
11
+ counters = /* @__PURE__ */ new Map();
12
+ assign(address, prefix) {
13
+ const existing = this.byAddress.get(address);
14
+ if (existing) return existing;
15
+ const next = (this.counters.get(prefix) ?? 0) + 1;
16
+ this.counters.set(prefix, next);
17
+ const alias = `${prefix}${next}`;
18
+ this.byAddress.set(address, alias);
19
+ this.byAlias.set(alias, address);
20
+ return alias;
21
+ }
22
+ alias(address) {
23
+ return this.byAddress.get(address);
24
+ }
25
+ addressMap() {
26
+ return Object.fromEntries([...this.byAlias.entries()].sort(([a], [b]) => a.localeCompare(b, void 0, { numeric: true })));
27
+ }
28
+ compactAddressMap(maxIntermediaries = 20, maxSourceExchanges = 20, maxLeads = 20) {
29
+ const counts = /* @__PURE__ */ new Map();
30
+ const entries = [...this.byAlias.entries()].filter(([alias]) => {
31
+ const prefix = alias.slice(0, 1);
32
+ if ([
33
+ "V",
34
+ "D",
35
+ "E"
36
+ ].includes(prefix)) return true;
37
+ const next = (counts.get(prefix) ?? 0) + 1;
38
+ counts.set(prefix, next);
39
+ if (prefix === "I") return next <= maxIntermediaries;
40
+ if (prefix === "X") return next <= maxSourceExchanges;
41
+ if (prefix === "L") return next <= maxLeads;
42
+ return true;
43
+ });
44
+ return Object.fromEntries(entries.sort(([a], [b]) => a.localeCompare(b, void 0, { numeric: true })));
45
+ }
46
+ };
47
+ const GRAPH_QUERY_BATCH_TIMEOUT_SECONDS$1 = 120;
48
+ const GRAPH_QUERY_BATCH_REQUEST_TIMEOUT_MS$1 = 300 * 1e3;
49
+ const SCHEMA_QUERY_SET = [
50
+ {
51
+ id: "node_labels",
52
+ query: "MATCH (n:Address) RETURN \"Address\" AS node_label, count(n) AS sample_count LIMIT 1"
53
+ },
54
+ {
55
+ id: "relationship_types",
56
+ query: "MATCH (:Address)-[r:FLOWS_TO]->(:Address) RETURN \"FLOWS_TO\" AS rel_name, count(r) AS sample_count LIMIT 1"
57
+ },
58
+ {
59
+ id: "address_property_keys",
60
+ query: "MATCH (n:Address) RETURN \"address\" AS property_key, count(n) AS sample_count LIMIT 1"
61
+ },
62
+ {
63
+ id: "flows_to_property_keys",
64
+ query: "MATCH (:Address)-[r:FLOWS_TO]->(:Address) RETURN \"amount_sum\" AS property_key, count(r) AS sample_count LIMIT 1"
65
+ }
66
+ ];
67
+ function clampInt$1(value, fallback, min, max) {
68
+ if (!Number.isFinite(value)) return fallback;
69
+ return Math.max(min, Math.min(max, Math.trunc(value)));
70
+ }
71
+ function escapeCypherString$2(value) {
72
+ return value.replaceAll("\\", "\\\\").replaceAll("\"", "\\\"");
73
+ }
74
+ function sanitizeSegment$1(value) {
75
+ return value.toLowerCase().replace(/[^a-z0-9_-]+/g, "-").replace(/(^-|-$)/g, "").slice(0, 80) || "trace";
76
+ }
77
+ async function ensureDirs(paths) {
78
+ await (0, node_fs_promises.mkdir)(paths.schemaDir, {
79
+ recursive: true,
80
+ mode: 448
81
+ });
82
+ await (0, node_fs_promises.mkdir)(paths.reportsRoot, {
83
+ recursive: true,
84
+ mode: 448
85
+ });
86
+ await (0, node_fs_promises.mkdir)(paths.reportGraphsRoot, {
87
+ recursive: true,
88
+ mode: 448
89
+ });
90
+ await (0, node_fs_promises.mkdir)(paths.reportTablesRoot, {
91
+ recursive: true,
92
+ mode: 448
93
+ });
94
+ await (0, node_fs_promises.mkdir)(paths.logsRoot, {
95
+ recursive: true,
96
+ mode: 448
97
+ });
98
+ }
99
+ function textFromToolResult$2(result) {
100
+ return (result.content ?? []).filter((item) => item.type === "text").map((item) => item.text).join("\n");
101
+ }
102
+ function parseGraphBatchResult$2(result) {
103
+ const text = textFromToolResult$2(result).trim();
104
+ if (!text) throw new Error("graph_query_batch returned no text content");
105
+ const parsed = JSON.parse(text);
106
+ if (!parsed.facts?.queries) throw new Error("graph_query_batch response did not include facts.queries");
107
+ return parsed;
108
+ }
109
+ function topologyGraphQuery$1(query) {
110
+ const trimmed = query.trim();
111
+ if (/^USE\s+/i.test(trimmed)) return trimmed;
112
+ return `USE live_topology ${trimmed}`;
113
+ }
114
+ async function callGraphBatch$2(remoteClient, network, queries) {
115
+ const result = await remoteClient.callTool({
116
+ name: "graph_query_batch",
117
+ arguments: {
118
+ network,
119
+ queries: queries.map((query) => ({
120
+ ...query,
121
+ query: topologyGraphQuery$1(query.query)
122
+ })),
123
+ per_query_timeout_seconds: GRAPH_QUERY_BATCH_TIMEOUT_SECONDS$1
124
+ }
125
+ }, void 0, {
126
+ timeout: GRAPH_QUERY_BATCH_REQUEST_TIMEOUT_MS$1,
127
+ maxTotalTimeout: GRAPH_QUERY_BATCH_REQUEST_TIMEOUT_MS$1
128
+ });
129
+ if (result.isError) throw new Error(textFromToolResult$2(result) || "graph_query_batch failed");
130
+ return parseGraphBatchResult$2(result);
131
+ }
132
+ function resultsFor(batch, id) {
133
+ const query = batch.facts?.queries?.find((entry) => entry.id === id);
134
+ if (!query) return [];
135
+ if (query.ok === false) throw new Error(query.error || `Query failed: ${id}`);
136
+ return query.results ?? [];
137
+ }
138
+ function schemaFromGraphBatch(network, batch) {
139
+ return {
140
+ schema: "chain-insights.runtime_graph_schema.v1",
141
+ network,
142
+ source: "graph_query_batch",
143
+ node_labels: resultsFor(batch, "node_labels"),
144
+ relationship_types: resultsFor(batch, "relationship_types"),
145
+ address_property_keys: resultsFor(batch, "address_property_keys").map((row) => row["property_key"]),
146
+ flows_to_property_keys: resultsFor(batch, "flows_to_property_keys").map((row) => row["property_key"]),
147
+ recommended_flow_projection: [
148
+ "src.address AS src",
149
+ "dst.address AS dst",
150
+ "r.amount_sum AS amount_sum",
151
+ "r.amount_usd_sum AS amount_usd_sum",
152
+ "r.tx_count AS tx_count",
153
+ "r.first_tx_id AS first_tx_id",
154
+ "r.last_tx_id AS last_tx_id",
155
+ "dst.labels AS dst_labels",
156
+ "dst.lifetime_degree_in AS dst_degree_in",
157
+ "dst.lifetime_degree_out AS dst_degree_out"
158
+ ]
159
+ };
160
+ }
161
+ async function loadOrCaptureTopologySchema(remoteClient, paths, network) {
162
+ const filePath = node_path.default.join(paths.schemaDir, `${sanitizeSegment$1(network)}.graph-schema.json`);
163
+ try {
164
+ return {
165
+ schema: JSON.parse(await (0, node_fs_promises.readFile)(filePath, "utf8")),
166
+ filePath
167
+ };
168
+ } catch (err) {
169
+ if (err.code !== "ENOENT") throw err;
170
+ }
171
+ const schema = schemaFromGraphBatch(network, await callGraphBatch$2(remoteClient, network, SCHEMA_QUERY_SET));
172
+ await (0, node_fs_promises.writeFile)(filePath, JSON.stringify(schema, null, 2) + "\n", { mode: 384 });
173
+ return {
174
+ schema,
175
+ filePath
176
+ };
177
+ }
178
+ function flowEdgeMap$1(variableName) {
179
+ 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
+ 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
+ }
184
+ function forwardExchangeQueries(address, limit, minAmountSum, maxHops) {
185
+ return Array.from({ length: maxHops }, (_, index) => forwardExchangeQueryAtDepth(address, limit, minAmountSum, index + 1));
186
+ }
187
+ function forwardExchangeQueryAtDepth(address, limit, minAmountSum, depth) {
188
+ const intermediateVariables = Array.from({ length: Math.max(depth - 1, 0) }, (_, index) => `n${index + 1}`);
189
+ const nodeVariables = [
190
+ "s",
191
+ ...intermediateVariables,
192
+ "t"
193
+ ];
194
+ const edgeVariables = Array.from({ length: depth }, (_, index) => `r${index + 1}`);
195
+ const relationshipChain = edgeVariables.map((edgeVariable, index) => {
196
+ return `-[${edgeVariable}:FLOWS_TO]->(${index === edgeVariables.length - 1 ? "t" : intermediateVariables[index]}:Address)`;
197
+ }).join("");
198
+ const amountPredicates = edgeVariables.map((edgeVariable) => `${edgeVariable}.amount_sum IS NOT NULL${minAmountSum > 0 ? ` AND ${edgeVariable}.amount_sum >= ${minAmountSum}` : ""}`);
199
+ const predicates = [
200
+ "s <> t",
201
+ "t.is_exchange IS NOT NULL",
202
+ ...intermediateVariables.map((nodeVariable) => `${nodeVariable}.is_exchange IS NULL`),
203
+ ...amountPredicates
204
+ ];
205
+ const depositVariable = nodeVariables[nodeVariables.length - 2];
206
+ return {
207
+ id: `forward_exchange_paths_${depth}`,
208
+ query: [
209
+ `MATCH (s:Address {address: "${escapeCypherString$2(address)}"})${relationshipChain}`,
210
+ `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
+ "ORDER BY hops ASC",
213
+ `LIMIT ${limit}`
214
+ ].join(" ")
215
+ };
216
+ }
217
+ function backwardSourceQueries(idPrefix, depositAddress, maxHops) {
218
+ return Array.from({ length: maxHops }, (_, index) => backwardSourceQueryAtDepth(`${idPrefix}_${index + 1}`, depositAddress, index + 1));
219
+ }
220
+ function backwardSourceQueryAtDepth(id, depositAddress, depth) {
221
+ const intermediateVariables = Array.from({ length: Math.max(depth - 1, 0) }, (_, index) => `n${index + 1}`);
222
+ const nodeVariables = [
223
+ "dep",
224
+ ...intermediateVariables,
225
+ "source"
226
+ ];
227
+ const edgeVariables = Array.from({ length: depth }, (_, index) => `r${index + 1}`);
228
+ const relationshipChain = edgeVariables.map((edgeVariable, index) => {
229
+ return `<-[${edgeVariable}:FLOWS_TO]-(${index === edgeVariables.length - 1 ? "source" : intermediateVariables[index]}:Address)`;
230
+ }).join("");
231
+ const intermediatePredicates = intermediateVariables.map((nodeVariable) => `${nodeVariable}.is_exchange IS NULL`);
232
+ return {
233
+ id,
234
+ query: [
235
+ `MATCH (dep:Address {address: "${escapeCypherString$2(depositAddress)}"})`,
236
+ `MATCH (dep)${relationshipChain}`,
237
+ `WHERE source <> dep AND source.is_exchange IS NOT NULL${intermediatePredicates.length > 0 ? ` AND ${intermediatePredicates.join(" AND ")}` : ""}`,
238
+ `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`,
239
+ "LIMIT 20"
240
+ ].join(" ")
241
+ };
242
+ }
243
+ function reverseLeadsQuery(depositAddresses) {
244
+ return {
245
+ id: "reverse_1hop",
246
+ query: [
247
+ "MATCH (sender:Address)-[r:FLOWS_TO]->(deposit:Address)",
248
+ `WHERE (${depositAddresses.map((address) => `deposit.address = "${escapeCypherString$2(address)}"`).join(" OR ")}) AND sender.is_exchange IS NULL AND sender.address <> deposit.address`,
249
+ "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
+ "ORDER BY r.amount_usd_sum DESC",
251
+ `LIMIT ${Math.max(50, depositAddresses.length * 50)}`
252
+ ].join(" ")
253
+ };
254
+ }
255
+ function edgeKey$1(src, dst) {
256
+ return `${src}\u0000${dst}`;
257
+ }
258
+ function directEdgePropsQuery(flows) {
259
+ const pairs = [...new Map(flows.map((flow) => [edgeKey$1(flow.src, flow.dst), {
260
+ src: flow.src,
261
+ dst: flow.dst
262
+ }])).values()];
263
+ if (pairs.length === 0) return null;
264
+ return {
265
+ id: "direct_edge_props",
266
+ query: [
267
+ "MATCH (a:Address)-[r:FLOWS_TO]->(b:Address)",
268
+ `WHERE (${pairs.map((pair) => `(a.address = "${escapeCypherString$2(pair.src)}" AND b.address = "${escapeCypherString$2(pair.dst)}")`).join(" OR ")})`,
269
+ "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
+ `LIMIT ${pairs.length}`
271
+ ].join(" ")
272
+ };
273
+ }
274
+ function numberValue$2(value) {
275
+ if (typeof value === "number" && Number.isFinite(value)) return value;
276
+ if (typeof value === "string" && value.trim()) {
277
+ const parsed = Number(value);
278
+ return Number.isFinite(parsed) ? parsed : void 0;
279
+ }
280
+ }
281
+ function rowTerminalAmount(row) {
282
+ const edgeProps = Array.isArray(row["edge_props"]) ? row["edge_props"] : [];
283
+ const terminalEdge = edgeProps[edgeProps.length - 1];
284
+ if (!terminalEdge) return void 0;
285
+ return numberValue$2(terminalEdge["amount_sum"]) ?? numberValue$2(terminalEdge["amount_usd_sum"]);
286
+ }
287
+ function rowsMatchingMinimumAmount(rows, minAmountSum) {
288
+ if (minAmountSum <= 0) return rows;
289
+ return rows.filter((row) => (rowTerminalAmount(row) ?? 0) >= minAmountSum);
290
+ }
291
+ function stringArrayValue$1(value) {
292
+ if (Array.isArray(value)) return value.map(String);
293
+ if (typeof value === "string" && value.trim()) return [value];
294
+ }
295
+ function uniqueStrings(values) {
296
+ return [...new Set(values ?? [])];
297
+ }
298
+ function nodeMetadataFromValue(value, fallbackAddress) {
299
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return fallbackAddress ? { address: fallbackAddress } : void 0;
300
+ const record = value;
301
+ const address = typeof record["address"] === "string" ? record["address"] : fallbackAddress;
302
+ if (!address) return void 0;
303
+ return {
304
+ address,
305
+ labels: stringArrayValue$1(record["labels"]),
306
+ system_labels: stringArrayValue$1(record["system_labels"]),
307
+ address_type: typeof record["address_type"] === "string" ? record["address_type"] : void 0,
308
+ address_subtypes: stringArrayValue$1(record["address_subtypes"])
309
+ };
310
+ }
311
+ function isExchangeFlow(flow) {
312
+ return flow.terminal_exchange || flow.dst_labels?.includes("Exchange") === true || flow.dst_node?.system_labels?.includes("Exchange") === true;
313
+ }
314
+ function depositFromRow(row) {
315
+ const pathAddresses = stringArrayValue$1(row["addresses"]) ?? [];
316
+ if (pathAddresses.length < 2) return null;
317
+ const exchangeAddress = typeof row["exchange_address"] === "string" ? row["exchange_address"] : pathAddresses[pathAddresses.length - 1];
318
+ const edgeProps = Array.isArray(row["edge_props"]) ? row["edge_props"] : [];
319
+ const terminalEdge = edgeProps[edgeProps.length - 1] ?? {};
320
+ const pathNodes = Array.isArray(row["path_nodes"]) ? row["path_nodes"].map((node, index) => nodeMetadataFromValue(node, pathAddresses[index])).filter((node) => Boolean(node)) : void 0;
321
+ const exchangeNode = {
322
+ address: exchangeAddress,
323
+ labels: stringArrayValue$1(row["exchange_display_labels"]),
324
+ system_labels: stringArrayValue$1(row["exchange_system_labels"]) ?? stringArrayValue$1(row["exchange_labels"]),
325
+ address_type: typeof row["exchange_address_type"] === "string" ? row["exchange_address_type"] : void 0,
326
+ address_subtypes: stringArrayValue$1(row["exchange_address_subtypes"])
327
+ };
328
+ return {
329
+ address: pathAddresses[pathAddresses.length - 2],
330
+ exchangeAddress,
331
+ exchangeLabels: stringArrayValue$1(row["exchange_labels"]),
332
+ exchangeNode,
333
+ amount_sum: numberValue$2(terminalEdge["amount_sum"]),
334
+ amount_usd_sum: numberValue$2(terminalEdge["amount_usd_sum"]),
335
+ hops: numberValue$2(row["hops"]) ?? pathAddresses.length - 1,
336
+ path: pathAddresses,
337
+ pathNodes
338
+ };
339
+ }
340
+ function flowsFromForwardRows(rows) {
341
+ const flows = [];
342
+ const deposits = [];
343
+ const seenEdges = /* @__PURE__ */ new Set();
344
+ for (const row of rows) {
345
+ const pathAddresses = stringArrayValue$1(row["addresses"]) ?? [];
346
+ const nodeLabels = Array.isArray(row["node_labels"]) ? row["node_labels"].map((labels) => stringArrayValue$1(labels) ?? []) : [];
347
+ const pathNodes = Array.isArray(row["path_nodes"]) ? row["path_nodes"].map((node, index) => nodeMetadataFromValue(node, pathAddresses[index])) : [];
348
+ const edgeProps = Array.isArray(row["edge_props"]) ? row["edge_props"] : [];
349
+ const deposit = depositFromRow(row);
350
+ if (deposit) deposits.push(deposit);
351
+ for (let index = 0; index < pathAddresses.length - 1; index += 1) {
352
+ const src = pathAddresses[index];
353
+ const dst = pathAddresses[index + 1];
354
+ const edge = edgeProps[index] ?? {};
355
+ const amount = numberValue$2(edge["amount_sum"]) ?? numberValue$2(edge["amount_usd_sum"]) ?? 0;
356
+ const terminal = index === pathAddresses.length - 2;
357
+ const key = `${src}->${dst}`;
358
+ if (seenEdges.has(key)) continue;
359
+ seenEdges.add(key);
360
+ flows.push({
361
+ hop: index + 1,
362
+ src,
363
+ dst,
364
+ amount_sum: amount,
365
+ amount_usd_sum: numberValue$2(edge["amount_usd_sum"]),
366
+ tx_count: numberValue$2(edge["tx_count"]),
367
+ first_tx_id: typeof edge["first_tx_id"] === "string" ? edge["first_tx_id"] : void 0,
368
+ last_tx_id: typeof edge["last_tx_id"] === "string" ? edge["last_tx_id"] : void 0,
369
+ src_labels: nodeLabels[index],
370
+ dst_labels: nodeLabels[index + 1],
371
+ src_node: pathNodes[index],
372
+ dst_node: pathNodes[index + 1],
373
+ terminal_exchange: terminal
374
+ });
375
+ }
376
+ }
377
+ return {
378
+ flows,
379
+ deposits
380
+ };
381
+ }
382
+ async function hydrateDirectEdgeProps(remoteClient, network, flows, deposits) {
383
+ const query = directEdgePropsQuery(flows);
384
+ if (!query) return;
385
+ const batch = await callGraphBatch$2(remoteClient, network, [query]);
386
+ const edgeProps = /* @__PURE__ */ new Map();
387
+ for (const row of resultsFor(batch, "direct_edge_props")) {
388
+ const src = typeof row["src"] === "string" ? row["src"] : "";
389
+ const dst = typeof row["dst"] === "string" ? row["dst"] : "";
390
+ if (!src || !dst) continue;
391
+ edgeProps.set(edgeKey$1(src, dst), row);
392
+ }
393
+ for (const flow of flows) {
394
+ const props = edgeProps.get(edgeKey$1(flow.src, flow.dst));
395
+ if (!props) continue;
396
+ flow.amount_sum = numberValue$2(props["amount_sum"]) ?? flow.amount_sum;
397
+ flow.amount_usd_sum = numberValue$2(props["amount_usd_sum"]);
398
+ flow.tx_count = numberValue$2(props["tx_count"]);
399
+ flow.first_tx_id = typeof props["first_tx_id"] === "string" ? props["first_tx_id"] : void 0;
400
+ flow.last_tx_id = typeof props["last_tx_id"] === "string" ? props["last_tx_id"] : void 0;
401
+ }
402
+ for (const deposit of deposits) {
403
+ const props = edgeProps.get(edgeKey$1(deposit.address, deposit.exchangeAddress));
404
+ if (!props) continue;
405
+ deposit.amount_sum = numberValue$2(props["amount_sum"]);
406
+ deposit.amount_usd_sum = numberValue$2(props["amount_usd_sum"]);
407
+ }
408
+ }
409
+ async function collectProbeTrace(remoteClient, options) {
410
+ 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
+ if (query.ok === false) throw new Error(query.error || `Query failed: ${query.id}`);
412
+ return query.results ?? [];
413
+ }), options.minAmountSum));
414
+ await hydrateDirectEdgeProps(remoteClient, options.network, flows, deposits);
415
+ const uniqueDepositAddresses = [...new Set(deposits.map((deposit) => deposit.address))];
416
+ const sourceMatches = [];
417
+ if (uniqueDepositAddresses.length > 0) {
418
+ 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
+ for (const query of backwardBatch.facts?.queries ?? []) for (const row of query.results ?? []) {
420
+ const pathAddresses = stringArrayValue$1(row["addresses"]) ?? [];
421
+ const pathNodes = Array.isArray(row["path_nodes"]) ? row["path_nodes"].map((node, index) => nodeMetadataFromValue(node, pathAddresses[index])).filter((node) => Boolean(node)) : void 0;
422
+ const depositAddress = typeof row["deposit_address"] === "string" ? row["deposit_address"] : pathAddresses[0];
423
+ const sourceExchange = typeof row["source_exchange"] === "string" ? row["source_exchange"] : pathAddresses[pathAddresses.length - 1];
424
+ if (!depositAddress || !sourceExchange) continue;
425
+ const sourceNode = {
426
+ address: sourceExchange,
427
+ labels: stringArrayValue$1(row["source_display_labels"]),
428
+ system_labels: stringArrayValue$1(row["source_system_labels"]) ?? stringArrayValue$1(row["source_labels"]),
429
+ address_type: typeof row["source_address_type"] === "string" ? row["source_address_type"] : void 0,
430
+ address_subtypes: stringArrayValue$1(row["source_address_subtypes"])
431
+ };
432
+ sourceMatches.push({
433
+ deposit_address: depositAddress,
434
+ source_exchange: sourceExchange,
435
+ source_labels: stringArrayValue$1(row["source_labels"]),
436
+ sourceNode,
437
+ hops: numberValue$2(row["hops"]) ?? Math.max(pathAddresses.length - 1, 0),
438
+ path: pathAddresses,
439
+ pathNodes
440
+ });
441
+ }
442
+ }
443
+ const reverseLeads = [];
444
+ if (uniqueDepositAddresses.length > 0) {
445
+ const reverseBatch = await callGraphBatch$2(remoteClient, options.network, [reverseLeadsQuery(uniqueDepositAddresses)]);
446
+ for (const row of resultsFor(reverseBatch, "reverse_1hop")) {
447
+ const address = typeof row["address"] === "string" ? row["address"] : "";
448
+ const depositAddress = typeof row["deposit_address"] === "string" ? row["deposit_address"] : "";
449
+ if (!address || !depositAddress) continue;
450
+ const labels = stringArrayValue$1(row["display_labels"]) ?? stringArrayValue$1(row["labels"]) ?? [];
451
+ const degreeIn = numberValue$2(row["degree_in"]) ?? 0;
452
+ const degreeOut = numberValue$2(row["degree_out"]) ?? 0;
453
+ const totalVolume = numberValue$2(row["total_volume_usd"]) ?? 0;
454
+ const reason = labels.length > 0 ? "labeled_entity" : degreeIn > 50 ? "fan_in_hub" : degreeOut > 50 ? "fan_out_hub" : totalVolume > 1e5 ? "high_volume_sender" : "";
455
+ if (!reason) continue;
456
+ reverseLeads.push({
457
+ address,
458
+ labels,
459
+ node: {
460
+ address,
461
+ labels,
462
+ system_labels: stringArrayValue$1(row["system_labels"]),
463
+ address_type: typeof row["address_type"] === "string" ? row["address_type"] : void 0,
464
+ address_subtypes: stringArrayValue$1(row["address_subtypes"])
465
+ },
466
+ degree_in: degreeIn,
467
+ degree_out: degreeOut,
468
+ total_volume_usd: totalVolume,
469
+ deposit_address: depositAddress,
470
+ amount_usd: numberValue$2(row["amount_usd"]),
471
+ reason
472
+ });
473
+ }
474
+ }
475
+ return {
476
+ flows,
477
+ deposits,
478
+ sourceMatches,
479
+ reverseLeads
480
+ };
481
+ }
482
+ function buildAliases(seedAddress, deposits, sourceMatches, reverseLeads) {
483
+ const aliases = new AliasTracker();
484
+ aliases.assign(seedAddress, "V");
485
+ for (const deposit of deposits) {
486
+ for (const address of deposit.path.slice(1, -2)) aliases.assign(address, "I");
487
+ aliases.assign(deposit.address, "D");
488
+ aliases.assign(deposit.exchangeAddress, "E");
489
+ }
490
+ for (const source of sourceMatches) {
491
+ aliases.assign(source.source_exchange, "X");
492
+ for (const address of source.path.slice(1, -1)) aliases.assign(address, "I");
493
+ }
494
+ for (const lead of reverseLeads) aliases.assign(lead.address, "L");
495
+ return aliases;
496
+ }
497
+ function buildGraph$1(seedAddress, network, flows, deposits, sourceMatches, reverseLeads) {
498
+ const totals = /* @__PURE__ */ new Map();
499
+ const ensure = (address) => {
500
+ if (!totals.has(address)) totals.set(address, {
501
+ in: 0,
502
+ out: 0,
503
+ labels: [],
504
+ systemLabels: [],
505
+ addressSubtypes: [],
506
+ roles: new Set(address === seedAddress ? ["seed"] : [])
507
+ });
508
+ return totals.get(address);
509
+ };
510
+ const mergeNode = (address, metadata, role, systemLabelsFallback) => {
511
+ const node = ensure(address);
512
+ node.labels = uniqueStrings([...node.labels, ...metadata?.labels ?? []]);
513
+ node.systemLabels = uniqueStrings([
514
+ ...node.systemLabels,
515
+ ...metadata?.system_labels ?? [],
516
+ ...systemLabelsFallback ?? []
517
+ ]);
518
+ if (metadata?.address_type) node.addressType = metadata.address_type;
519
+ node.addressSubtypes = uniqueStrings([...node.addressSubtypes, ...metadata?.address_subtypes ?? []]);
520
+ if (role) node.roles.add(role);
521
+ return node;
522
+ };
523
+ for (const flow of flows) {
524
+ const src = mergeNode(flow.src, flow.src_node, void 0, flow.src_labels);
525
+ src.out += flow.amount_usd_sum ?? flow.amount_sum;
526
+ const dst = mergeNode(flow.dst, flow.dst_node, void 0, flow.dst_labels);
527
+ dst.in += flow.amount_usd_sum ?? flow.amount_sum;
528
+ if (isExchangeFlow(flow)) dst.roles.add("exchange");
529
+ }
530
+ for (const deposit of deposits) {
531
+ for (const node of deposit.pathNodes ?? []) mergeNode(node.address, node);
532
+ mergeNode(deposit.address, deposit.pathNodes?.find((node) => node.address === deposit.address), "deposit_candidate");
533
+ mergeNode(deposit.exchangeAddress, deposit.exchangeNode, "exchange", deposit.exchangeLabels);
534
+ }
535
+ for (const source of sourceMatches) {
536
+ for (const node of source.pathNodes ?? []) mergeNode(node.address, node);
537
+ mergeNode(source.source_exchange, source.sourceNode, "exchange", source.source_labels);
538
+ }
539
+ for (const lead of reverseLeads) {
540
+ mergeNode(lead.address, lead.node ?? {
541
+ address: lead.address,
542
+ labels: lead.labels
543
+ }, "lead");
544
+ const deposit = ensure(lead.deposit_address);
545
+ deposit.in += lead.amount_usd ?? 0;
546
+ }
547
+ const sourceMatchEdges = sourceMatches.flatMap((source) => {
548
+ const path = source.path.length >= 2 ? source.path : [source.deposit_address, source.source_exchange];
549
+ const edges = [];
550
+ for (let index = path.length - 1; index > 0; index -= 1) edges.push({
551
+ source: path[index],
552
+ target: path[index - 1],
553
+ edge_type: "flows_to",
554
+ usd_amount: 0,
555
+ amount_sum: 0,
556
+ tx_count: 0,
557
+ direction: "traceback"
558
+ });
559
+ return edges;
560
+ });
561
+ return require_graph_normalizer.normalizeGraphPayload({
562
+ schema: "chain-insights.graph.v1",
563
+ nodes: [...totals.entries()].map(([address, data]) => ({
564
+ id: address,
565
+ address,
566
+ node_type: "address",
567
+ labels: uniqueStrings(data.labels),
568
+ ...data.systemLabels.length > 0 ? { system_labels: uniqueStrings(data.systemLabels) } : {},
569
+ ...data.addressType ? { address_type: data.addressType } : {},
570
+ ...data.addressSubtypes.length > 0 ? { address_subtypes: uniqueStrings(data.addressSubtypes) } : {},
571
+ ...data.roles.size > 0 ? { roles: [...data.roles] } : {},
572
+ flow_in_usd: data.in,
573
+ flow_out_usd: data.out
574
+ })),
575
+ edges: [
576
+ ...flows.map((flow) => ({
577
+ source: flow.src,
578
+ target: flow.dst,
579
+ edge_type: "flows_to",
580
+ usd_amount: flow.amount_usd_sum ?? flow.amount_sum,
581
+ amount_sum: flow.amount_sum,
582
+ tx_count: flow.tx_count ?? 0,
583
+ first_tx_id: flow.first_tx_id,
584
+ last_tx_id: flow.last_tx_id,
585
+ terminal_exchange: flow.terminal_exchange
586
+ })),
587
+ ...sourceMatchEdges,
588
+ ...reverseLeads.map((lead) => ({
589
+ source: lead.address,
590
+ target: lead.deposit_address,
591
+ edge_type: "flows_to",
592
+ usd_amount: lead.amount_usd ?? 0,
593
+ amount_sum: lead.amount_usd ?? 0,
594
+ tx_count: 0,
595
+ direction: "reverse_1hop_lead"
596
+ }))
597
+ ],
598
+ flows,
599
+ deposits,
600
+ source_matches: sourceMatches,
601
+ reverse_leads: reverseLeads,
602
+ edge_anchors: [],
603
+ metadata: {
604
+ seed_address: seedAddress,
605
+ network,
606
+ generated_at: (/* @__PURE__ */ new Date()).toISOString()
607
+ }
608
+ });
609
+ }
610
+ function buildMarkdownReport(seedAddress, network, flows, deposits, sourceMatches, reverseLeads, aliases, graphPath, schemaPath) {
611
+ return [
612
+ `# Trace Funds: ${seedAddress}`,
613
+ "",
614
+ `Network: \`${network}\``,
615
+ `Schema: \`${schemaPath}\``,
616
+ `Graph: \`${graphPath}\``,
617
+ "",
618
+ "## Probe Summary",
619
+ "",
620
+ `- Exchange endpoint(s): ${[...new Set(deposits.map((deposit) => aliases.alias(deposit.exchangeAddress) ?? deposit.exchangeAddress))].join(", ") || "none"}`,
621
+ `- Deposit candidate(s): ${[...new Set(deposits.map((deposit) => aliases.alias(deposit.address) ?? deposit.address))].join(", ") || "none"}`,
622
+ `- Traceback source exchange path(s): ${sourceMatches.length}`,
623
+ `- Reverse 1-hop lead(s): ${reverseLeads.length}`,
624
+ "",
625
+ "## Flow Table",
626
+ "",
627
+ "| Hop | Source | Destination | amount_sum | amount_usd_sum | tx_count | first_tx_id | terminal_exchange |",
628
+ "|---:|---|---|---:|---:|---:|---|---|",
629
+ ...flows.map((flow) => [
630
+ `| ${flow.hop}`,
631
+ `\`${flow.src}\``,
632
+ `\`${flow.dst}\``,
633
+ flow.amount_sum,
634
+ flow.amount_usd_sum ?? "",
635
+ flow.tx_count ?? "",
636
+ flow.first_tx_id ? `\`${flow.first_tx_id}\`` : "",
637
+ flow.terminal_exchange ? "yes" : "no"
638
+ ].join(" | ") + " |"),
639
+ "",
640
+ "## Mermaid",
641
+ "",
642
+ "```mermaid",
643
+ "flowchart LR",
644
+ ...flows.map((flow, index) => ` n${index}["${flow.src.slice(0, 8)}..."] -->|"amount_sum ${flow.amount_sum}${flow.terminal_exchange ? "; exchange endpoint" : ""}"| m${index}["${flow.dst.slice(0, 8)}..."]`),
645
+ "```"
646
+ ].join("\n") + "\n";
647
+ }
648
+ function probeEvidence(seedAddress, network, schemaPath, aliases, flows, deposits, sourceMatches, reverseLeads) {
649
+ return {
650
+ schema: "chain-insights.probe_evidence.v1",
651
+ source: "track_funds",
652
+ network,
653
+ seed_address: seedAddress,
654
+ schema_ref: schemaPath,
655
+ address_map: aliases.addressMap(),
656
+ fund_flows: [...deposits.map((deposit, index) => ({
657
+ id: `F${index + 1}`,
658
+ type: "deposit",
659
+ path: deposit.path.map((address) => aliases.alias(address) ?? address),
660
+ deposit: aliases.alias(deposit.address),
661
+ exchange: aliases.alias(deposit.exchangeAddress),
662
+ amount_sum: deposit.amount_sum,
663
+ amount_usd_sum: deposit.amount_usd_sum,
664
+ hops: deposit.hops
665
+ })), ...sourceMatches.map((source, index) => ({
666
+ id: `S${index + 1}`,
667
+ type: "source",
668
+ path: [...source.path].reverse().map((address) => aliases.alias(address) ?? address),
669
+ source_exchange: aliases.alias(source.source_exchange),
670
+ deposit: aliases.alias(source.deposit_address),
671
+ hops: source.hops
672
+ }))],
673
+ reverse_leads: reverseLeads.map((lead) => ({
674
+ alias: aliases.alias(lead.address),
675
+ address: lead.address,
676
+ reason: lead.reason,
677
+ labels: lead.labels,
678
+ deposit: aliases.alias(lead.deposit_address),
679
+ amount_usd: lead.amount_usd
680
+ })),
681
+ outgoing_flows: flows.map((flow) => ({
682
+ hop: flow.hop,
683
+ src: aliases.alias(flow.src) ?? flow.src,
684
+ dst: aliases.alias(flow.dst) ?? flow.dst,
685
+ amount_sum: flow.amount_sum,
686
+ amount_usd_sum: flow.amount_usd_sum,
687
+ tx_count: flow.tx_count,
688
+ first_tx_id: flow.first_tx_id,
689
+ last_tx_id: flow.last_tx_id,
690
+ terminal_exchange: flow.terminal_exchange
691
+ }))
692
+ };
693
+ }
694
+ function tableCsv(flows) {
695
+ const rows = ["hop,src,dst,amount_sum,amount_usd_sum,tx_count,first_tx_id,last_tx_id,terminal_exchange"];
696
+ for (const flow of flows) rows.push([
697
+ flow.hop,
698
+ flow.src,
699
+ flow.dst,
700
+ flow.amount_sum,
701
+ flow.amount_usd_sum ?? "",
702
+ flow.tx_count ?? "",
703
+ flow.first_tx_id ?? "",
704
+ flow.last_tx_id ?? "",
705
+ flow.terminal_exchange ? "true" : "false"
706
+ ].map((value) => JSON.stringify(String(value))).join(","));
707
+ return rows.join("\n") + "\n";
708
+ }
709
+ function htmlEscape(value) {
710
+ return String(value ?? "").replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\"", "&quot;").replaceAll("'", "&#39;");
711
+ }
712
+ function buildTableHtml(seedAddress, network, flows, deposits, sourceMatches, reverseLeads) {
713
+ const headers = [
714
+ "hop",
715
+ "src",
716
+ "dst",
717
+ "amount_sum",
718
+ "amount_usd_sum",
719
+ "tx_count",
720
+ "first_tx_id",
721
+ "last_tx_id",
722
+ "terminal_exchange_display"
723
+ ];
724
+ const headerLabels = {
725
+ hop: "Hop",
726
+ src: "Source",
727
+ dst: "Destination",
728
+ amount_sum: "amount_sum",
729
+ amount_usd_sum: "amount_usd_sum",
730
+ tx_count: "tx_count",
731
+ first_tx_id: "first_tx_id",
732
+ last_tx_id: "last_tx_id",
733
+ terminal_exchange_display: "terminal_exchange"
734
+ };
735
+ const rows = flows.map((flow) => {
736
+ const values = {
737
+ ...flow,
738
+ terminal_exchange_display: flow.terminal_exchange ? "yes" : "no"
739
+ };
740
+ return `<tr>${headers.map((header) => `<td>${htmlEscape(values[header])}</td>`).join("")}</tr>`;
741
+ }).join("\n");
742
+ return `<!doctype html>
743
+ <html lang="en">
744
+ <head>
745
+ <meta charset="utf-8">
746
+ <meta name="viewport" content="width=device-width, initial-scale=1">
747
+ <title>Trace Funds Table - ${htmlEscape(seedAddress)}</title>
748
+ <style>
749
+ :root { color-scheme: dark; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #0b0d12; color: #f4f2ea; }
750
+ body { margin: 0; background: #0b0d12; color: #f4f2ea; }
751
+ main { padding: 24px; }
752
+ h1 { font-size: 20px; margin: 0 0 8px; font-weight: 650; }
753
+ .meta { display: grid; gap: 6px; margin: 0 0 20px; color: rgba(244,242,234,.72); font-size: 13px; }
754
+ .summary { display: flex; flex-wrap: wrap; gap: 8px; margin: 0 0 20px; }
755
+ .pill { border: 1px solid rgba(242,221,166,.25); background: rgba(242,221,166,.08); border-radius: 999px; padding: 6px 10px; font-size: 12px; color: #f2dda6; }
756
+ .table-wrap { overflow: auto; border: 1px solid rgba(255,255,255,.1); border-radius: 8px; background: #10131b; }
757
+ table { border-collapse: collapse; width: 100%; min-width: 1180px; font-size: 12px; }
758
+ th, td { border-bottom: 1px solid rgba(255,255,255,.08); padding: 8px 10px; text-align: left; vertical-align: top; }
759
+ th { position: sticky; top: 0; background: #161a24; color: #f2dda6; font-weight: 600; z-index: 1; }
760
+ td { color: rgba(244,242,234,.86); font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
761
+ tr:hover td { background: rgba(242,221,166,.045); }
762
+ </style>
763
+ </head>
764
+ <body>
765
+ <main>
766
+ <h1>Trace Funds Table</h1>
767
+ <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>
771
+ </div>
772
+ <div class="summary">
773
+ <span class="pill">${flows.length} FLOWS_TO edges</span>
774
+ <span class="pill">${deposits.length} deposit candidates</span>
775
+ <span class="pill">${sourceMatches.length} traceback source paths</span>
776
+ <span class="pill">${reverseLeads.length} reverse 1-hop leads</span>
777
+ </div>
778
+ <div class="table-wrap">
779
+ <table>
780
+ <thead><tr>${headers.map((header) => `<th>${htmlEscape(headerLabels[header])}</th>`).join("")}</tr></thead>
781
+ <tbody>
782
+ ${rows}
783
+ </tbody>
784
+ </table>
785
+ </div>
786
+ </main>
787
+ </body>
788
+ </html>
789
+ `;
790
+ }
791
+ function summarize$1(seedAddress, network, flows, sourceMatches, reverseLeads, aliases, files, continuation) {
792
+ const totalAmount = flows.reduce((sum, flow) => sum + flow.amount_sum, 0);
793
+ const byHop = /* @__PURE__ */ new Map();
794
+ for (const flow of flows) byHop.set(flow.hop, (byHop.get(flow.hop) ?? 0) + 1);
795
+ const depositCount = continuation.depositAddresses.length;
796
+ const exchangeCount = continuation.exchangeAddresses.length;
797
+ return [
798
+ `Trace complete for ${network}:${seedAddress}`,
799
+ "",
800
+ `Facts: ${flows.length} FLOWS_TO edge(s), sum of traced edge amount_sum values ${Number(totalAmount.toFixed(8))}.`,
801
+ `By hop: ${[...byHop.entries()].map(([hop, count]) => `hop ${hop}: ${count}`).join(", ") || "none"}.`,
802
+ `Exchange endpoints reached: ${exchangeCount}. Deposit candidate address(es): ${depositCount}.`,
803
+ `Traceback source path(s): ${sourceMatches.length}. Reverse 1-hop lead(s): ${reverseLeads.length}.`,
804
+ "",
805
+ "Files written:",
806
+ `- schema: ${files.schema}`,
807
+ `- compact evidence JSON: ${files.compactEvidence}`,
808
+ `- graph JSON: ${files.graph}`,
809
+ `- graph HTML: ${files.graphHtml}`,
810
+ `- table CSV: ${files.table}`,
811
+ `- table HTML: ${files.tableHtml}`,
812
+ `- report: ${files.report}`,
813
+ "",
814
+ `Continuation hint: ${continuation.hint}`,
815
+ continuation.depositAddresses.length > 0 ? `Deposit candidates: ${continuation.depositAddresses.map((address) => aliases.alias(address) ?? address).join(", ")}` : "Deposit candidates: none reached in this bounded trace.",
816
+ continuation.nextHopAddresses.length > 0 ? `Next addresses: ${continuation.nextHopAddresses.join(", ")}` : "Next addresses: none found in this trace."
817
+ ].join("\n");
818
+ }
819
+ async function runFundFlowProbe(remoteClient, _config, options) {
820
+ const seedAddress = options.seedAddress.trim();
821
+ const network = options.network.trim();
822
+ if (!seedAddress) throw new Error("seed_address is required");
823
+ if (!network) throw new Error("network is required");
824
+ const maxHops = clampInt$1(options.maxHops, 3, 1, 5);
825
+ const perAddressLimit = clampInt$1(options.perAddressLimit, 5, 1, 10);
826
+ const minAmountSum = Math.max(0, options.minAmountSum ?? 0);
827
+ const paths = require_output_root.workspaceOutputPaths();
828
+ await ensureDirs(paths);
829
+ const schemaResult = await loadOrCaptureTopologySchema(remoteClient, paths, network);
830
+ const { flows, deposits, sourceMatches, reverseLeads } = await collectProbeTrace(remoteClient, {
831
+ seedAddress,
832
+ network,
833
+ maxHops,
834
+ perAddressLimit,
835
+ minAmountSum
836
+ });
837
+ 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);
841
+ const compactPath = node_path.default.join(paths.reportTablesRoot, `${slug}.compact-evidence.json`);
842
+ const graphPath = node_path.default.join(paths.reportGraphsRoot, `${slug}.graph.json`);
843
+ const graphHtmlPath = node_path.default.join(paths.reportsRoot, `${slug}.graph.html`);
844
+ const tablePath = node_path.default.join(paths.reportTablesRoot, `${slug}.flows.csv`);
845
+ const tableHtmlPath = node_path.default.join(paths.reportsRoot, `${slug}.table.html`);
846
+ const reportPath = node_path.default.join(paths.reportsRoot, `${slug}.trace-report.md`);
847
+ const { generateInlineGraphHtml } = await Promise.resolve().then(() => require("./html-generator-CAv81IWH.cjs")).then((n) => n.html_generator_exports);
848
+ await (0, node_fs_promises.writeFile)(compactPath, JSON.stringify(compact, null, 2) + "\n", { mode: 384 });
849
+ await (0, node_fs_promises.writeFile)(graphPath, JSON.stringify(graph, null, 2) + "\n", { mode: 384 });
850
+ await (0, node_fs_promises.writeFile)(graphHtmlPath, generateInlineGraphHtml(graph), { mode: 384 });
851
+ await (0, node_fs_promises.writeFile)(tablePath, tableCsv(flows), { mode: 384 });
852
+ await (0, node_fs_promises.writeFile)(tableHtmlPath, buildTableHtml(seedAddress, network, flows, deposits, sourceMatches, reverseLeads), { mode: 384 });
853
+ await (0, node_fs_promises.writeFile)(reportPath, buildMarkdownReport(seedAddress, network, flows, deposits, sourceMatches, reverseLeads, aliases, graphPath, schemaResult.filePath), { mode: 384 });
854
+ if (options.caseId) {
855
+ const { EvidenceStore } = await Promise.resolve().then(() => require("./cases-CDcNU91B.cjs"));
856
+ await EvidenceStore.append(options.caseId, {
857
+ source: "track_funds",
858
+ queryParams: `network=${network} seed_address=${seedAddress} max_hops=${maxHops} per_address_limit=${perAddressLimit} min_amount_sum=${minAmountSum}`,
859
+ content: JSON.stringify({
860
+ schema: "chain-insights.evidence_pointer.v1",
861
+ source: "track_funds",
862
+ network,
863
+ seed_address: seedAddress,
864
+ address_map: aliases.compactAddressMap(),
865
+ files: {
866
+ compactEvidence: compactPath,
867
+ graph: graphPath,
868
+ graphHtml: graphHtmlPath,
869
+ table: tablePath,
870
+ tableHtml: tableHtmlPath,
871
+ report: reportPath
872
+ },
873
+ facts: {
874
+ flow_count: flows.length,
875
+ deposit_candidates: [...new Set(deposits.map((deposit) => aliases.alias(deposit.address) ?? deposit.address))],
876
+ exchange_endpoints: [...new Set(deposits.map((deposit) => aliases.alias(deposit.exchangeAddress) ?? deposit.exchangeAddress))],
877
+ traceback_source_paths: sourceMatches.length,
878
+ reverse_leads: reverseLeads.length
879
+ }
880
+ }, null, 2)
881
+ });
882
+ }
883
+ const depositAddresses = [...new Set(deposits.map((deposit) => deposit.address))];
884
+ const exchangeAddresses = [...new Set(deposits.map((deposit) => deposit.exchangeAddress))];
885
+ const leaves = [];
886
+ const continuation = {
887
+ nextHopAddresses: leaves.slice(0, 20),
888
+ depositAddresses,
889
+ exchangeAddresses,
890
+ hint: depositAddresses.length > 0 ? `Found ${depositAddresses.length} deposit candidate(s), defined as the address one hop before an exchange endpoint. Do not continue through exchange nodes.` : leaves.length > 0 ? `No exchange endpoint reached yet. Continue from ${leaves.length} non-exchange leaf destination(s) with the same tool, or raise the result budget if the current trace stopped early.` : "No exchange endpoint or non-exchange leaf destinations found; inspect graph/report files or lower min_amount_sum."
891
+ };
892
+ const files = {
893
+ schema: schemaResult.filePath,
894
+ compactEvidence: compactPath,
895
+ graph: graphPath,
896
+ graphHtml: graphHtmlPath,
897
+ table: tablePath,
898
+ tableHtml: tableHtmlPath,
899
+ report: reportPath
900
+ };
901
+ return {
902
+ summaryText: summarize$1(seedAddress, network, flows, sourceMatches, reverseLeads, aliases, files, continuation),
903
+ compactEvidence: compact,
904
+ graphData: graph,
905
+ files,
906
+ continuation,
907
+ addressMap: aliases.compactAddressMap()
908
+ };
909
+ }
910
+ //#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(value) {
938
+ return typeof value === "string" && value.trim() ? value.trim() : void 0;
939
+ }
940
+ function numberValue$1(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(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$1(value) {
974
+ return value.replaceAll("\\", "\\\\").replaceAll("\"", "\\\"");
975
+ }
976
+ function textFromToolResult$1(result) {
977
+ return (result.content ?? []).filter((item) => item.type === "text").map((item) => item.text).join("\n");
978
+ }
979
+ function parseGraphBatchResult$1(result) {
980
+ const text = textFromToolResult$1(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$1(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$1(result) || "graph_query_batch failed");
999
+ return parseGraphBatchResult$1(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
+ function hasExchangeLabel(labels) {
1015
+ return labels.some((label) => label.toLowerCase() === "exchange" || label.toLowerCase().includes("exchange"));
1016
+ }
1017
+ function isExchangeEndpoint(labels, isExchange, roles) {
1018
+ return isExchangeFlag(isExchange) || hasExchangeLabel(labels) || roles.some((role) => role.toLowerCase().includes("exchange"));
1019
+ }
1020
+ function isGenericContextLabel(label) {
1021
+ const normalized = label.trim().toLowerCase();
1022
+ return normalized === "exchange" || normalized === "validator" || /^miner subnet \d+$/.test(normalized) || /^subnet \d+(?: owner)?$/.test(normalized);
1023
+ }
1024
+ function exchangeNamesFromLabels(labels) {
1025
+ return [...new Set(labels.map((label) => label.trim().replace(/,\s*exchange$/i, "").trim()).filter((label) => label.length > 0 && !isGenericContextLabel(label)))];
1026
+ }
1027
+ function traversalProjection() {
1028
+ return [
1029
+ "src.address AS src",
1030
+ "dst.address AS dst",
1031
+ "src.labels AS src_labels",
1032
+ "dst.labels AS dst_labels",
1033
+ "src.is_exchange AS src_is_exchange",
1034
+ "dst.is_exchange AS dst_is_exchange",
1035
+ "r.amount_sum AS amount_sum",
1036
+ "r.amount_usd_sum AS amount_usd_sum",
1037
+ "r.tx_count AS tx_count",
1038
+ "r.first_seen_timestamp AS first_seen_timestamp",
1039
+ "r.last_seen_timestamp AS last_seen_timestamp",
1040
+ "r.first_tx_id AS first_tx_id",
1041
+ "r.last_tx_id AS last_tx_id"
1042
+ ].join(", ");
1043
+ }
1044
+ function frontierQuery(graphScope, sourceAddress, hop, sourceIndex, perAddressLimit, minAmountSum, activityThresholdTimestamp) {
1045
+ const where = ["src.address <> dst.address"];
1046
+ if (minAmountSum !== void 0) where.push(`r.amount_sum >= ${minAmountSum}`);
1047
+ if (graphScope === "incident" && activityThresholdTimestamp !== void 0) where.push(`(r.first_seen_timestamp >= ${activityThresholdTimestamp} OR r.last_seen_timestamp >= ${activityThresholdTimestamp})`);
1048
+ return {
1049
+ id: sourceIndex === void 0 ? `${graphScope}_hop_${hop}` : `${graphScope}_hop_${hop}_source_${sourceIndex}`,
1050
+ query: [
1051
+ `USE ${graphForScope(graphScope)}`,
1052
+ `MATCH (src:Address {address: "${escapeCypherString$1(sourceAddress)}"})-[r:FLOWS_TO]->(dst:Address)`,
1053
+ `WHERE ${where.join(" AND ")}`,
1054
+ `RETURN ${traversalProjection()}`,
1055
+ "ORDER BY r.amount_sum DESC",
1056
+ `LIMIT ${perAddressLimit}`
1057
+ ].join(" ")
1058
+ };
1059
+ }
1060
+ function activityThresholdFor(policy, incidentTimestampMs, entry) {
1061
+ if (policy === "global_incident") return incidentTimestampMs;
1062
+ return entry.arrivalTimestamp ?? incidentTimestampMs;
1063
+ }
1064
+ function edgeArrivalTimestamp(edge, threshold) {
1065
+ if (threshold === void 0) return edge.first_seen_timestamp ?? edge.last_seen_timestamp;
1066
+ if (edge.first_seen_timestamp !== void 0 && edge.first_seen_timestamp >= threshold) return edge.first_seen_timestamp;
1067
+ if (edge.last_seen_timestamp !== void 0 && edge.last_seen_timestamp >= threshold) return threshold;
1068
+ return edge.first_seen_timestamp ?? edge.last_seen_timestamp ?? threshold;
1069
+ }
1070
+ function depositClusterQuery(graphScope, depositAddress, index, minAmountSum) {
1071
+ const where = ["src.address <> dst.address", "src.is_exchange IS NULL"];
1072
+ if (minAmountSum !== void 0) where.push(`r.amount_sum >= ${minAmountSum}`);
1073
+ return {
1074
+ id: `${graphScope}_deposit_cluster_${index}`,
1075
+ query: [
1076
+ `USE ${graphForScope(graphScope)}`,
1077
+ `MATCH (src:Address)-[r:FLOWS_TO]->(dst:Address {address: "${escapeCypherString$1(depositAddress)}"})`,
1078
+ `WHERE ${where.join(" AND ")}`,
1079
+ `RETURN ${traversalProjection()}`,
1080
+ "ORDER BY r.amount_sum DESC",
1081
+ `LIMIT ${SCAM_TOPOLOGY_DEPOSIT_CLUSTER_LIMIT}`
1082
+ ].join(" ")
1083
+ };
1084
+ }
1085
+ function edgeFromRow(row, graphScope, hop, context) {
1086
+ const src = stringValue(row["src"]) ?? stringValue(row["from_address"]);
1087
+ const dst = stringValue(row["dst"]) ?? stringValue(row["to_address"]);
1088
+ if (!src || !dst || src === dst) return null;
1089
+ const srcLabels = stringArray(row["src_labels"]);
1090
+ const dstLabels = stringArray(row["dst_labels"]);
1091
+ const srcRoles = stringArray(row["src_roles"]);
1092
+ const dstRoles = stringArray(row["dst_roles"]);
1093
+ const srcIsExchange = isExchangeEndpoint(srcLabels, row["src_is_exchange"], srcRoles);
1094
+ const dstIsExchange = isExchangeEndpoint(dstLabels, row["dst_is_exchange"], dstRoles);
1095
+ const genericLabeledBoundary = dstLabels.length > 0 && !dstIsExchange;
1096
+ return {
1097
+ relation: dstIsExchange ? "terminal_exchange" : genericLabeledBoundary ? "context_boundary" : hop === 1 ? "seed_outflow" : "traversal_edge",
1098
+ src,
1099
+ dst,
1100
+ hop,
1101
+ graph_scope: graphScope,
1102
+ topology_graph: graphForScope(graphScope),
1103
+ seed_address: context.seedAddress,
1104
+ seed_role: context.seedRole,
1105
+ amount_sum: numberValue$1(row["amount_sum"]),
1106
+ amount_usd_sum: numberValue$1(row["amount_usd_sum"]),
1107
+ tx_count: numberValue$1(row["tx_count"]),
1108
+ first_seen_timestamp: numberValue$1(row["first_seen_timestamp"]),
1109
+ last_seen_timestamp: numberValue$1(row["last_seen_timestamp"]),
1110
+ first_tx_id: stringValue(row["first_tx_id"]),
1111
+ last_tx_id: stringValue(row["last_tx_id"]),
1112
+ src_labels: srcLabels,
1113
+ dst_labels: dstLabels,
1114
+ src_is_exchange: srcIsExchange,
1115
+ dst_is_exchange: dstIsExchange
1116
+ };
1117
+ }
1118
+ function edgeKey(edge) {
1119
+ return `${edge.graph_scope}\u0000${edge.seed_role ?? ""}\u0000${edge.seed_address ?? ""}\u0000${edge.src}\u0000${edge.dst}`;
1120
+ }
1121
+ function frontierKey(entry) {
1122
+ return `${entry.seedRole}\u0000${entry.seedAddress}\u0000${entry.address}`;
1123
+ }
1124
+ async function runDirectedTraversal(remoteClient, network, seeds, graphScope, activityPolicy, maxHops, perAddressLimit, minAmountSum, incidentTimestampMs) {
1125
+ const edgesByKey = /* @__PURE__ */ new Map();
1126
+ const skippedQueryErrors = [];
1127
+ let frontier = seeds.map((seed) => ({
1128
+ address: seed.address,
1129
+ seedAddress: seed.address,
1130
+ seedRole: seed.role,
1131
+ arrivalTimestamp: incidentTimestampMs,
1132
+ waveIndex: 0
1133
+ }));
1134
+ const visited = new Set(frontier.map(frontierKey));
1135
+ for (let hop = 1; hop <= maxHops && frontier.length > 0; hop += 1) {
1136
+ const frontierByAddress = /* @__PURE__ */ new Map();
1137
+ for (const entry of frontier) {
1138
+ const entries = frontierByAddress.get(entry.address) ?? [];
1139
+ entries.push(entry);
1140
+ frontierByAddress.set(entry.address, entries);
1141
+ }
1142
+ const frontierAddresses = [...frontierByAddress.keys()];
1143
+ const queries = frontierAddresses.map((address, index) => {
1144
+ const entry = frontierByAddress.get(address)?.[0];
1145
+ return frontierQuery(graphScope, address, hop, frontierAddresses.length === 1 ? void 0 : index + 1, perAddressLimit, minAmountSum, entry ? activityThresholdFor(activityPolicy, incidentTimestampMs, entry) : incidentTimestampMs);
1146
+ });
1147
+ const nextByKey = /* @__PURE__ */ new Map();
1148
+ const maxBatchQueries = graphScope === "history" ? SCAM_TOPOLOGY_ARCHIVE_BATCH_QUERIES : SCAM_TOPOLOGY_MAX_BATCH_QUERIES;
1149
+ for (const queryChunk of chunks(queries, maxBatchQueries)) {
1150
+ let batch;
1151
+ try {
1152
+ batch = await callGraphBatch$1(remoteClient, network, queryChunk);
1153
+ } catch (err) {
1154
+ if (hop === 1) throw err;
1155
+ for (const query of queryChunk) skippedQueryErrors.push({
1156
+ id: query.id,
1157
+ hop,
1158
+ graph_scope: graphScope,
1159
+ error: err.message
1160
+ });
1161
+ continue;
1162
+ }
1163
+ for (const queryResult of batch.facts?.queries ?? []) {
1164
+ if (queryResult.ok === false) {
1165
+ if (hop === 1) throw new Error(queryResult.error || `Query failed: ${queryResult.id}`);
1166
+ skippedQueryErrors.push({
1167
+ id: queryResult.id,
1168
+ hop,
1169
+ graph_scope: graphScope,
1170
+ error: queryResult.error || `Query failed: ${queryResult.id}`
1171
+ });
1172
+ continue;
1173
+ }
1174
+ for (const row of queryResult.results ?? []) {
1175
+ const src = stringValue(row["src"]) ?? stringValue(row["from_address"]);
1176
+ if (!src) continue;
1177
+ const contexts = frontierByAddress.get(src) ?? [];
1178
+ for (const context of contexts) {
1179
+ const baseEdge = edgeFromRow(row, graphScope, hop, context);
1180
+ if (!baseEdge || edgesByKey.has(edgeKey(baseEdge))) continue;
1181
+ const threshold = activityThresholdFor(activityPolicy, incidentTimestampMs, context);
1182
+ const targetEntry = {
1183
+ address: baseEdge.dst,
1184
+ seedAddress: context.seedAddress,
1185
+ seedRole: context.seedRole,
1186
+ arrivalTimestamp: edgeArrivalTimestamp(baseEdge, threshold),
1187
+ waveIndex: hop
1188
+ };
1189
+ const targetKey = frontierKey(targetEntry);
1190
+ const seenBefore = visited.has(targetKey);
1191
+ const terminal = baseEdge.relation === "terminal_exchange" || baseEdge.relation === "context_boundary";
1192
+ const expandsFrontier = !seenBefore && !terminal;
1193
+ const edge = {
1194
+ ...baseEdge,
1195
+ relation: seenBefore && baseEdge.relation === "traversal_edge" ? "convergence_edge" : baseEdge.relation,
1196
+ activity_policy: activityPolicy,
1197
+ wave_index: hop,
1198
+ expands_frontier: expandsFrontier,
1199
+ converges_to_seen_node: seenBefore,
1200
+ activity_threshold_timestamp: threshold,
1201
+ src_arrival_timestamp: context.arrivalTimestamp,
1202
+ dst_arrival_timestamp: targetEntry.arrivalTimestamp
1203
+ };
1204
+ edgesByKey.set(edgeKey(edge), edge);
1205
+ if (!seenBefore) visited.add(targetKey);
1206
+ if (!expandsFrontier) continue;
1207
+ const nextEntry = {
1208
+ address: edge.dst,
1209
+ seedAddress: context.seedAddress,
1210
+ seedRole: context.seedRole,
1211
+ arrivalTimestamp: edge.dst_arrival_timestamp,
1212
+ waveIndex: hop
1213
+ };
1214
+ nextByKey.set(targetKey, nextEntry);
1215
+ }
1216
+ }
1217
+ }
1218
+ }
1219
+ frontier = [...nextByKey.values()].slice(0, SCAM_TOPOLOGY_MAX_FRONTIER_SOURCES_PER_HOP);
1220
+ }
1221
+ return {
1222
+ graphScope,
1223
+ topologyGraph: graphForScope(graphScope),
1224
+ activityPolicy,
1225
+ edges: [...edgesByKey.values()],
1226
+ skippedQueryErrors
1227
+ };
1228
+ }
1229
+ async function expandDepositClusters(remoteClient, network, run, minAmountSum) {
1230
+ const edgesByKey = new Map(run.edges.map((edge) => [edgeKey(edge), edge]));
1231
+ const terminalDepositsByKey = /* @__PURE__ */ new Map();
1232
+ for (const edge of run.edges) {
1233
+ if (edge.relation !== "terminal_exchange") continue;
1234
+ const key = `${edge.seed_role ?? ""}\u0000${edge.seed_address ?? ""}\u0000${edge.src}`;
1235
+ if (!terminalDepositsByKey.has(key)) terminalDepositsByKey.set(key, edge);
1236
+ }
1237
+ const terminalDeposits = [...terminalDepositsByKey.values()];
1238
+ if (terminalDeposits.length === 0) return run;
1239
+ const queries = terminalDeposits.map((edge, index) => depositClusterQuery(run.graphScope, edge.src, index + 1, minAmountSum));
1240
+ const maxBatchQueries = run.graphScope === "history" ? SCAM_TOPOLOGY_ARCHIVE_BATCH_QUERIES : SCAM_TOPOLOGY_MAX_BATCH_QUERIES;
1241
+ for (const queryChunk of chunks(queries, maxBatchQueries)) {
1242
+ let batch;
1243
+ try {
1244
+ batch = await callGraphBatch$1(remoteClient, network, queryChunk);
1245
+ } catch (err) {
1246
+ for (const query of queryChunk) run.skippedQueryErrors.push({
1247
+ id: query.id,
1248
+ graph_scope: run.graphScope,
1249
+ error: err.message
1250
+ });
1251
+ continue;
1252
+ }
1253
+ for (const queryResult of batch.facts?.queries ?? []) {
1254
+ if (queryResult.ok === false) {
1255
+ run.skippedQueryErrors.push({
1256
+ id: queryResult.id,
1257
+ graph_scope: run.graphScope,
1258
+ error: queryResult.error || `Query failed: ${queryResult.id}`
1259
+ });
1260
+ continue;
1261
+ }
1262
+ const terminalEdge = terminalDeposits[queries.findIndex((query) => query.id === queryResult.id)];
1263
+ if (!terminalEdge) continue;
1264
+ const context = {
1265
+ address: terminalEdge.src,
1266
+ seedAddress: terminalEdge.seed_address ?? terminalEdge.src,
1267
+ seedRole: terminalEdge.seed_role ?? "victim",
1268
+ arrivalTimestamp: terminalEdge.src_arrival_timestamp ?? terminalEdge.first_seen_timestamp ?? terminalEdge.last_seen_timestamp,
1269
+ waveIndex: Math.max(0, terminalEdge.hop - 1)
1270
+ };
1271
+ for (const row of queryResult.results ?? []) {
1272
+ const edge = edgeFromRow(row, run.graphScope, Math.max(1, terminalEdge.hop - 1), context);
1273
+ if (!edge || edge.dst !== terminalEdge.src || edge.src === terminalEdge.dst) continue;
1274
+ const clusterEdge = {
1275
+ ...edge,
1276
+ relation: "deposit_cluster_inflow",
1277
+ seed_address: terminalEdge.seed_address,
1278
+ seed_role: terminalEdge.seed_role,
1279
+ activity_policy: run.activityPolicy,
1280
+ wave_index: Math.max(1, terminalEdge.hop - 1),
1281
+ expands_frontier: false,
1282
+ converges_to_seen_node: true,
1283
+ activity_threshold_timestamp: terminalEdge.activity_threshold_timestamp,
1284
+ src_arrival_timestamp: edge.first_seen_timestamp ?? edge.last_seen_timestamp,
1285
+ dst_arrival_timestamp: terminalEdge.dst_arrival_timestamp
1286
+ };
1287
+ if (!edgesByKey.has(edgeKey(clusterEdge))) edgesByKey.set(edgeKey(clusterEdge), clusterEdge);
1288
+ }
1289
+ }
1290
+ }
1291
+ return {
1292
+ ...run,
1293
+ edges: [...edgesByKey.values()]
1294
+ };
1295
+ }
1296
+ function candidateKey(candidate) {
1297
+ return `${candidate.address}\u0000${candidate.address_subtype}`;
1298
+ }
1299
+ function mergeCandidate(candidates, candidate) {
1300
+ const key = candidateKey(candidate);
1301
+ const existing = candidates.get(key);
1302
+ if (!existing) {
1303
+ candidates.set(key, candidate);
1304
+ return;
1305
+ }
1306
+ existing.confidence_score = Math.max(existing.confidence_score, candidate.confidence_score);
1307
+ existing.evidence.push(...candidate.evidence);
1308
+ if (candidate.promotion_status === "promote_confirmed") {
1309
+ existing.promotion_status = "promote_confirmed";
1310
+ existing.trust_level = "blacklisted";
1311
+ existing.risk_level = "critical";
1312
+ }
1313
+ }
1314
+ function labelForSubtype(subtype) {
1315
+ switch (subtype) {
1316
+ case "scam_seed": return "Known scam seed";
1317
+ case "laundering_intermediate": return "Scam laundering intermediate";
1318
+ case "exchange_deposit_candidate": return "Scam exchange deposit candidate";
1319
+ }
1320
+ }
1321
+ function makeCandidate(address, subtype, evidence, confidence, promotionStatus) {
1322
+ return {
1323
+ address,
1324
+ label: labelForSubtype(subtype),
1325
+ address_type: "SCAM",
1326
+ address_subtype: subtype,
1327
+ trust_level: promotionStatus === "promote_confirmed" ? "blacklisted" : "candidate",
1328
+ risk_level: promotionStatus === "promote_confirmed" ? "critical" : "high",
1329
+ confidence_score: confidence,
1330
+ promotion_status: promotionStatus,
1331
+ source: "scam_topology",
1332
+ evidence: [evidence]
1333
+ };
1334
+ }
1335
+ function addRole(rolesByAddress, address, role) {
1336
+ if (!address) return;
1337
+ const roles = rolesByAddress.get(address) ?? /* @__PURE__ */ new Set();
1338
+ roles.add(role);
1339
+ rolesByAddress.set(address, roles);
1340
+ }
1341
+ function pushCaseRole(caseRoles, role) {
1342
+ 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;
1343
+ caseRoles.push(role);
1344
+ }
1345
+ function pushSafetyDecision(safetyDecisions, decision) {
1346
+ if (safetyDecisions.some((entry) => JSON.stringify(entry) === JSON.stringify(decision))) return;
1347
+ safetyDecisions.push(decision);
1348
+ }
1349
+ function edgeEvidence(edge, reason) {
1350
+ return {
1351
+ seed_address: edge.seed_address,
1352
+ seed_role: edge.seed_role,
1353
+ graph_scope: edge.graph_scope,
1354
+ scope_membership: edge.scope_membership,
1355
+ hop: edge.hop,
1356
+ src: edge.src,
1357
+ dst: edge.dst,
1358
+ amount_sum: edge.amount_sum,
1359
+ amount_usd_sum: edge.amount_usd_sum,
1360
+ tx_count: edge.tx_count,
1361
+ ...edge.relation === "terminal_exchange" ? {
1362
+ deposit_address: edge.src,
1363
+ exchange_address: edge.dst,
1364
+ exchange_names: exchangeNamesFromLabels(edge.dst_labels),
1365
+ exchange_labels: edge.dst_labels
1366
+ } : {},
1367
+ reason
1368
+ };
1369
+ }
1370
+ function classifyTopology(seeds, edges) {
1371
+ const candidates = /* @__PURE__ */ new Map();
1372
+ const caseRoles = [];
1373
+ const safetyDecisions = [];
1374
+ const rolesByAddress = /* @__PURE__ */ new Map();
1375
+ const seedAddresses = new Set(seeds.map((seed) => seed.address));
1376
+ const victimAddresses = new Set(seeds.filter((seed) => seed.role === "victim").map((seed) => seed.address));
1377
+ const exchangeDepositAddresses = new Set(edges.filter((edge) => edge.relation === "terminal_exchange").map((edge) => edge.src).filter((address) => !seedAddresses.has(address) && !victimAddresses.has(address)));
1378
+ const terminalPoints = [];
1379
+ const exchangeDeposits = [];
1380
+ const investigationHints = [];
1381
+ for (const seed of seeds) {
1382
+ pushCaseRole(caseRoles, {
1383
+ address: seed.address,
1384
+ role: seed.role
1385
+ });
1386
+ addRole(rolesByAddress, seed.address, seed.role);
1387
+ if (seed.role === "victim") pushSafetyDecision(safetyDecisions, {
1388
+ address: seed.address,
1389
+ decision: "do_not_label_victim_seed",
1390
+ reason: "Victim/source addresses are protected case roles, not risky actors by default."
1391
+ });
1392
+ else mergeCandidate(candidates, makeCandidate(seed.address, "scam_seed", {
1393
+ seed_address: seed.address,
1394
+ seed_role: seed.role,
1395
+ reason: "Operator supplied this address as a known scammer seed."
1396
+ }, 1, "promote_confirmed"));
1397
+ }
1398
+ for (const edge of edges) {
1399
+ if (edge.relation === "deposit_cluster_inflow") {
1400
+ if (seedAddresses.has(edge.src) || victimAddresses.has(edge.src) || exchangeDepositAddresses.has(edge.src)) continue;
1401
+ if (edge.src_labels.length > 0) {
1402
+ pushCaseRole(caseRoles, {
1403
+ address: edge.src,
1404
+ role: "continue_from_address",
1405
+ seed_address: edge.seed_address,
1406
+ seed_role: edge.seed_role
1407
+ });
1408
+ addRole(rolesByAddress, edge.src, "continue_from_address");
1409
+ investigationHints.push({
1410
+ address: edge.src,
1411
+ hint_type: "generic_labeled_cluster_member",
1412
+ labels: edge.src_labels,
1413
+ reason: "Generic labels are preserved as context, but the address shares an exchange-deposit inflow cluster with the scam topology.",
1414
+ seed_address: edge.seed_address
1415
+ });
1416
+ pushSafetyDecision(safetyDecisions, {
1417
+ address: edge.src,
1418
+ decision: "context_only_generic_labeled_cluster_member",
1419
+ reason: "Generic non-exchange labels stop automatic scam labeling; investigate manually if this context should continue.",
1420
+ labels: edge.src_labels,
1421
+ seed_address: edge.seed_address
1422
+ });
1423
+ continue;
1424
+ }
1425
+ pushCaseRole(caseRoles, {
1426
+ address: edge.src,
1427
+ role: "laundering_intermediate",
1428
+ seed_address: edge.seed_address,
1429
+ seed_role: edge.seed_role
1430
+ });
1431
+ addRole(rolesByAddress, edge.src, "laundering_intermediate");
1432
+ mergeCandidate(candidates, makeCandidate(edge.src, "laundering_intermediate", edgeEvidence(edge, "Address sends into an exchange-deposit cluster reached from a known scam topology seed."), edge.seed_role === "scammer" ? .78 : .64, "review_required"));
1433
+ continue;
1434
+ }
1435
+ if (edge.relation === "terminal_exchange") {
1436
+ const exchangeNames = exchangeNamesFromLabels(edge.dst_labels);
1437
+ const exchangeDeposit = {
1438
+ deposit_address: edge.src,
1439
+ exchange_address: edge.dst,
1440
+ exchange_names: exchangeNames,
1441
+ exchange_labels: edge.dst_labels,
1442
+ amount_sum: edge.amount_sum,
1443
+ amount_usd_sum: edge.amount_usd_sum,
1444
+ tx_count: edge.tx_count,
1445
+ hop: edge.hop,
1446
+ graph_scope: edge.graph_scope,
1447
+ topology_graph: edge.topology_graph,
1448
+ scope_membership: edge.scope_membership,
1449
+ seed_address: edge.seed_address,
1450
+ seed_role: edge.seed_role,
1451
+ first_seen_timestamp: edge.first_seen_timestamp,
1452
+ last_seen_timestamp: edge.last_seen_timestamp,
1453
+ first_tx_id: edge.first_tx_id,
1454
+ last_tx_id: edge.last_tx_id
1455
+ };
1456
+ pushCaseRole(caseRoles, {
1457
+ address: edge.dst,
1458
+ role: "exchange_endpoint",
1459
+ seed_address: edge.seed_address,
1460
+ seed_role: edge.seed_role
1461
+ });
1462
+ addRole(rolesByAddress, edge.dst, "exchange_endpoint");
1463
+ terminalPoints.push({
1464
+ address: edge.dst,
1465
+ terminal_type: "exchange_endpoint",
1466
+ source_address: edge.src,
1467
+ deposit_address: edge.src,
1468
+ exchange_address: edge.dst,
1469
+ exchange_names: exchangeNames,
1470
+ exchange_labels: edge.dst_labels,
1471
+ seed_address: edge.seed_address,
1472
+ graph_scope: edge.graph_scope,
1473
+ topology_graph: edge.topology_graph,
1474
+ scope_membership: edge.scope_membership
1475
+ });
1476
+ 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);
1477
+ pushSafetyDecision(safetyDecisions, {
1478
+ address: edge.dst,
1479
+ decision: "do_not_label_exchange_endpoint",
1480
+ reason: "Exchange endpoints are terminal service context, not scam label candidates.",
1481
+ seed_address: edge.seed_address
1482
+ });
1483
+ if (!seedAddresses.has(edge.src) && !victimAddresses.has(edge.src)) {
1484
+ pushCaseRole(caseRoles, {
1485
+ address: edge.src,
1486
+ role: "exchange_deposit_candidate",
1487
+ seed_address: edge.seed_address,
1488
+ seed_role: edge.seed_role
1489
+ });
1490
+ addRole(rolesByAddress, edge.src, "exchange_deposit_candidate");
1491
+ mergeCandidate(candidates, makeCandidate(edge.src, "exchange_deposit_candidate", edgeEvidence(edge, "Address is the penultimate hop before an exchange endpoint."), edge.seed_role === "scammer" ? .8 : .68, "review_required"));
1492
+ }
1493
+ continue;
1494
+ }
1495
+ if (edge.relation === "context_boundary") {
1496
+ pushCaseRole(caseRoles, {
1497
+ address: edge.dst,
1498
+ role: "context_boundary",
1499
+ seed_address: edge.seed_address,
1500
+ seed_role: edge.seed_role
1501
+ });
1502
+ addRole(rolesByAddress, edge.dst, "context_boundary");
1503
+ terminalPoints.push({
1504
+ address: edge.dst,
1505
+ terminal_type: "context_boundary",
1506
+ source_address: edge.src,
1507
+ labels: edge.dst_labels,
1508
+ seed_address: edge.seed_address,
1509
+ graph_scope: edge.graph_scope,
1510
+ scope_membership: edge.scope_membership
1511
+ });
1512
+ investigationHints.push({
1513
+ address: edge.dst,
1514
+ hint_type: "generic_labeled_context",
1515
+ labels: edge.dst_labels,
1516
+ reason: "Non-exchange labels are context hints only and stop automatic scam traversal.",
1517
+ seed_address: edge.seed_address
1518
+ });
1519
+ pushSafetyDecision(safetyDecisions, {
1520
+ address: edge.dst,
1521
+ decision: "context_only_generic_labeled_node",
1522
+ reason: "Generic non-exchange labels are not hard-coded scam infrastructure classes.",
1523
+ labels: edge.dst_labels,
1524
+ seed_address: edge.seed_address
1525
+ });
1526
+ continue;
1527
+ }
1528
+ if (seedAddresses.has(edge.dst) || victimAddresses.has(edge.dst) || exchangeDepositAddresses.has(edge.dst)) continue;
1529
+ pushCaseRole(caseRoles, {
1530
+ address: edge.dst,
1531
+ role: "laundering_intermediate",
1532
+ seed_address: edge.seed_address,
1533
+ seed_role: edge.seed_role
1534
+ });
1535
+ addRole(rolesByAddress, edge.dst, "laundering_intermediate");
1536
+ mergeCandidate(candidates, makeCandidate(edge.dst, "laundering_intermediate", edgeEvidence(edge, "Address appears on an outward path from a known scam topology seed."), edge.seed_role === "scammer" ? .85 : .72, "review_required"));
1537
+ }
1538
+ return {
1539
+ labelCandidates: [...candidates.values()].sort((a, b) => b.confidence_score - a.confidence_score || a.address.localeCompare(b.address)),
1540
+ caseRoles,
1541
+ safetyDecisions,
1542
+ rolesByAddress,
1543
+ intermediaries: [...new Set(caseRoles.filter((role) => role.role === "laundering_intermediate").map((role) => role.address))],
1544
+ terminalPoints,
1545
+ exchangeDeposits,
1546
+ investigationHints
1547
+ };
1548
+ }
1549
+ function mergeLabels(existing, next) {
1550
+ return [...new Set([...stringArray(existing), ...next])];
1551
+ }
1552
+ function primaryFlowEdges(edges) {
1553
+ return edges.filter((edge) => edge.relation !== "deposit_cluster_inflow");
1554
+ }
1555
+ function depositClusterEdges(edges) {
1556
+ return edges.filter((edge) => edge.relation === "deposit_cluster_inflow");
1557
+ }
1558
+ function shortestPathFromSeed(seedAddress, targetAddress, edges) {
1559
+ if (seedAddress === targetAddress) return [seedAddress];
1560
+ const adjacency = /* @__PURE__ */ new Map();
1561
+ for (const edge of edges) {
1562
+ const destinations = adjacency.get(edge.src) ?? [];
1563
+ destinations.push(edge.dst);
1564
+ adjacency.set(edge.src, destinations);
1565
+ }
1566
+ const queue = [seedAddress];
1567
+ const parent = new Map([[seedAddress, null]]);
1568
+ for (let index = 0; index < queue.length; index += 1) {
1569
+ const current = queue[index];
1570
+ for (const next of adjacency.get(current) ?? []) {
1571
+ if (parent.has(next)) continue;
1572
+ parent.set(next, current);
1573
+ if (next === targetAddress) {
1574
+ const path = [targetAddress];
1575
+ let cursor = current;
1576
+ while (cursor) {
1577
+ path.push(cursor);
1578
+ cursor = parent.get(cursor);
1579
+ }
1580
+ return path.reverse();
1581
+ }
1582
+ queue.push(next);
1583
+ }
1584
+ }
1585
+ return [seedAddress, targetAddress];
1586
+ }
1587
+ function scamLabelsByAddress(facts) {
1588
+ const labels = Array.isArray(facts["scam_labels"]) ? facts["scam_labels"] : [];
1589
+ const result = /* @__PURE__ */ new Map();
1590
+ for (const label of labels) {
1591
+ if (!label || typeof label !== "object" || Array.isArray(label)) continue;
1592
+ const record = label;
1593
+ const address = stringValue(record["address"]);
1594
+ const confidence = numberValue$1(record["confidence"]);
1595
+ if (!address || confidence === void 0) continue;
1596
+ result.set(address, {
1597
+ address,
1598
+ scam: true,
1599
+ confidence,
1600
+ source: "scam_topology",
1601
+ source_victim_address: stringValue(record["source_victim_address"]) ?? "",
1602
+ source_incident_timestamp_ms: numberValue$1(record["source_incident_timestamp_ms"]) ?? 0
1603
+ });
1604
+ }
1605
+ return result;
1606
+ }
1607
+ function buildGraph(seeds, edges, rolesByAddress, facts) {
1608
+ const nodesById = /* @__PURE__ */ new Map();
1609
+ const primaryEdges = primaryFlowEdges(edges);
1610
+ const clusterEdges = depositClusterEdges(edges);
1611
+ const scamLabels = scamLabelsByAddress(facts);
1612
+ for (const seed of seeds) nodesById.set(seed.address, {
1613
+ id: seed.address,
1614
+ address: seed.address,
1615
+ node_type: "address",
1616
+ roles: [...rolesByAddress.get(seed.address) ?? new Set([seed.role])],
1617
+ flow_in_usd: 0,
1618
+ flow_out_usd: 0
1619
+ });
1620
+ const mergeNode = (address, labels, roles = []) => {
1621
+ const existing = nodesById.get(address) ?? {
1622
+ id: address,
1623
+ address,
1624
+ node_type: "address",
1625
+ flow_in_usd: 0,
1626
+ flow_out_usd: 0
1627
+ };
1628
+ const addressRoles = [...new Set([
1629
+ ...stringArray(existing["roles"]),
1630
+ ...[...rolesByAddress.get(address) ?? []],
1631
+ ...roles
1632
+ ])];
1633
+ const scamLabel = scamLabels.get(address);
1634
+ nodesById.set(address, {
1635
+ ...existing,
1636
+ labels: mergeLabels(existing["labels"], labels),
1637
+ roles: addressRoles,
1638
+ ...scamLabel ? {
1639
+ scam: true,
1640
+ scam_confidence: scamLabel.confidence,
1641
+ scam_source: scamLabel.source
1642
+ } : {}
1643
+ });
1644
+ return nodesById.get(address);
1645
+ };
1646
+ const addFlowTotals = (address, direction, amount) => {
1647
+ const node = nodesById.get(address) ?? mergeNode(address, []);
1648
+ const key = direction === "in" ? "flow_in_usd" : "flow_out_usd";
1649
+ node[key] = (numberValue$1(node[key]) ?? 0) + amount;
1650
+ nodesById.set(address, node);
1651
+ };
1652
+ for (const edge of edges) {
1653
+ const src = mergeNode(edge.src, edge.src_labels, edge.relation === "deposit_cluster_inflow" ? ["lead"] : []);
1654
+ const dstRoles = edge.relation === "terminal_exchange" ? ["exchange"] : edge.relation === "context_boundary" ? ["context_boundary"] : [];
1655
+ const dst = mergeNode(edge.dst, edge.dst_labels, dstRoles);
1656
+ if (edge.src_is_exchange) src["is_exchange"] = true;
1657
+ if (edge.dst_is_exchange) dst["is_exchange"] = true;
1658
+ const amount = edge.amount_usd_sum ?? edge.amount_sum ?? 0;
1659
+ addFlowTotals(edge.src, "out", amount);
1660
+ addFlowTotals(edge.dst, "in", amount);
1661
+ }
1662
+ const deposits = primaryEdges.filter((edge) => edge.relation === "terminal_exchange").map((edge) => ({
1663
+ address: edge.src,
1664
+ exchangeAddress: edge.dst,
1665
+ exchangeLabels: edge.dst_labels,
1666
+ exchangeNames: exchangeNamesFromLabels(edge.dst_labels),
1667
+ amount_sum: edge.amount_sum,
1668
+ amount_usd_sum: edge.amount_usd_sum,
1669
+ hops: edge.hop,
1670
+ path: shortestPathFromSeed(edge.seed_address ?? seeds[0]?.address ?? edge.src, edge.dst, primaryEdges),
1671
+ seed_role: edge.seed_role,
1672
+ seed_address: edge.seed_address
1673
+ }));
1674
+ const reverseLeads = clusterEdges.map((edge) => ({
1675
+ address: edge.src,
1676
+ labels: edge.src_labels,
1677
+ deposit_address: edge.dst,
1678
+ amount_usd: edge.amount_usd_sum ?? edge.amount_sum,
1679
+ degree_in: void 0,
1680
+ degree_out: void 0,
1681
+ total_volume_usd: edge.amount_usd_sum,
1682
+ reason: "deposit_cluster_inflow",
1683
+ seed_role: edge.seed_role,
1684
+ seed_address: edge.seed_address,
1685
+ first_seen_timestamp: edge.first_seen_timestamp,
1686
+ last_seen_timestamp: edge.last_seen_timestamp,
1687
+ tx_count: edge.tx_count
1688
+ }));
1689
+ return require_graph_normalizer.normalizeGraphPayload({
1690
+ schema: "chain-insights.graph.v1",
1691
+ nodes: [...nodesById.values()],
1692
+ edges: [...primaryEdges.map((edge) => ({
1693
+ source: edge.src,
1694
+ target: edge.dst,
1695
+ edge_type: "flows_to",
1696
+ relation: edge.relation,
1697
+ hop: edge.hop,
1698
+ wave_index: edge.wave_index,
1699
+ graph_scope: edge.graph_scope,
1700
+ topology_graph: edge.topology_graph,
1701
+ activity_policy: edge.activity_policy,
1702
+ scope_membership: edge.scope_membership,
1703
+ seed_address: edge.seed_address,
1704
+ seed_role: edge.seed_role,
1705
+ usd_amount: edge.amount_usd_sum ?? edge.amount_sum,
1706
+ amount_sum: edge.amount_sum,
1707
+ amount_usd_sum: edge.amount_usd_sum,
1708
+ tx_count: edge.tx_count ?? 0,
1709
+ first_seen_timestamp: edge.first_seen_timestamp,
1710
+ last_seen_timestamp: edge.last_seen_timestamp,
1711
+ first_tx_id: edge.first_tx_id,
1712
+ last_tx_id: edge.last_tx_id,
1713
+ expands_frontier: edge.expands_frontier,
1714
+ converges_to_seen_node: edge.converges_to_seen_node,
1715
+ activity_threshold_timestamp: edge.activity_threshold_timestamp,
1716
+ src_arrival_timestamp: edge.src_arrival_timestamp,
1717
+ dst_arrival_timestamp: edge.dst_arrival_timestamp,
1718
+ terminal_exchange: edge.relation === "terminal_exchange",
1719
+ context_boundary: edge.relation === "context_boundary"
1720
+ })), ...reverseLeads.map((lead) => ({
1721
+ source: lead.address,
1722
+ target: lead.deposit_address,
1723
+ edge_type: "flows_to",
1724
+ relation: "deposit_cluster_inflow",
1725
+ usd_amount: lead.amount_usd ?? 0,
1726
+ amount_sum: lead.amount_usd ?? 0,
1727
+ tx_count: lead.tx_count ?? 0,
1728
+ direction: "reverse_1hop_lead"
1729
+ }))],
1730
+ flows: primaryEdges.map((edge) => ({
1731
+ hop: edge.hop,
1732
+ src: edge.src,
1733
+ dst: edge.dst,
1734
+ relation: edge.relation,
1735
+ graph_scope: edge.graph_scope,
1736
+ topology_graph: edge.topology_graph,
1737
+ activity_policy: edge.activity_policy,
1738
+ wave_index: edge.wave_index,
1739
+ scope_membership: edge.scope_membership,
1740
+ seed_address: edge.seed_address,
1741
+ seed_role: edge.seed_role,
1742
+ amount_sum: edge.amount_sum,
1743
+ amount_usd_sum: edge.amount_usd_sum,
1744
+ tx_count: edge.tx_count,
1745
+ first_seen_timestamp: edge.first_seen_timestamp,
1746
+ last_seen_timestamp: edge.last_seen_timestamp,
1747
+ first_tx_id: edge.first_tx_id,
1748
+ last_tx_id: edge.last_tx_id,
1749
+ expands_frontier: edge.expands_frontier,
1750
+ converges_to_seen_node: edge.converges_to_seen_node,
1751
+ terminal_exchange: edge.relation === "terminal_exchange",
1752
+ context_boundary: edge.relation === "context_boundary"
1753
+ })),
1754
+ deposits,
1755
+ source_matches: [],
1756
+ reverse_leads: reverseLeads,
1757
+ edge_anchors: [],
1758
+ metadata: {
1759
+ source: "scam_topology",
1760
+ network: facts["network"],
1761
+ victim_address: facts["victim_address"],
1762
+ incident_timestamp_ms: facts["incident_timestamp_ms"],
1763
+ scam_label_count: Array.isArray(facts["scam_labels"]) ? facts["scam_labels"].length : 0,
1764
+ label_candidate_count: Array.isArray(facts["label_candidates"]) ? facts["label_candidates"].length : 0,
1765
+ topology_edge_count: edges.length,
1766
+ primary_flow_count: primaryEdges.length,
1767
+ reverse_lead_count: reverseLeads.length,
1768
+ primary_activity_policy: "node_relative",
1769
+ generated_at: (/* @__PURE__ */ new Date()).toISOString()
1770
+ }
1771
+ });
1772
+ }
1773
+ function makeScamLabels(candidates, victimAddress, incidentTimestampMs) {
1774
+ return candidates.filter((candidate) => candidate.address_subtype !== "scam_seed").map((candidate) => ({
1775
+ address: candidate.address,
1776
+ scam: true,
1777
+ confidence: candidate.confidence_score,
1778
+ source: "scam_topology",
1779
+ source_victim_address: victimAddress,
1780
+ source_incident_timestamp_ms: incidentTimestampMs
1781
+ }));
1782
+ }
1783
+ function summarize(network, victimAddress, incidentTimestampMs, candidates, scamLabels, safetyDecisions, topologyEdges, terminalPoints) {
1784
+ const review = candidates.filter((candidate) => candidate.promotion_status === "review_required").length;
1785
+ return [
1786
+ `Scam topology complete for ${network}`,
1787
+ "",
1788
+ "Topology graph: live_topology",
1789
+ `Victim/source seed: ${victimAddress}`,
1790
+ `Incident timestamp ms: ${incidentTimestampMs}`,
1791
+ `Topology edges: ${topologyEdges.length}.`,
1792
+ `Terminal points: ${terminalPoints.length}.`,
1793
+ `Scam labels: ${scamLabels.length}.`,
1794
+ `Review candidates: ${candidates.length} (${review} review_required).`,
1795
+ `Safety decisions: ${safetyDecisions.length}.`,
1796
+ "",
1797
+ "Policy: victims, exchange endpoints, and generic labeled context nodes are not automatic scam labels."
1798
+ ].join("\n");
1799
+ }
1800
+ function csvCell(value) {
1801
+ if (value === void 0 || value === null) return "\"\"";
1802
+ if (Array.isArray(value) || typeof value === "object" && value !== null) return JSON.stringify(JSON.stringify(value));
1803
+ return JSON.stringify(String(value));
1804
+ }
1805
+ function labelCandidatesCsv(candidates) {
1806
+ const rows = [[
1807
+ "address",
1808
+ "label",
1809
+ "address_type",
1810
+ "address_subtype",
1811
+ "trust_level",
1812
+ "risk_level",
1813
+ "confidence_score",
1814
+ "promotion_status",
1815
+ "source",
1816
+ "evidence_count"
1817
+ ].join(",")];
1818
+ for (const candidate of candidates) rows.push([
1819
+ candidate.address,
1820
+ candidate.label,
1821
+ candidate.address_type,
1822
+ candidate.address_subtype,
1823
+ candidate.trust_level,
1824
+ candidate.risk_level,
1825
+ candidate.confidence_score,
1826
+ candidate.promotion_status,
1827
+ candidate.source,
1828
+ candidate.evidence.length
1829
+ ].map(csvCell).join(","));
1830
+ return rows.join("\n") + "\n";
1831
+ }
1832
+ function buildScamTopologyReport(facts, files) {
1833
+ return [
1834
+ `# Scam Topology: ${facts.victim_address}`,
1835
+ "",
1836
+ `Network: \`${facts.network}\``,
1837
+ `Incident timestamp ms: \`${facts.incident_timestamp_ms}\``,
1838
+ `Activity policy: \`${facts.activity_policy_mode}\``,
1839
+ `Graph: \`${files.graph}\``,
1840
+ `Label candidates CSV: \`${files.labelCandidates}\``,
1841
+ "",
1842
+ "## Summary",
1843
+ "",
1844
+ `- Topology edges: ${facts.topology_edges.length}`,
1845
+ `- Terminal points: ${facts.terminal_points.length}`,
1846
+ `- Exchange deposits: ${facts.exchange_deposits.length}`,
1847
+ `- Scam labels: ${facts.scam_labels.length}`,
1848
+ `- Review candidates: ${facts.label_candidates.length}`,
1849
+ `- Safety decisions: ${facts.safety_decisions.length}`,
1850
+ "",
1851
+ "## Exchange Deposits",
1852
+ "",
1853
+ "| Deposit | Exchange | Names | Hop | amount_sum | tx_count |",
1854
+ "|---|---|---|---:|---:|---:|",
1855
+ ...facts.exchange_deposits.map((entry) => {
1856
+ return `| \`${stringValue(entry["deposit_address"]) ?? ""}\` | \`${stringValue(entry["exchange_address"]) ?? ""}\` | ${stringArray(entry["exchange_names"]).join(", ") || ""} | ${entry["hop"] ?? ""} | ${entry["amount_sum"] ?? ""} | ${entry["tx_count"] ?? ""} |`;
1857
+ }),
1858
+ "",
1859
+ "## Label Candidates",
1860
+ "",
1861
+ "| Address | Subtype | Confidence | Status |",
1862
+ "|---|---|---:|---|",
1863
+ ...facts.label_candidates.map((candidate) => `| \`${candidate.address}\` | ${candidate.address_subtype} | ${candidate.confidence_score} | ${candidate.promotion_status} |`),
1864
+ ""
1865
+ ].join("\n") + "\n";
1866
+ }
1867
+ function scamTopologyCompactEvidence(facts) {
1868
+ return {
1869
+ schema: "chain-insights.scam_topology_evidence.v1",
1870
+ source: "scam_topology",
1871
+ network: facts.network,
1872
+ victim_address: facts.victim_address,
1873
+ incident_timestamp_ms: facts.incident_timestamp_ms,
1874
+ topology_graphs: facts.topology_graphs,
1875
+ primary_activity_policy: facts.primary_activity_policy,
1876
+ activity_policy_mode: facts.activity_policy_mode,
1877
+ topology_edge_count: facts.topology_edges.length,
1878
+ terminal_points: facts.terminal_points,
1879
+ exchange_deposits: facts.exchange_deposits,
1880
+ scam_labels: facts.scam_labels,
1881
+ label_candidates: facts.label_candidates,
1882
+ safety_decisions: facts.safety_decisions
1883
+ };
1884
+ }
1885
+ async function writeScamTopologyCaseArtifacts(facts, graphData) {
1886
+ const paths = require_output_root.workspaceOutputPaths();
1887
+ await ensureScamTopologyDirs(paths);
1888
+ const slug = `${(/* @__PURE__ */ new Date()).toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z")}_scam-topology_${sanitizeSegment(facts.victim_address.slice(0, 16))}`;
1889
+ const compactEvidencePath = node_path.default.join(paths.reportTablesRoot, `${slug}.compact-evidence.json`);
1890
+ const graphPath = node_path.default.join(paths.reportGraphsRoot, `${slug}.graph.json`);
1891
+ const graphHtmlPath = node_path.default.join(paths.reportsRoot, `${slug}.graph.html`);
1892
+ const labelCandidatesPath = node_path.default.join(paths.reportTablesRoot, `${slug}.label-candidates.csv`);
1893
+ const reportPath = node_path.default.join(paths.reportsRoot, `${slug}.scam-topology-report.md`);
1894
+ const { generateInlineGraphHtml } = await Promise.resolve().then(() => require("./html-generator-CAv81IWH.cjs")).then((n) => n.html_generator_exports);
1895
+ const files = {
1896
+ compactEvidence: compactEvidencePath,
1897
+ graph: graphPath,
1898
+ graphHtml: graphHtmlPath,
1899
+ labelCandidates: labelCandidatesPath,
1900
+ report: reportPath
1901
+ };
1902
+ await (0, node_fs_promises.writeFile)(compactEvidencePath, JSON.stringify(scamTopologyCompactEvidence(facts), null, 2) + "\n", { mode: 384 });
1903
+ await (0, node_fs_promises.writeFile)(graphPath, JSON.stringify(graphData, null, 2) + "\n", { mode: 384 });
1904
+ await (0, node_fs_promises.writeFile)(graphHtmlPath, generateInlineGraphHtml(graphData), { mode: 384 });
1905
+ await (0, node_fs_promises.writeFile)(labelCandidatesPath, labelCandidatesCsv(facts.label_candidates), { mode: 384 });
1906
+ await (0, node_fs_promises.writeFile)(reportPath, buildScamTopologyReport(facts, files), { mode: 384 });
1907
+ return files;
1908
+ }
1909
+ function validateNonNegativeNumber(value, name) {
1910
+ if (value === void 0) return void 0;
1911
+ if (!Number.isFinite(value) || value < 0) throw new Error(`${name} must be a non-negative number`);
1912
+ return value;
1913
+ }
1914
+ function validateActivityPolicyMode(value) {
1915
+ if (value === void 0 || value === null || value === "") return "node_relative_only";
1916
+ if (value === "node_relative_only" || value === "global_incident_only") return value;
1917
+ throw new Error("activity_policy must be one of: node_relative_only, global_incident_only");
1918
+ }
1919
+ function activityPolicyForMode(mode) {
1920
+ return mode === "global_incident_only" ? "global_incident" : "node_relative";
1921
+ }
1922
+ async function scamTopology(remoteClient, config, options) {
1923
+ const network = options.network.trim();
1924
+ const legacyOptions = options;
1925
+ const victimAddresses = parseAddressList$1(options.victimAddress ?? legacyOptions.victimAddresses);
1926
+ const scammerAddresses = parseAddressList$1(legacyOptions.scammerAddresses);
1927
+ const incidentTimestampMs = validateNonNegativeNumber(options.incidentTimestampMs, "incident_timestamp_ms");
1928
+ const maxHops = clampInt(options.maxHops, SCAM_TOPOLOGY_DEFAULT_MAX_HOPS, 1, SCAM_TOPOLOGY_MAX_HOPS);
1929
+ const perAddressLimit = SCAM_TOPOLOGY_FRONTIER_LIMIT;
1930
+ const minAmountSum = void 0;
1931
+ const activityPolicyMode = validateActivityPolicyMode(options.activityPolicyMode);
1932
+ const primaryActivityPolicy = activityPolicyForMode(activityPolicyMode);
1933
+ const caseId = options.caseId ?? legacyOptions.caseId;
1934
+ if (!network) throw new Error("network is required");
1935
+ if (legacyOptions.scope !== void 0) throw new Error("scope is no longer accepted; scam_topology always runs the victim incident topology");
1936
+ if (legacyOptions.sinceTimestampMs !== void 0) throw new Error("since_timestamp_ms is no longer accepted; use incident_timestamp_ms");
1937
+ if (legacyOptions.perAddressLimit !== void 0) throw new Error("per_address_limit is no longer accepted; scam_topology uses its internal bounded frontier");
1938
+ if (legacyOptions.minAmountSum !== void 0) throw new Error("min_amount_sum is no longer accepted; scam_topology does not amount-filter scam topology expansion");
1939
+ if (scammerAddresses.length > 0) throw new Error("scammer_addresses is no longer accepted; scam_topology starts from a victim incident");
1940
+ if (victimAddresses.length === 0) throw new Error("victim_address is required");
1941
+ if (victimAddresses.length !== 1) throw new Error("victim_address must contain exactly one address");
1942
+ if (incidentTimestampMs === void 0) throw new Error("incident_timestamp_ms is required");
1943
+ const victimAddress = victimAddresses[0];
1944
+ const seeds = [{
1945
+ address: victimAddress,
1946
+ role: "victim"
1947
+ }];
1948
+ const primaryRunWithClusters = await expandDepositClusters(remoteClient, network, await runDirectedTraversal(remoteClient, network, seeds, "incident", primaryActivityPolicy, maxHops, perAddressLimit, minAmountSum, incidentTimestampMs), minAmountSum);
1949
+ const runs = [primaryRunWithClusters];
1950
+ const topologyEdges = primaryRunWithClusters.edges;
1951
+ const classification = classifyTopology(seeds, topologyEdges);
1952
+ const labelCandidates = classification.labelCandidates;
1953
+ const scamLabels = makeScamLabels(labelCandidates, victimAddress, incidentTimestampMs);
1954
+ const facts = {
1955
+ network,
1956
+ victim_address: victimAddress,
1957
+ incident_timestamp_ms: incidentTimestampMs,
1958
+ topology_graphs: ["live_topology"],
1959
+ primary_activity_policy: primaryActivityPolicy,
1960
+ activity_policy_mode: activityPolicyMode,
1961
+ topology_edges: topologyEdges,
1962
+ intermediaries: classification.intermediaries,
1963
+ terminal_points: classification.terminalPoints,
1964
+ exchange_deposits: classification.exchangeDeposits,
1965
+ investigation_hints: classification.investigationHints,
1966
+ scam_labels: scamLabels,
1967
+ label_candidates: labelCandidates,
1968
+ case_roles: classification.caseRoles,
1969
+ safety_decisions: classification.safetyDecisions,
1970
+ infrastructure_anchors: [],
1971
+ infrastructure_flows: [],
1972
+ runs: runs.map((run) => ({
1973
+ graph_scope: run.graphScope,
1974
+ topology_graph: run.topologyGraph,
1975
+ activity_policy: run.activityPolicy,
1976
+ edge_count: run.edges.length,
1977
+ primary: run.activityPolicy === primaryActivityPolicy,
1978
+ max_hops: maxHops,
1979
+ frontier_limit: perAddressLimit,
1980
+ frontier_source_limit_per_hop: SCAM_TOPOLOGY_MAX_FRONTIER_SOURCES_PER_HOP,
1981
+ skipped_query_errors: run.skippedQueryErrors
1982
+ }))
1983
+ };
1984
+ const graphData = buildGraph(seeds, topologyEdges, classification.rolesByAddress, facts);
1985
+ const summaryText = summarize(network, victimAddress, incidentTimestampMs, labelCandidates, scamLabels, classification.safetyDecisions, topologyEdges, classification.terminalPoints);
1986
+ if (caseId) {
1987
+ const files = await writeScamTopologyCaseArtifacts(facts, graphData);
1988
+ const { EvidenceStore } = await Promise.resolve().then(() => require("./cases-CDcNU91B.cjs"));
1989
+ await EvidenceStore.append(caseId, {
1990
+ source: "scam_topology",
1991
+ queryParams: [
1992
+ `network=${network}`,
1993
+ `victim_address=${victimAddress}`,
1994
+ `incident_timestamp_ms=${incidentTimestampMs}`,
1995
+ `max_hops=${maxHops}`,
1996
+ `activity_policy=${activityPolicyMode}`
1997
+ ].filter(Boolean).join(" "),
1998
+ content: JSON.stringify({
1999
+ schema: "chain-insights.evidence_pointer.v1",
2000
+ source: "scam_topology",
2001
+ network,
2002
+ victim_address: victimAddress,
2003
+ incident_timestamp_ms: incidentTimestampMs,
2004
+ topology_graphs: facts.topology_graphs,
2005
+ primary_activity_policy: primaryActivityPolicy,
2006
+ activity_policy_mode: activityPolicyMode,
2007
+ files,
2008
+ facts: {
2009
+ topology_edges: topologyEdges.length,
2010
+ terminal_points: classification.terminalPoints.length,
2011
+ exchange_deposits: classification.exchangeDeposits.length,
2012
+ scam_labels: scamLabels.length,
2013
+ label_candidates: labelCandidates.length,
2014
+ safety_decisions: classification.safetyDecisions.length
2015
+ }
2016
+ }, null, 2)
2017
+ });
2018
+ }
2019
+ return {
2020
+ summaryText,
2021
+ structuredContent: {
2022
+ schema: "chain-insights.result.v1",
2023
+ tool: "scam_topology",
2024
+ facts,
2025
+ hint: "Use scam_labels as ML-ready scam flags. Review label_candidates and safety_decisions before promoting addresses into core_address_labels."
2026
+ },
2027
+ graphData
2028
+ };
2029
+ }
2030
+ //#endregion
2031
+ //#region src/investigation/public-tools.ts
2032
+ const GRAPH_QUERY_BATCH_TIMEOUT_SECONDS = 120;
2033
+ const GRAPH_QUERY_BATCH_REQUEST_TIMEOUT_MS = 300 * 1e3;
2034
+ function escapeCypherString(value) {
2035
+ return value.replaceAll("\\", "\\\\").replaceAll("\"", "\\\"");
2036
+ }
2037
+ function textFromToolResult(result) {
2038
+ return (result.content ?? []).filter((item) => item.type === "text").map((item) => item.text).join("\n");
2039
+ }
2040
+ function parseGraphBatchResult(result) {
2041
+ const text = textFromToolResult(result).trim();
2042
+ if (!text) throw new Error("graph_query_batch returned no text content");
2043
+ const parsed = JSON.parse(text);
2044
+ if (!parsed.facts?.queries) throw new Error("graph_query_batch response did not include facts.queries");
2045
+ return parsed;
2046
+ }
2047
+ function topologyGraphQuery(query) {
2048
+ const trimmed = query.trim();
2049
+ if (/^USE\s+/i.test(trimmed)) return trimmed;
2050
+ return `USE live_topology ${trimmed}`;
2051
+ }
2052
+ function collectQueryFailure(failures, id, error) {
2053
+ failures.push({
2054
+ id,
2055
+ error: error || "unknown error"
2056
+ });
2057
+ }
2058
+ function optionalResultsFor(batch, id, failures) {
2059
+ const query = batch.facts?.queries?.find((entry) => entry.id === id);
2060
+ if (!query) return [];
2061
+ if (query.ok === false) {
2062
+ collectQueryFailure(failures, id, query.error);
2063
+ return [];
2064
+ }
2065
+ return query.results ?? [];
2066
+ }
2067
+ function optionalResultsWithPrefix(batch, prefix, failures) {
2068
+ return (batch.facts?.queries ?? []).filter((entry) => entry.id?.startsWith(prefix)).flatMap((entry) => {
2069
+ if (entry.ok === false) {
2070
+ collectQueryFailure(failures, entry.id ?? prefix, entry.error);
2071
+ return [];
2072
+ }
2073
+ return entry.results ?? [];
2074
+ });
2075
+ }
2076
+ async function callGraphBatch(remoteClient, network, queries) {
2077
+ const result = await remoteClient.callTool({
2078
+ name: "graph_query_batch",
2079
+ arguments: {
2080
+ network,
2081
+ queries: queries.map((query) => ({
2082
+ ...query,
2083
+ query: topologyGraphQuery(query.query)
2084
+ })),
2085
+ per_query_timeout_seconds: GRAPH_QUERY_BATCH_TIMEOUT_SECONDS
2086
+ }
2087
+ }, void 0, {
2088
+ timeout: GRAPH_QUERY_BATCH_REQUEST_TIMEOUT_MS,
2089
+ maxTotalTimeout: GRAPH_QUERY_BATCH_REQUEST_TIMEOUT_MS
2090
+ });
2091
+ if (result.isError) throw new Error(textFromToolResult(result) || "graph_query_batch failed");
2092
+ return parseGraphBatchResult(result);
2093
+ }
2094
+ function parseAddressList(value) {
2095
+ return (Array.isArray(value) ? value.join(",") : value ?? "").split(",").map((entry) => entry.trim()).filter(Boolean);
2096
+ }
2097
+ function graphArray(graphData, key) {
2098
+ const value = graphData[key];
2099
+ return Array.isArray(value) ? value.filter((item) => typeof item === "object" && item !== null && !Array.isArray(item)) : [];
2100
+ }
2101
+ function addressProfileQuery(address) {
2102
+ return {
2103
+ id: "address_profile",
2104
+ query: [
2105
+ `MATCH (a:Address {address: "${escapeCypherString(address)}"})`,
2106
+ "RETURN a.address AS address, a.labels AS display_labels, a.labels AS system_labels, a.address_type AS address_type, a.address_subtypes AS address_subtypes, a.is_exchange AS is_exchange, a.confluence_score AS confluence_score, a.ml_risk_score AS ml_risk_score, a.ml_risk_level AS ml_risk_level, a.ml_top_drivers AS ml_top_drivers, a.ml_pattern_summary AS ml_pattern_summary, a.risk_score AS risk_score, a.risk_level AS risk_level, a.pattern_flags AS pattern_flags, a.ml_pagerank AS ml_pagerank, a.ml_betweenness AS ml_betweenness, a.ml_community_id AS ml_community_id",
2107
+ "LIMIT 1"
2108
+ ].join(" ")
2109
+ };
2110
+ }
2111
+ function addressFeatureQuery(address) {
2112
+ return {
2113
+ id: "address_feature",
2114
+ query: [
2115
+ "USE facts",
2116
+ `MATCH (a:Address {address: "${escapeCypherString(address)}"})-[:HAS_FEATURE]->(feature:AddressFeature)`,
2117
+ "RETURN feature.degree_in AS degree_in, feature.degree_out AS degree_out, feature.degree_total AS degree_total, feature.tx_in_count AS tx_in_count, feature.tx_out_count AS tx_out_count, feature.tx_total_count AS tx_total_count, feature.total_volume_usd AS total_volume_usd, feature.total_in_usd AS total_in_usd, feature.total_out_usd AS total_out_usd, feature.net_flow_usd AS net_flow_usd, feature.first_activity_timestamp AS first_activity_timestamp, feature.last_activity_timestamp AS last_activity_timestamp, feature.activity_span_days AS activity_span_days, feature.active_days AS active_days",
2118
+ "LIMIT 1"
2119
+ ].join(" ")
2120
+ };
2121
+ }
2122
+ function addressRiskScoreQuery(address) {
2123
+ return {
2124
+ id: "address_risk_score",
2125
+ query: [
2126
+ "USE facts",
2127
+ `MATCH (a:Address {address: "${escapeCypherString(address)}"})-[:HAS_RISK_SCORE]->(risk:RiskScore)`,
2128
+ "RETURN risk.risk_score AS risk_score, risk.window_days AS risk_window_days, risk.processing_date AS risk_processing_date, risk.shap_top_features AS shap_top_features",
2129
+ "LIMIT 1"
2130
+ ].join(" ")
2131
+ };
2132
+ }
2133
+ function flowEdgeMap(variableName) {
2134
+ 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}`;
2135
+ }
2136
+ function pathNodeMap(variableName) {
2137
+ return `{address: ${variableName}.address, labels: ${variableName}.labels, system_labels: ${variableName}.labels, address_type: ${variableName}.address_type, address_subtypes: ${variableName}.address_subtypes}`;
2138
+ }
2139
+ function exchangeOutflowQueries(address) {
2140
+ return Array.from({ length: 3 }, (_, index) => exchangeOutflowQueryAtDepth(address, index + 1));
2141
+ }
2142
+ function exchangeOutflowQueryAtDepth(address, depth) {
2143
+ const intermediateVariables = Array.from({ length: Math.max(depth - 1, 0) }, (_, index) => `n${index + 1}`);
2144
+ const nodeVariables = [
2145
+ "a",
2146
+ ...intermediateVariables,
2147
+ "exchange"
2148
+ ];
2149
+ const edgeVariables = Array.from({ length: depth }, (_, index) => `r${index + 1}`);
2150
+ const relationshipChain = edgeVariables.map((edgeVariable, index) => {
2151
+ return `-[${edgeVariable}:FLOWS_TO]->(${index === edgeVariables.length - 1 ? "exchange" : intermediateVariables[index]}:Address)`;
2152
+ }).join("");
2153
+ const intermediatePredicates = intermediateVariables.map((nodeVariable) => `${nodeVariable}.is_exchange IS NULL`);
2154
+ const depositVariable = nodeVariables[nodeVariables.length - 2];
2155
+ const terminalEdgeVariable = edgeVariables[edgeVariables.length - 1];
2156
+ return {
2157
+ id: `exchange_outflows_${depth}`,
2158
+ query: [
2159
+ `MATCH (a:Address {address: "${escapeCypherString(address)}"})${relationshipChain}`,
2160
+ `WHERE a <> exchange AND exchange.is_exchange IS NOT NULL${intermediatePredicates.length > 0 ? ` AND ${intermediatePredicates.join(" AND ")}` : ""}`,
2161
+ `RETURN "outflow" AS direction, exchange.address AS exchange_address, exchange.labels AS exchange_display_labels, exchange.labels AS exchange_system_labels, exchange.address_type AS exchange_address_type, exchange.address_subtypes AS exchange_address_subtypes, ${depositVariable}.address AS deposit_address, ${depth} AS hops, ${terminalEdgeVariable}.amount_sum AS amount_sum, ${terminalEdgeVariable}.amount_usd_sum AS amount_usd_sum, ${terminalEdgeVariable}.tx_count AS tx_count, [${nodeVariables.map((nodeVariable) => `${nodeVariable}.address`).join(", ")}] AS addresses, [${nodeVariables.map(pathNodeMap).join(", ")}] AS path_nodes, [${edgeVariables.map(flowEdgeMap).join(", ")}] AS edge_props`,
2162
+ "ORDER BY hops ASC",
2163
+ "LIMIT 200"
2164
+ ].join(" ")
2165
+ };
2166
+ }
2167
+ function exchangeInflowQueries(address) {
2168
+ return Array.from({ length: 3 }, (_, index) => exchangeInflowQueryAtDepth(address, index + 1));
2169
+ }
2170
+ function exchangeInflowQueryAtDepth(address, depth) {
2171
+ const intermediateVariables = Array.from({ length: Math.max(depth - 1, 0) }, (_, index) => `n${index + 1}`);
2172
+ const nodeVariables = [
2173
+ "exchange",
2174
+ ...intermediateVariables,
2175
+ "a"
2176
+ ];
2177
+ const edgeVariables = Array.from({ length: depth }, (_, index) => `r${index + 1}`);
2178
+ const relationshipChain = edgeVariables.map((edgeVariable, index) => {
2179
+ return `-[${edgeVariable}:FLOWS_TO]->(${index === edgeVariables.length - 1 ? "a" : intermediateVariables[index]}:Address)`;
2180
+ }).join("");
2181
+ const intermediatePredicates = intermediateVariables.map((nodeVariable) => `${nodeVariable}.is_exchange IS NULL`);
2182
+ const withdrawalVariable = nodeVariables[1];
2183
+ const terminalEdgeVariable = edgeVariables[edgeVariables.length - 1];
2184
+ return {
2185
+ id: `exchange_inflows_${depth}`,
2186
+ query: [
2187
+ `MATCH (exchange:Address)${relationshipChain}`,
2188
+ `WHERE a.address = "${escapeCypherString(address)}" AND a <> exchange AND exchange.is_exchange IS NOT NULL${intermediatePredicates.length > 0 ? ` AND ${intermediatePredicates.join(" AND ")}` : ""}`,
2189
+ `RETURN "inflow" AS direction, exchange.address AS exchange_address, exchange.labels AS exchange_display_labels, exchange.labels AS exchange_system_labels, exchange.address_type AS exchange_address_type, exchange.address_subtypes AS exchange_address_subtypes, ${withdrawalVariable}.address AS withdrawal_address, ${depth} AS hops, ${terminalEdgeVariable}.amount_sum AS amount_sum, ${terminalEdgeVariable}.amount_usd_sum AS amount_usd_sum, ${terminalEdgeVariable}.tx_count AS tx_count, [${nodeVariables.map((nodeVariable) => `${nodeVariable}.address`).join(", ")}] AS addresses, [${nodeVariables.map(pathNodeMap).join(", ")}] AS path_nodes, [${edgeVariables.map(flowEdgeMap).join(", ")}] AS edge_props`,
2190
+ "ORDER BY hops ASC",
2191
+ "LIMIT 200"
2192
+ ].join(" ")
2193
+ };
2194
+ }
2195
+ function connectionProbeQuery(address, compareAddress) {
2196
+ return {
2197
+ id: "connection_probe",
2198
+ query: [
2199
+ `MATCH (a:Address {address: "${escapeCypherString(address)}"})-[r:FLOWS_TO]-(b:Address {address: "${escapeCypherString(compareAddress)}"})`,
2200
+ "RETURN [a.address, b.address] AS addresses, 1 AS hops",
2201
+ "LIMIT 5"
2202
+ ].join(" ")
2203
+ };
2204
+ }
2205
+ function formatExchangeRows(rows) {
2206
+ return rows.map((row) => {
2207
+ const direction = String(row["direction"] ?? "flow");
2208
+ const exchange = String(row["exchange_address"] ?? "");
2209
+ const amount = row["amount_sum"] ?? row["amount_usd_sum"] ?? "";
2210
+ return `- ${direction}: ${exchange} (${row["hops"] ?? ""} hop(s), amount ${amount})`;
2211
+ });
2212
+ }
2213
+ function numberValue(value) {
2214
+ if (typeof value === "number" && Number.isFinite(value)) return value;
2215
+ if (typeof value === "string" && value.trim()) {
2216
+ const parsed = Number(value);
2217
+ return Number.isFinite(parsed) ? parsed : void 0;
2218
+ }
2219
+ }
2220
+ function firstNumber(...values) {
2221
+ for (const value of values) {
2222
+ const parsed = numberValue(value);
2223
+ if (parsed !== void 0) return parsed;
2224
+ }
2225
+ }
2226
+ function firstString(...values) {
2227
+ for (const value of values) if (typeof value === "string" && value.trim()) return value.trim();
2228
+ }
2229
+ function riskLevelFromScore(score) {
2230
+ if (score >= .85) return "critical";
2231
+ if (score >= .7) return "high";
2232
+ if (score >= .4) return "medium";
2233
+ return "low";
2234
+ }
2235
+ function riskRecommendation(level) {
2236
+ if (level === "critical" || level === "high") return "Escalate for manual review.";
2237
+ if (level === "medium") return "Review exchange exposure and counterparties before clearing.";
2238
+ return "No stored risk signal found; continue with normal monitoring.";
2239
+ }
2240
+ function riskDrivers(profile, exchangeRows) {
2241
+ const drivers = [];
2242
+ const storedDrivers = stringArrayValue(profile["ml_top_drivers"]);
2243
+ if (storedDrivers?.length) drivers.push(...storedDrivers);
2244
+ const patternFlags = stringArrayValue(profile["pattern_flags"]);
2245
+ if (patternFlags?.length) drivers.push(`Pattern flags: ${patternFlags.join(", ")}`);
2246
+ const outflowCount = exchangeRows.filter((row) => row["direction"] === "outflow").length;
2247
+ const inflowCount = exchangeRows.filter((row) => row["direction"] === "inflow").length;
2248
+ if (outflowCount > 0) drivers.push(`Forward bounded search reached ${outflowCount} exchange path(s).`);
2249
+ if (inflowCount > 0) drivers.push(`Backward bounded search found ${inflowCount} source exchange path(s).`);
2250
+ return [...new Set(drivers)];
2251
+ }
2252
+ function terminalEdgeProperties(row) {
2253
+ const edgeProps = Array.isArray(row["edge_props"]) ? row["edge_props"] : [];
2254
+ return edgeProps[edgeProps.length - 1];
2255
+ }
2256
+ function enrichExchangeRows(rows) {
2257
+ return rows.map((row) => {
2258
+ const terminal = terminalEdgeProperties(row);
2259
+ if (!terminal) return row;
2260
+ return {
2261
+ ...row,
2262
+ amount_sum: row["amount_sum"] ?? terminal["amount_sum"],
2263
+ amount_usd_sum: row["amount_usd_sum"] ?? terminal["amount_usd_sum"],
2264
+ tx_count: row["tx_count"] ?? terminal["tx_count"],
2265
+ first_tx_id: row["first_tx_id"] ?? terminal["first_tx_id"],
2266
+ last_tx_id: row["last_tx_id"] ?? terminal["last_tx_id"]
2267
+ };
2268
+ });
2269
+ }
2270
+ function riskAssessment(profile, exchangeRows) {
2271
+ const storedScore = firstNumber(profile["confluence_score"], profile["ml_risk_score"], profile["risk_score"]);
2272
+ const score = storedScore ?? (exchangeRows.length > 0 ? .4 : 0);
2273
+ const level = firstString(profile["ml_risk_level"], profile["risk_level"]) ?? riskLevelFromScore(score);
2274
+ const drivers = riskDrivers(profile, exchangeRows);
2275
+ return {
2276
+ level,
2277
+ score,
2278
+ confidence: storedScore !== void 0 || firstString(profile["ml_risk_level"], profile["risk_level"]) ? "high" : exchangeRows.length > 0 ? "medium" : "low",
2279
+ recommendation: riskRecommendation(level),
2280
+ drivers
2281
+ };
2282
+ }
2283
+ function formatRiskScore(score) {
2284
+ const parsed = numberValue(score);
2285
+ if (parsed === void 0) return String(score ?? "unknown");
2286
+ return Number.isInteger(parsed) ? parsed.toString() : parsed.toFixed(2);
2287
+ }
2288
+ function stringArrayValue(value) {
2289
+ if (Array.isArray(value)) return value.map(String);
2290
+ if (typeof value === "string" && value.trim()) return [value];
2291
+ }
2292
+ function restoreSystemLabels(graph, rawNodes) {
2293
+ if (!Array.isArray(graph["nodes"])) return graph;
2294
+ const labelsByAddress = new Map(rawNodes.map((node) => [typeof node["address"] === "string" ? node["address"] : typeof node["id"] === "string" ? node["id"] : "", stringArrayValue(node["system_labels"])]).filter((entry) => Boolean(entry[0]) && Array.isArray(entry[1]) && entry[1].length > 0));
2295
+ return {
2296
+ ...graph,
2297
+ nodes: graph["nodes"].map((node) => {
2298
+ if (typeof node !== "object" || node === null || Array.isArray(node)) return node;
2299
+ const record = node;
2300
+ const address = typeof record["address"] === "string" ? record["address"] : typeof record["id"] === "string" ? record["id"] : "";
2301
+ const systemLabels = labelsByAddress.get(address);
2302
+ return systemLabels ? {
2303
+ ...record,
2304
+ system_labels: systemLabels
2305
+ } : record;
2306
+ })
2307
+ };
2308
+ }
2309
+ function buildRiskGraph(address, profile, rows, network) {
2310
+ const nodes = /* @__PURE__ */ new Map();
2311
+ nodes.set(address, {
2312
+ id: address,
2313
+ address,
2314
+ node_type: "address",
2315
+ labels: stringArrayValue(profile["display_labels"]) ?? [],
2316
+ ...stringArrayValue(profile["system_labels"]) ? { system_labels: stringArrayValue(profile["system_labels"]) } : {},
2317
+ ...typeof profile["address_type"] === "string" ? { address_type: profile["address_type"] } : {},
2318
+ ...stringArrayValue(profile["address_subtypes"]) ? { address_subtypes: stringArrayValue(profile["address_subtypes"]) } : {},
2319
+ roles: ["subject"]
2320
+ });
2321
+ const edges = [];
2322
+ const mergeNode = (entry, metadata) => {
2323
+ const existing = nodes.get(entry) ?? {
2324
+ id: entry,
2325
+ address: entry,
2326
+ node_type: "address",
2327
+ labels: []
2328
+ };
2329
+ const labels = stringArrayValue(metadata?.["labels"]) ?? existing["labels"];
2330
+ const systemLabels = stringArrayValue(metadata?.["system_labels"]) ?? existing["system_labels"];
2331
+ const addressType = typeof metadata?.["address_type"] === "string" ? metadata["address_type"] : existing["address_type"];
2332
+ const addressSubtypes = stringArrayValue(metadata?.["address_subtypes"]) ?? existing["address_subtypes"];
2333
+ nodes.set(entry, {
2334
+ ...existing,
2335
+ labels,
2336
+ ...systemLabels ? { system_labels: systemLabels } : {},
2337
+ ...addressType ? { address_type: addressType } : {},
2338
+ ...addressSubtypes ? { address_subtypes: addressSubtypes } : {}
2339
+ });
2340
+ };
2341
+ for (const row of rows) {
2342
+ const rawPath = Array.isArray(row["path"]) ? row["path"] : row["addresses"];
2343
+ const path = Array.isArray(rawPath) ? rawPath.map(String) : [];
2344
+ const pathNodes = Array.isArray(row["path_nodes"]) ? row["path_nodes"] : [];
2345
+ for (let index = 0; index < path.length; index += 1) {
2346
+ const entry = path[index];
2347
+ mergeNode(entry, pathNodes[index]);
2348
+ }
2349
+ const exchange = typeof row["exchange_address"] === "string" ? row["exchange_address"] : "";
2350
+ if (exchange) {
2351
+ const displayLabels = stringArrayValue(row["exchange_display_labels"]) ?? [];
2352
+ const systemLabels = stringArrayValue(row["exchange_system_labels"]) ?? stringArrayValue(row["exchange_labels"]) ?? [];
2353
+ nodes.set(exchange, {
2354
+ id: exchange,
2355
+ address: exchange,
2356
+ node_type: "address",
2357
+ labels: displayLabels,
2358
+ ...systemLabels.length > 0 ? { system_labels: systemLabels } : {},
2359
+ ...typeof row["exchange_address_type"] === "string" ? { address_type: row["exchange_address_type"] } : {},
2360
+ ...stringArrayValue(row["exchange_address_subtypes"]) ? { address_subtypes: stringArrayValue(row["exchange_address_subtypes"]) } : {},
2361
+ roles: ["exchange"]
2362
+ });
2363
+ }
2364
+ for (let index = 0; index < path.length - 1; index += 1) {
2365
+ const edge = (Array.isArray(row["edge_props"]) ? row["edge_props"] : [])[index] ?? row;
2366
+ edges.push({
2367
+ source: path[index],
2368
+ target: path[index + 1],
2369
+ edge_type: "flows_to",
2370
+ usd_amount: edge["amount_usd_sum"] ?? edge["amount_sum"] ?? 0,
2371
+ amount_sum: edge["amount_sum"] ?? 0,
2372
+ tx_count: edge["tx_count"] ?? 0,
2373
+ first_tx_id: edge["first_tx_id"],
2374
+ last_tx_id: edge["last_tx_id"],
2375
+ direction: row["direction"]
2376
+ });
2377
+ }
2378
+ }
2379
+ const rawNodes = [...nodes.values()];
2380
+ return restoreSystemLabels(require_graph_normalizer.normalizeGraphPayload({
2381
+ schema: "chain-insights.graph.v1",
2382
+ nodes: rawNodes,
2383
+ edges,
2384
+ flows: [],
2385
+ edge_anchors: [],
2386
+ metadata: {
2387
+ address,
2388
+ network,
2389
+ generated_at: (/* @__PURE__ */ new Date()).toISOString()
2390
+ }
2391
+ }), rawNodes);
2392
+ }
2393
+ async function addressRisk(remoteClient, options) {
2394
+ const address = options.address.trim();
2395
+ const network = options.network.trim();
2396
+ const compareAddress = options.compareAddress?.trim() ?? "";
2397
+ if (!address) throw new Error("address is required");
2398
+ if (!network) throw new Error("network is required");
2399
+ const batch = await callGraphBatch(remoteClient, network, [
2400
+ addressProfileQuery(address),
2401
+ addressFeatureQuery(address),
2402
+ addressRiskScoreQuery(address),
2403
+ ...exchangeOutflowQueries(address),
2404
+ ...exchangeInflowQueries(address),
2405
+ ...compareAddress ? [connectionProbeQuery(address, compareAddress)] : [{
2406
+ id: "connection_probe",
2407
+ query: "MATCH (n:Address {address: \"__chain_insights_noop__\"}) RETURN n.address AS noop LIMIT 0"
2408
+ }]
2409
+ ]);
2410
+ const partialQueryFailures = [];
2411
+ const profile = {
2412
+ address,
2413
+ ...optionalResultsFor(batch, "address_profile", partialQueryFailures)[0] ?? {},
2414
+ ...optionalResultsFor(batch, "address_feature", partialQueryFailures)[0] ?? {},
2415
+ ...optionalResultsFor(batch, "address_risk_score", partialQueryFailures)[0] ?? {}
2416
+ };
2417
+ const outflows = enrichExchangeRows(optionalResultsWithPrefix(batch, "exchange_outflows_", partialQueryFailures));
2418
+ const inflows = enrichExchangeRows(optionalResultsWithPrefix(batch, "exchange_inflows_", partialQueryFailures));
2419
+ const connections = compareAddress ? optionalResultsFor(batch, "connection_probe", partialQueryFailures) : [];
2420
+ const exchangeRows = [...outflows, ...inflows];
2421
+ const graphData = buildRiskGraph(address, profile, exchangeRows, network);
2422
+ const risk = riskAssessment(profile, exchangeRows);
2423
+ const lines = [
2424
+ `Address risk for ${network}:${address}`,
2425
+ "",
2426
+ `Risk: ${risk["level"]} (${formatRiskScore(risk["score"])})`,
2427
+ `Confidence: ${risk["confidence"]}`,
2428
+ `Recommendation: ${risk["recommendation"]}`,
2429
+ `Graph degree: in ${profile["degree_in"] ?? "unknown"}, out ${profile["degree_out"] ?? "unknown"}.`,
2430
+ "",
2431
+ "Exchange behavior",
2432
+ exchangeRows.length > 0 ? formatExchangeRows(exchangeRows).join("\n") : "- No exchange inflow/outflow paths found in bounded search."
2433
+ ];
2434
+ if (Array.isArray(risk["drivers"]) && risk["drivers"].length > 0) lines.push("", "Risk drivers", risk["drivers"].map((driver) => `- ${driver}`).join("\n"));
2435
+ if (compareAddress) lines.push("", `Connection compare target: ${compareAddress}`, connections.length > 0 ? `Connection paths found: ${connections.length}` : "Connection paths found: 0");
2436
+ if (partialQueryFailures.length > 0) lines.push("", "Partial query failures", partialQueryFailures.map((failure) => `- ${failure.id}: ${failure.error}`).join("\n"));
2437
+ return {
2438
+ summaryText: lines.join("\n"),
2439
+ structuredContent: {
2440
+ schema: "chain-insights.result.v1",
2441
+ tool: "address_risk",
2442
+ facts: {
2443
+ subject: {
2444
+ network,
2445
+ addresses: compareAddress ? [address, compareAddress] : [address]
2446
+ },
2447
+ risk,
2448
+ exchange_behavior: {
2449
+ outflows,
2450
+ inflows
2451
+ },
2452
+ connection: compareAddress ? {
2453
+ compare_address: compareAddress,
2454
+ paths: connections
2455
+ } : void 0,
2456
+ partial_query_errors: partialQueryFailures.length > 0 ? partialQueryFailures : void 0
2457
+ }
2458
+ },
2459
+ graphData
2460
+ };
2461
+ }
2462
+ async function trackFunds(remoteClient, config, options) {
2463
+ const network = options.network.trim();
2464
+ const trusted = parseAddressList(options.trustedAddresses);
2465
+ const untrusted = parseAddressList(options.untrustedAddresses);
2466
+ if (!network) throw new Error("network is required");
2467
+ if (trusted.length < 1) throw new Error("trusted_addresses must contain at least 1 address");
2468
+ if (trusted.length > 5) throw new Error("trusted_addresses cannot exceed 5 addresses");
2469
+ if (untrusted.length > 5) throw new Error("untrusted_addresses cannot exceed 5 addresses");
2470
+ const overlap = trusted.filter((address) => untrusted.includes(address));
2471
+ if (overlap.length > 0) throw new Error(`Address(es) appear in both trusted and untrusted lists: ${overlap.join(", ")}`);
2472
+ const runs = [];
2473
+ for (const address of trusted) runs.push({
2474
+ role: "trusted",
2475
+ address,
2476
+ result: await runFundFlowProbe(remoteClient, config, {
2477
+ seedAddress: address,
2478
+ network,
2479
+ caseId: options.caseId,
2480
+ maxHops: options.maxHops,
2481
+ perAddressLimit: options.perAddressLimit,
2482
+ minAmountSum: options.minAmountSum
2483
+ })
2484
+ });
2485
+ for (const address of untrusted) runs.push({
2486
+ role: "untrusted",
2487
+ address,
2488
+ result: await runFundFlowProbe(remoteClient, config, {
2489
+ seedAddress: address,
2490
+ network,
2491
+ caseId: options.caseId,
2492
+ maxHops: options.maxHops,
2493
+ perAddressLimit: options.perAddressLimit,
2494
+ minAmountSum: options.minAmountSum
2495
+ })
2496
+ });
2497
+ const graphData = require_graph_normalizer.normalizeGraphPayload({
2498
+ schema: "chain-insights.graph.v1",
2499
+ nodes: runs.flatMap((run) => Array.isArray(run.result.graphData.nodes) ? run.result.graphData.nodes : []),
2500
+ edges: runs.flatMap((run) => Array.isArray(run.result.graphData.edges) ? run.result.graphData.edges : []),
2501
+ flows: runs.flatMap((run) => Array.isArray(run.result.graphData.flows) ? run.result.graphData.flows : []),
2502
+ deposits: runs.flatMap((run) => graphArray(run.result.graphData, "deposits").map((item) => ({
2503
+ ...item,
2504
+ run_role: run.role,
2505
+ run_address: run.address
2506
+ }))),
2507
+ source_matches: runs.flatMap((run) => graphArray(run.result.graphData, "source_matches").map((item) => ({
2508
+ ...item,
2509
+ run_role: run.role,
2510
+ run_address: run.address
2511
+ }))),
2512
+ reverse_leads: runs.flatMap((run) => graphArray(run.result.graphData, "reverse_leads").map((item) => ({
2513
+ ...item,
2514
+ run_role: run.role,
2515
+ run_address: run.address
2516
+ }))),
2517
+ edge_anchors: [],
2518
+ metadata: {
2519
+ network,
2520
+ trusted_addresses: trusted,
2521
+ untrusted_addresses: untrusted,
2522
+ generated_at: (/* @__PURE__ */ new Date()).toISOString()
2523
+ }
2524
+ });
2525
+ return {
2526
+ summaryText: [
2527
+ `Track funds complete for ${network}`,
2528
+ "",
2529
+ `Trusted addresses: ${trusted.join(", ")}`,
2530
+ `Untrusted addresses: ${untrusted.join(", ") || "none"}`,
2531
+ "",
2532
+ ...runs.map((run) => `## ${run.role}: ${run.address}\n${run.result.summaryText}`)
2533
+ ].join("\n"),
2534
+ structuredContent: {
2535
+ schema: "chain-insights.result.v1",
2536
+ tool: "track_funds",
2537
+ facts: {
2538
+ network,
2539
+ trusted_addresses: trusted,
2540
+ untrusted_addresses: untrusted,
2541
+ runs: runs.map((run) => ({
2542
+ role: run.role,
2543
+ address: run.address,
2544
+ files: run.result.files,
2545
+ continuation: run.result.continuation,
2546
+ address_map: run.result.addressMap
2547
+ }))
2548
+ }
2549
+ },
2550
+ graphData
2551
+ };
2552
+ }
2553
+ //#endregion
2554
+ exports.addressRisk = addressRisk;
2555
+ exports.scamTopology = scamTopology;
2556
+ exports.trackFunds = trackFunds;