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