chain-insights 0.2.18 → 0.2.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +54 -12
- package/bin/cli.js +2 -3
- package/bin/install.cjs +0 -1
- package/dist/{app-DxlQE_P5.cjs → app-BxojXjtB.cjs} +1 -1
- package/dist/{app-DdWQF_zb.mjs → app-CRd39JJ8.mjs} +2 -2
- package/dist/{app-DdWQF_zb.mjs.map → app-CRd39JJ8.mjs.map} +1 -1
- package/dist/{artifact-server-4DiMvwhC.mjs → artifact-server-CP6LXQ9d.mjs} +2 -2
- package/dist/{artifact-server-4DiMvwhC.mjs.map → artifact-server-CP6LXQ9d.mjs.map} +1 -1
- package/dist/{artifact-server-B-3ho4bk.cjs → artifact-server-XbN16DwU.cjs} +1 -1
- package/dist/cli.cjs +66 -25
- package/dist/cli.mjs +66 -25
- package/dist/cli.mjs.map +1 -1
- package/dist/{config-BhYbhLDI.cjs → config-BwVx19Og.cjs} +48 -15
- package/dist/config-Drgc2HuF.mjs +77 -0
- package/dist/config-Drgc2HuF.mjs.map +1 -0
- package/dist/frontmatter-D0ccQnUM.mjs.map +1 -1
- package/dist/index.cjs +4 -4
- package/dist/index.d.cts +3 -3
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.mts +3 -3
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +4 -4
- package/dist/{init-CZbZegIW.mjs → init-4tn7jfhN.mjs} +3 -2
- package/dist/init-4tn7jfhN.mjs.map +1 -0
- package/dist/{init-BvpZtFiT.cjs → init-TCQY5RDJ.cjs} +2 -1
- package/dist/mcp-endpoint-BaV8h_lq.cjs +60 -0
- package/dist/mcp-endpoint-DHs1cRFH.mjs +39 -0
- package/dist/mcp-endpoint-DHs1cRFH.mjs.map +1 -0
- package/dist/mcp-proxy.cjs +108 -9
- package/dist/mcp-proxy.d.cts.map +1 -1
- package/dist/mcp-proxy.d.mts.map +1 -1
- package/dist/mcp-proxy.mjs +108 -9
- package/dist/mcp-proxy.mjs.map +1 -1
- package/dist/{public-tools-D6Q5MTcO.mjs → public-tools-B13J0MJZ.mjs} +465 -70
- package/dist/public-tools-B13J0MJZ.mjs.map +1 -0
- package/dist/{public-tools-V7ON7goq.cjs → public-tools-BC1fi0DV.cjs} +464 -68
- package/dist/resolver-D7VBb0uB.mjs.map +1 -1
- package/dist/{runner-BatyCxv7.mjs → runner-DIs04IhN.mjs} +2 -2
- package/dist/{runner-BatyCxv7.mjs.map → runner-DIs04IhN.mjs.map} +1 -1
- package/dist/{runner-CCA7SJ7X.cjs → runner-ZYowxCVl.cjs} +1 -1
- package/dist/schema-BFEWhzg7.mjs +60 -0
- package/dist/schema-BFEWhzg7.mjs.map +1 -0
- package/dist/{schema-DN-KLkYN.cjs → schema-Vl9yuOFO.cjs} +31 -8
- package/dist/{server-BDlbmGbL.mjs → server-BXLX2j_A.mjs} +2 -2
- package/dist/{server-BDlbmGbL.mjs.map → server-BXLX2j_A.mjs.map} +1 -1
- package/dist/{server-C3y1gQmZ.cjs → server-BqVdWath.cjs} +1 -1
- package/dist/{topup-server-6MH7q73X.mjs → topup-server-BJgVw6Jt.mjs} +100 -42
- package/dist/topup-server-BJgVw6Jt.mjs.map +1 -0
- package/dist/{topup-server-DjUjhNjv.cjs → topup-server-yAaXYkJP.cjs} +98 -40
- package/docs/architecture.md +4 -0
- package/docs/contributing.md +1 -0
- package/docs/debugging.md +10 -14
- package/docs/graph-tools.md +60 -2
- package/docs/mcp-proxy.md +44 -0
- package/package.json +2 -2
- package/skills/chain-insights-developer-experience/SKILL.md +4 -2
- package/skills/chain-insights-investigation/SKILL.md +1 -1
- package/skills/test-chain-insights-graphrag-mcp/SKILL.md +4 -5
- package/skills/test-chain-insights-graphrag-mcp/scripts/run-uat.sh +5 -24
- package/dist/config-9KYXaAv-.mjs +0 -44
- package/dist/config-9KYXaAv-.mjs.map +0 -1
- package/dist/init-CZbZegIW.mjs.map +0 -1
- package/dist/public-tools-D6Q5MTcO.mjs.map +0 -1
- package/dist/schema-BbQVXp36.mjs +0 -37
- package/dist/schema-BbQVXp36.mjs.map +0 -1
- package/dist/topup-server-6MH7q73X.mjs.map +0 -1
|
@@ -64,11 +64,11 @@ const SCHEMA_QUERY_SET = [
|
|
|
64
64
|
query: "MATCH (:Address)-[r:FLOWS_TO]->(:Address) RETURN \"amount_sum\" AS property_key, count(r) AS sample_count LIMIT 1"
|
|
65
65
|
}
|
|
66
66
|
];
|
|
67
|
-
function clampInt$
|
|
67
|
+
function clampInt$2(value, fallback, min, max) {
|
|
68
68
|
if (!Number.isFinite(value)) return fallback;
|
|
69
69
|
return Math.max(min, Math.min(max, Math.trunc(value)));
|
|
70
70
|
}
|
|
71
|
-
function escapeCypherString$
|
|
71
|
+
function escapeCypherString$3(value) {
|
|
72
72
|
return value.replaceAll("\\", "\\\\").replaceAll("\"", "\\\"");
|
|
73
73
|
}
|
|
74
74
|
function sanitizeSegment$1(value) {
|
|
@@ -96,11 +96,11 @@ async function ensureDirs(paths) {
|
|
|
96
96
|
mode: 448
|
|
97
97
|
});
|
|
98
98
|
}
|
|
99
|
-
function textFromToolResult$
|
|
99
|
+
function textFromToolResult$3(result) {
|
|
100
100
|
return (result.content ?? []).filter((item) => item.type === "text").map((item) => item.text).join("\n");
|
|
101
101
|
}
|
|
102
|
-
function parseGraphBatchResult$
|
|
103
|
-
const text = textFromToolResult$
|
|
102
|
+
function parseGraphBatchResult$3(result) {
|
|
103
|
+
const text = textFromToolResult$3(result).trim();
|
|
104
104
|
if (!text) throw new Error("graph_query_batch returned no text content");
|
|
105
105
|
const parsed = JSON.parse(text);
|
|
106
106
|
if (!parsed.facts?.queries) throw new Error("graph_query_batch response did not include facts.queries");
|
|
@@ -111,7 +111,7 @@ function topologyGraphQuery$1(query) {
|
|
|
111
111
|
if (/^USE\s+/i.test(trimmed)) return trimmed;
|
|
112
112
|
return `USE live_topology ${trimmed}`;
|
|
113
113
|
}
|
|
114
|
-
async function callGraphBatch$
|
|
114
|
+
async function callGraphBatch$3(remoteClient, network, queries) {
|
|
115
115
|
const result = await remoteClient.callTool({
|
|
116
116
|
name: "graph_query_batch",
|
|
117
117
|
arguments: {
|
|
@@ -126,8 +126,8 @@ async function callGraphBatch$2(remoteClient, network, queries) {
|
|
|
126
126
|
timeout: GRAPH_QUERY_BATCH_REQUEST_TIMEOUT_MS$1,
|
|
127
127
|
maxTotalTimeout: GRAPH_QUERY_BATCH_REQUEST_TIMEOUT_MS$1
|
|
128
128
|
});
|
|
129
|
-
if (result.isError) throw new Error(textFromToolResult$
|
|
130
|
-
return parseGraphBatchResult$
|
|
129
|
+
if (result.isError) throw new Error(textFromToolResult$3(result) || "graph_query_batch failed");
|
|
130
|
+
return parseGraphBatchResult$3(result);
|
|
131
131
|
}
|
|
132
132
|
function resultsFor(batch, id) {
|
|
133
133
|
const query = batch.facts?.queries?.find((entry) => entry.id === id);
|
|
@@ -168,7 +168,7 @@ async function loadOrCaptureTopologySchema(remoteClient, paths, network) {
|
|
|
168
168
|
} catch (err) {
|
|
169
169
|
if (err.code !== "ENOENT") throw err;
|
|
170
170
|
}
|
|
171
|
-
const schema = schemaFromGraphBatch(network, await callGraphBatch$
|
|
171
|
+
const schema = schemaFromGraphBatch(network, await callGraphBatch$3(remoteClient, network, SCHEMA_QUERY_SET));
|
|
172
172
|
await (0, node_fs_promises.writeFile)(filePath, JSON.stringify(schema, null, 2) + "\n", { mode: 384 });
|
|
173
173
|
return {
|
|
174
174
|
schema,
|
|
@@ -206,7 +206,7 @@ function forwardExchangeQueryAtDepth(address, limit, minAmountSum, depth) {
|
|
|
206
206
|
return {
|
|
207
207
|
id: `forward_exchange_paths_${depth}`,
|
|
208
208
|
query: [
|
|
209
|
-
`MATCH (s:Address {address: "${escapeCypherString$
|
|
209
|
+
`MATCH (s:Address {address: "${escapeCypherString$3(address)}"})${relationshipChain}`,
|
|
210
210
|
`WHERE ${predicates.join(" AND ")}`,
|
|
211
211
|
`RETURN [${nodeVariables.map((nodeVariable) => `${nodeVariable}.address`).join(", ")}] AS addresses, [${nodeVariables.map((nodeVariable) => `${nodeVariable}.labels`).join(", ")}] AS node_labels, [${nodeVariables.map(pathNodeMap$1).join(", ")}] AS path_nodes, [${edgeVariables.map(flowEdgeMap$1).join(", ")}] AS edge_props, t.address AS exchange_address, t.labels AS exchange_display_labels, t.labels AS exchange_labels, t.address_type AS exchange_address_type, t.address_subtypes AS exchange_address_subtypes, ${depositVariable}.address AS deposit_address, ${depth} AS hops`,
|
|
212
212
|
"ORDER BY hops ASC",
|
|
@@ -232,7 +232,7 @@ function backwardSourceQueryAtDepth(id, depositAddress, depth) {
|
|
|
232
232
|
return {
|
|
233
233
|
id,
|
|
234
234
|
query: [
|
|
235
|
-
`MATCH (dep:Address {address: "${escapeCypherString$
|
|
235
|
+
`MATCH (dep:Address {address: "${escapeCypherString$3(depositAddress)}"})`,
|
|
236
236
|
`MATCH (dep)${relationshipChain}`,
|
|
237
237
|
`WHERE source <> dep AND source.is_exchange IS NOT NULL${intermediatePredicates.length > 0 ? ` AND ${intermediatePredicates.join(" AND ")}` : ""}`,
|
|
238
238
|
`RETURN dep.address AS deposit_address, source.address AS source_exchange, source.labels AS source_display_labels, source.labels AS source_labels, source.address_type AS source_address_type, source.address_subtypes AS source_address_subtypes, ${depth} AS hops, [${nodeVariables.map((nodeVariable) => `${nodeVariable}.address`).join(", ")}] AS addresses, [${nodeVariables.map((nodeVariable) => `${nodeVariable}.labels`).join(", ")}] AS node_labels, [${nodeVariables.map(pathNodeMap$1).join(", ")}] AS path_nodes`,
|
|
@@ -245,7 +245,7 @@ function reverseLeadsQuery(depositAddresses) {
|
|
|
245
245
|
id: "reverse_1hop",
|
|
246
246
|
query: [
|
|
247
247
|
"MATCH (sender:Address)-[r:FLOWS_TO]->(deposit:Address)",
|
|
248
|
-
`WHERE (${depositAddresses.map((address) => `deposit.address = "${escapeCypherString$
|
|
248
|
+
`WHERE (${depositAddresses.map((address) => `deposit.address = "${escapeCypherString$3(address)}"`).join(" OR ")}) AND sender.is_exchange IS NULL AND sender.address <> deposit.address`,
|
|
249
249
|
"RETURN DISTINCT sender.address AS address, sender.labels AS display_labels, sender.labels AS system_labels, sender.address_type AS address_type, sender.address_subtypes AS address_subtypes, coalesce(sender.lifetime_degree_in, 0) AS degree_in, coalesce(sender.lifetime_degree_out, 0) AS degree_out, coalesce(sender.total_volume_usd, 0) AS total_volume_usd, deposit.address AS deposit_address, r.amount_usd_sum AS amount_usd",
|
|
250
250
|
"ORDER BY r.amount_usd_sum DESC",
|
|
251
251
|
`LIMIT ${Math.max(50, depositAddresses.length * 50)}`
|
|
@@ -265,13 +265,13 @@ function directEdgePropsQuery(flows) {
|
|
|
265
265
|
id: "direct_edge_props",
|
|
266
266
|
query: [
|
|
267
267
|
"MATCH (a:Address)-[r:FLOWS_TO]->(b:Address)",
|
|
268
|
-
`WHERE (${pairs.map((pair) => `(a.address = "${escapeCypherString$
|
|
268
|
+
`WHERE (${pairs.map((pair) => `(a.address = "${escapeCypherString$3(pair.src)}" AND b.address = "${escapeCypherString$3(pair.dst)}")`).join(" OR ")})`,
|
|
269
269
|
"RETURN a.address AS src, b.address AS dst, r.amount_sum AS amount_sum, r.amount_usd_sum AS amount_usd_sum, r.tx_count AS tx_count, r.first_tx_id AS first_tx_id, r.last_tx_id AS last_tx_id",
|
|
270
270
|
`LIMIT ${pairs.length}`
|
|
271
271
|
].join(" ")
|
|
272
272
|
};
|
|
273
273
|
}
|
|
274
|
-
function numberValue$
|
|
274
|
+
function numberValue$3(value) {
|
|
275
275
|
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
276
276
|
if (typeof value === "string" && value.trim()) {
|
|
277
277
|
const parsed = Number(value);
|
|
@@ -282,7 +282,7 @@ function rowTerminalAmount(row) {
|
|
|
282
282
|
const edgeProps = Array.isArray(row["edge_props"]) ? row["edge_props"] : [];
|
|
283
283
|
const terminalEdge = edgeProps[edgeProps.length - 1];
|
|
284
284
|
if (!terminalEdge) return void 0;
|
|
285
|
-
return numberValue$
|
|
285
|
+
return numberValue$3(terminalEdge["amount_sum"]) ?? numberValue$3(terminalEdge["amount_usd_sum"]);
|
|
286
286
|
}
|
|
287
287
|
function rowsMatchingMinimumAmount(rows, minAmountSum) {
|
|
288
288
|
if (minAmountSum <= 0) return rows;
|
|
@@ -330,9 +330,9 @@ function depositFromRow(row) {
|
|
|
330
330
|
exchangeAddress,
|
|
331
331
|
exchangeLabels: stringArrayValue$1(row["exchange_labels"]),
|
|
332
332
|
exchangeNode,
|
|
333
|
-
amount_sum: numberValue$
|
|
334
|
-
amount_usd_sum: numberValue$
|
|
335
|
-
hops: numberValue$
|
|
333
|
+
amount_sum: numberValue$3(terminalEdge["amount_sum"]),
|
|
334
|
+
amount_usd_sum: numberValue$3(terminalEdge["amount_usd_sum"]),
|
|
335
|
+
hops: numberValue$3(row["hops"]) ?? pathAddresses.length - 1,
|
|
336
336
|
path: pathAddresses,
|
|
337
337
|
pathNodes
|
|
338
338
|
};
|
|
@@ -352,7 +352,7 @@ function flowsFromForwardRows(rows) {
|
|
|
352
352
|
const src = pathAddresses[index];
|
|
353
353
|
const dst = pathAddresses[index + 1];
|
|
354
354
|
const edge = edgeProps[index] ?? {};
|
|
355
|
-
const amount = numberValue$
|
|
355
|
+
const amount = numberValue$3(edge["amount_sum"]) ?? numberValue$3(edge["amount_usd_sum"]) ?? 0;
|
|
356
356
|
const terminal = index === pathAddresses.length - 2;
|
|
357
357
|
const key = `${src}->${dst}`;
|
|
358
358
|
if (seenEdges.has(key)) continue;
|
|
@@ -362,8 +362,8 @@ function flowsFromForwardRows(rows) {
|
|
|
362
362
|
src,
|
|
363
363
|
dst,
|
|
364
364
|
amount_sum: amount,
|
|
365
|
-
amount_usd_sum: numberValue$
|
|
366
|
-
tx_count: numberValue$
|
|
365
|
+
amount_usd_sum: numberValue$3(edge["amount_usd_sum"]),
|
|
366
|
+
tx_count: numberValue$3(edge["tx_count"]),
|
|
367
367
|
first_tx_id: typeof edge["first_tx_id"] === "string" ? edge["first_tx_id"] : void 0,
|
|
368
368
|
last_tx_id: typeof edge["last_tx_id"] === "string" ? edge["last_tx_id"] : void 0,
|
|
369
369
|
src_labels: nodeLabels[index],
|
|
@@ -382,7 +382,7 @@ function flowsFromForwardRows(rows) {
|
|
|
382
382
|
async function hydrateDirectEdgeProps(remoteClient, network, flows, deposits) {
|
|
383
383
|
const query = directEdgePropsQuery(flows);
|
|
384
384
|
if (!query) return;
|
|
385
|
-
const batch = await callGraphBatch$
|
|
385
|
+
const batch = await callGraphBatch$3(remoteClient, network, [query]);
|
|
386
386
|
const edgeProps = /* @__PURE__ */ new Map();
|
|
387
387
|
for (const row of resultsFor(batch, "direct_edge_props")) {
|
|
388
388
|
const src = typeof row["src"] === "string" ? row["src"] : "";
|
|
@@ -393,21 +393,21 @@ async function hydrateDirectEdgeProps(remoteClient, network, flows, deposits) {
|
|
|
393
393
|
for (const flow of flows) {
|
|
394
394
|
const props = edgeProps.get(edgeKey$1(flow.src, flow.dst));
|
|
395
395
|
if (!props) continue;
|
|
396
|
-
flow.amount_sum = numberValue$
|
|
397
|
-
flow.amount_usd_sum = numberValue$
|
|
398
|
-
flow.tx_count = numberValue$
|
|
396
|
+
flow.amount_sum = numberValue$3(props["amount_sum"]) ?? flow.amount_sum;
|
|
397
|
+
flow.amount_usd_sum = numberValue$3(props["amount_usd_sum"]);
|
|
398
|
+
flow.tx_count = numberValue$3(props["tx_count"]);
|
|
399
399
|
flow.first_tx_id = typeof props["first_tx_id"] === "string" ? props["first_tx_id"] : void 0;
|
|
400
400
|
flow.last_tx_id = typeof props["last_tx_id"] === "string" ? props["last_tx_id"] : void 0;
|
|
401
401
|
}
|
|
402
402
|
for (const deposit of deposits) {
|
|
403
403
|
const props = edgeProps.get(edgeKey$1(deposit.address, deposit.exchangeAddress));
|
|
404
404
|
if (!props) continue;
|
|
405
|
-
deposit.amount_sum = numberValue$
|
|
406
|
-
deposit.amount_usd_sum = numberValue$
|
|
405
|
+
deposit.amount_sum = numberValue$3(props["amount_sum"]);
|
|
406
|
+
deposit.amount_usd_sum = numberValue$3(props["amount_usd_sum"]);
|
|
407
407
|
}
|
|
408
408
|
}
|
|
409
409
|
async function collectProbeTrace(remoteClient, options) {
|
|
410
|
-
const { flows, deposits } = flowsFromForwardRows(rowsMatchingMinimumAmount(((await callGraphBatch$
|
|
410
|
+
const { flows, deposits } = flowsFromForwardRows(rowsMatchingMinimumAmount(((await callGraphBatch$3(remoteClient, options.network, [...forwardExchangeQueries(options.seedAddress, Math.max(options.perAddressLimit * 20, 200), options.minAmountSum, options.maxHops)])).facts?.queries ?? []).filter((query) => query.id?.startsWith("forward_exchange_paths_")).flatMap((query) => {
|
|
411
411
|
if (query.ok === false) throw new Error(query.error || `Query failed: ${query.id}`);
|
|
412
412
|
return query.results ?? [];
|
|
413
413
|
}), options.minAmountSum));
|
|
@@ -415,7 +415,7 @@ async function collectProbeTrace(remoteClient, options) {
|
|
|
415
415
|
const uniqueDepositAddresses = [...new Set(deposits.map((deposit) => deposit.address))];
|
|
416
416
|
const sourceMatches = [];
|
|
417
417
|
if (uniqueDepositAddresses.length > 0) {
|
|
418
|
-
const backwardBatch = await callGraphBatch$
|
|
418
|
+
const backwardBatch = await callGraphBatch$3(remoteClient, options.network, uniqueDepositAddresses.slice(0, Math.max(1, Math.floor(20 / options.maxHops))).flatMap((address, index) => backwardSourceQueries(`backward_from_deposit_${index + 1}`, address, options.maxHops)));
|
|
419
419
|
for (const query of backwardBatch.facts?.queries ?? []) for (const row of query.results ?? []) {
|
|
420
420
|
const pathAddresses = stringArrayValue$1(row["addresses"]) ?? [];
|
|
421
421
|
const pathNodes = Array.isArray(row["path_nodes"]) ? row["path_nodes"].map((node, index) => nodeMetadataFromValue(node, pathAddresses[index])).filter((node) => Boolean(node)) : void 0;
|
|
@@ -434,7 +434,7 @@ async function collectProbeTrace(remoteClient, options) {
|
|
|
434
434
|
source_exchange: sourceExchange,
|
|
435
435
|
source_labels: stringArrayValue$1(row["source_labels"]),
|
|
436
436
|
sourceNode,
|
|
437
|
-
hops: numberValue$
|
|
437
|
+
hops: numberValue$3(row["hops"]) ?? Math.max(pathAddresses.length - 1, 0),
|
|
438
438
|
path: pathAddresses,
|
|
439
439
|
pathNodes
|
|
440
440
|
});
|
|
@@ -442,15 +442,15 @@ async function collectProbeTrace(remoteClient, options) {
|
|
|
442
442
|
}
|
|
443
443
|
const reverseLeads = [];
|
|
444
444
|
if (uniqueDepositAddresses.length > 0) {
|
|
445
|
-
const reverseBatch = await callGraphBatch$
|
|
445
|
+
const reverseBatch = await callGraphBatch$3(remoteClient, options.network, [reverseLeadsQuery(uniqueDepositAddresses)]);
|
|
446
446
|
for (const row of resultsFor(reverseBatch, "reverse_1hop")) {
|
|
447
447
|
const address = typeof row["address"] === "string" ? row["address"] : "";
|
|
448
448
|
const depositAddress = typeof row["deposit_address"] === "string" ? row["deposit_address"] : "";
|
|
449
449
|
if (!address || !depositAddress) continue;
|
|
450
450
|
const labels = stringArrayValue$1(row["display_labels"]) ?? stringArrayValue$1(row["labels"]) ?? [];
|
|
451
|
-
const degreeIn = numberValue$
|
|
452
|
-
const degreeOut = numberValue$
|
|
453
|
-
const totalVolume = numberValue$
|
|
451
|
+
const degreeIn = numberValue$3(row["degree_in"]) ?? 0;
|
|
452
|
+
const degreeOut = numberValue$3(row["degree_out"]) ?? 0;
|
|
453
|
+
const totalVolume = numberValue$3(row["total_volume_usd"]) ?? 0;
|
|
454
454
|
const reason = labels.length > 0 ? "labeled_entity" : degreeIn > 50 ? "fan_in_hub" : degreeOut > 50 ? "fan_out_hub" : totalVolume > 1e5 ? "high_volume_sender" : "";
|
|
455
455
|
if (!reason) continue;
|
|
456
456
|
reverseLeads.push({
|
|
@@ -467,7 +467,7 @@ async function collectProbeTrace(remoteClient, options) {
|
|
|
467
467
|
degree_out: degreeOut,
|
|
468
468
|
total_volume_usd: totalVolume,
|
|
469
469
|
deposit_address: depositAddress,
|
|
470
|
-
amount_usd: numberValue$
|
|
470
|
+
amount_usd: numberValue$3(row["amount_usd"]),
|
|
471
471
|
reason
|
|
472
472
|
});
|
|
473
473
|
}
|
|
@@ -821,8 +821,8 @@ async function runFundFlowProbe(remoteClient, _config, options) {
|
|
|
821
821
|
const network = options.network.trim();
|
|
822
822
|
if (!seedAddress) throw new Error("seed_address is required");
|
|
823
823
|
if (!network) throw new Error("network is required");
|
|
824
|
-
const maxHops = clampInt$
|
|
825
|
-
const perAddressLimit = clampInt$
|
|
824
|
+
const maxHops = clampInt$2(options.maxHops, 3, 1, 5);
|
|
825
|
+
const perAddressLimit = clampInt$2(options.perAddressLimit, 5, 1, 10);
|
|
826
826
|
const minAmountSum = Math.max(0, options.minAmountSum ?? 0);
|
|
827
827
|
const paths = require_output_root.workspaceOutputPaths();
|
|
828
828
|
await ensureDirs(paths);
|
|
@@ -934,17 +934,17 @@ function stringArray(value) {
|
|
|
934
934
|
}
|
|
935
935
|
return [];
|
|
936
936
|
}
|
|
937
|
-
function stringValue(value) {
|
|
937
|
+
function stringValue$1(value) {
|
|
938
938
|
return typeof value === "string" && value.trim() ? value.trim() : void 0;
|
|
939
939
|
}
|
|
940
|
-
function numberValue$
|
|
940
|
+
function numberValue$2(value) {
|
|
941
941
|
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
942
942
|
if (typeof value === "string" && value.trim()) {
|
|
943
943
|
const parsed = Number(value);
|
|
944
944
|
if (Number.isFinite(parsed)) return parsed;
|
|
945
945
|
}
|
|
946
946
|
}
|
|
947
|
-
function clampInt(value, fallback, min, max) {
|
|
947
|
+
function clampInt$1(value, fallback, min, max) {
|
|
948
948
|
if (!Number.isFinite(value)) return fallback;
|
|
949
949
|
return Math.max(min, Math.min(max, Math.trunc(value)));
|
|
950
950
|
}
|
|
@@ -970,20 +970,20 @@ async function ensureScamTopologyDirs(paths) {
|
|
|
970
970
|
mode: 448
|
|
971
971
|
});
|
|
972
972
|
}
|
|
973
|
-
function escapeCypherString$
|
|
973
|
+
function escapeCypherString$2(value) {
|
|
974
974
|
return value.replaceAll("\\", "\\\\").replaceAll("\"", "\\\"");
|
|
975
975
|
}
|
|
976
|
-
function textFromToolResult$
|
|
976
|
+
function textFromToolResult$2(result) {
|
|
977
977
|
return (result.content ?? []).filter((item) => item.type === "text").map((item) => item.text).join("\n");
|
|
978
978
|
}
|
|
979
|
-
function parseGraphBatchResult$
|
|
980
|
-
const text = textFromToolResult$
|
|
979
|
+
function parseGraphBatchResult$2(result) {
|
|
980
|
+
const text = textFromToolResult$2(result).trim();
|
|
981
981
|
if (!text) throw new Error("graph_query_batch returned no text content");
|
|
982
982
|
const parsed = JSON.parse(text);
|
|
983
983
|
if (!parsed.facts?.queries) throw new Error("graph_query_batch response did not include facts.queries");
|
|
984
984
|
return parsed;
|
|
985
985
|
}
|
|
986
|
-
async function callGraphBatch$
|
|
986
|
+
async function callGraphBatch$2(remoteClient, network, queries) {
|
|
987
987
|
const result = await remoteClient.callTool({
|
|
988
988
|
name: "graph_query_batch",
|
|
989
989
|
arguments: {
|
|
@@ -995,8 +995,8 @@ async function callGraphBatch$1(remoteClient, network, queries) {
|
|
|
995
995
|
timeout: SCAM_TOPOLOGY_GRAPH_BATCH_REQUEST_TIMEOUT_MS,
|
|
996
996
|
maxTotalTimeout: SCAM_TOPOLOGY_GRAPH_BATCH_REQUEST_TIMEOUT_MS
|
|
997
997
|
});
|
|
998
|
-
if (result.isError) throw new Error(textFromToolResult$
|
|
999
|
-
return parseGraphBatchResult$
|
|
998
|
+
if (result.isError) throw new Error(textFromToolResult$2(result) || "graph_query_batch failed");
|
|
999
|
+
return parseGraphBatchResult$2(result);
|
|
1000
1000
|
}
|
|
1001
1001
|
function graphForScope(graphScope) {
|
|
1002
1002
|
return graphScope === "history" ? "archive_topology" : "live_topology";
|
|
@@ -1049,7 +1049,7 @@ function frontierQuery(graphScope, sourceAddress, hop, sourceIndex, perAddressLi
|
|
|
1049
1049
|
id: sourceIndex === void 0 ? `${graphScope}_hop_${hop}` : `${graphScope}_hop_${hop}_source_${sourceIndex}`,
|
|
1050
1050
|
query: [
|
|
1051
1051
|
`USE ${graphForScope(graphScope)}`,
|
|
1052
|
-
`MATCH (src:Address {address: "${escapeCypherString$
|
|
1052
|
+
`MATCH (src:Address {address: "${escapeCypherString$2(sourceAddress)}"})-[r:FLOWS_TO]->(dst:Address)`,
|
|
1053
1053
|
`WHERE ${where.join(" AND ")}`,
|
|
1054
1054
|
`RETURN ${traversalProjection()}`,
|
|
1055
1055
|
"ORDER BY r.amount_sum DESC",
|
|
@@ -1074,7 +1074,7 @@ function depositClusterQuery(graphScope, depositAddress, index, minAmountSum) {
|
|
|
1074
1074
|
id: `${graphScope}_deposit_cluster_${index}`,
|
|
1075
1075
|
query: [
|
|
1076
1076
|
`USE ${graphForScope(graphScope)}`,
|
|
1077
|
-
`MATCH (src:Address)-[r:FLOWS_TO]->(dst:Address {address: "${escapeCypherString$
|
|
1077
|
+
`MATCH (src:Address)-[r:FLOWS_TO]->(dst:Address {address: "${escapeCypherString$2(depositAddress)}"})`,
|
|
1078
1078
|
`WHERE ${where.join(" AND ")}`,
|
|
1079
1079
|
`RETURN ${traversalProjection()}`,
|
|
1080
1080
|
"ORDER BY r.amount_sum DESC",
|
|
@@ -1083,8 +1083,8 @@ function depositClusterQuery(graphScope, depositAddress, index, minAmountSum) {
|
|
|
1083
1083
|
};
|
|
1084
1084
|
}
|
|
1085
1085
|
function edgeFromRow(row, graphScope, hop, context) {
|
|
1086
|
-
const src = stringValue(row["src"]) ?? stringValue(row["from_address"]);
|
|
1087
|
-
const dst = stringValue(row["dst"]) ?? stringValue(row["to_address"]);
|
|
1086
|
+
const src = stringValue$1(row["src"]) ?? stringValue$1(row["from_address"]);
|
|
1087
|
+
const dst = stringValue$1(row["dst"]) ?? stringValue$1(row["to_address"]);
|
|
1088
1088
|
if (!src || !dst || src === dst) return null;
|
|
1089
1089
|
const srcLabels = stringArray(row["src_labels"]);
|
|
1090
1090
|
const dstLabels = stringArray(row["dst_labels"]);
|
|
@@ -1102,13 +1102,13 @@ function edgeFromRow(row, graphScope, hop, context) {
|
|
|
1102
1102
|
topology_graph: graphForScope(graphScope),
|
|
1103
1103
|
seed_address: context.seedAddress,
|
|
1104
1104
|
seed_role: context.seedRole,
|
|
1105
|
-
amount_sum: numberValue$
|
|
1106
|
-
amount_usd_sum: numberValue$
|
|
1107
|
-
tx_count: numberValue$
|
|
1108
|
-
first_seen_timestamp: numberValue$
|
|
1109
|
-
last_seen_timestamp: numberValue$
|
|
1110
|
-
first_tx_id: stringValue(row["first_tx_id"]),
|
|
1111
|
-
last_tx_id: stringValue(row["last_tx_id"]),
|
|
1105
|
+
amount_sum: numberValue$2(row["amount_sum"]),
|
|
1106
|
+
amount_usd_sum: numberValue$2(row["amount_usd_sum"]),
|
|
1107
|
+
tx_count: numberValue$2(row["tx_count"]),
|
|
1108
|
+
first_seen_timestamp: numberValue$2(row["first_seen_timestamp"]),
|
|
1109
|
+
last_seen_timestamp: numberValue$2(row["last_seen_timestamp"]),
|
|
1110
|
+
first_tx_id: stringValue$1(row["first_tx_id"]),
|
|
1111
|
+
last_tx_id: stringValue$1(row["last_tx_id"]),
|
|
1112
1112
|
src_labels: srcLabels,
|
|
1113
1113
|
dst_labels: dstLabels,
|
|
1114
1114
|
src_is_exchange: srcIsExchange,
|
|
@@ -1149,7 +1149,7 @@ async function runDirectedTraversal(remoteClient, network, seeds, graphScope, ac
|
|
|
1149
1149
|
for (const queryChunk of chunks(queries, maxBatchQueries)) {
|
|
1150
1150
|
let batch;
|
|
1151
1151
|
try {
|
|
1152
|
-
batch = await callGraphBatch$
|
|
1152
|
+
batch = await callGraphBatch$2(remoteClient, network, queryChunk);
|
|
1153
1153
|
} catch (err) {
|
|
1154
1154
|
if (hop === 1) throw err;
|
|
1155
1155
|
for (const query of queryChunk) skippedQueryErrors.push({
|
|
@@ -1172,7 +1172,7 @@ async function runDirectedTraversal(remoteClient, network, seeds, graphScope, ac
|
|
|
1172
1172
|
continue;
|
|
1173
1173
|
}
|
|
1174
1174
|
for (const row of queryResult.results ?? []) {
|
|
1175
|
-
const src = stringValue(row["src"]) ?? stringValue(row["from_address"]);
|
|
1175
|
+
const src = stringValue$1(row["src"]) ?? stringValue$1(row["from_address"]);
|
|
1176
1176
|
if (!src) continue;
|
|
1177
1177
|
const contexts = frontierByAddress.get(src) ?? [];
|
|
1178
1178
|
for (const context of contexts) {
|
|
@@ -1241,7 +1241,7 @@ async function expandDepositClusters(remoteClient, network, run, minAmountSum) {
|
|
|
1241
1241
|
for (const queryChunk of chunks(queries, maxBatchQueries)) {
|
|
1242
1242
|
let batch;
|
|
1243
1243
|
try {
|
|
1244
|
-
batch = await callGraphBatch$
|
|
1244
|
+
batch = await callGraphBatch$2(remoteClient, network, queryChunk);
|
|
1245
1245
|
} catch (err) {
|
|
1246
1246
|
for (const query of queryChunk) run.skippedQueryErrors.push({
|
|
1247
1247
|
id: query.id,
|
|
@@ -1590,16 +1590,16 @@ function scamLabelsByAddress(facts) {
|
|
|
1590
1590
|
for (const label of labels) {
|
|
1591
1591
|
if (!label || typeof label !== "object" || Array.isArray(label)) continue;
|
|
1592
1592
|
const record = label;
|
|
1593
|
-
const address = stringValue(record["address"]);
|
|
1594
|
-
const confidence = numberValue$
|
|
1593
|
+
const address = stringValue$1(record["address"]);
|
|
1594
|
+
const confidence = numberValue$2(record["confidence"]);
|
|
1595
1595
|
if (!address || confidence === void 0) continue;
|
|
1596
1596
|
result.set(address, {
|
|
1597
1597
|
address,
|
|
1598
1598
|
scam: true,
|
|
1599
1599
|
confidence,
|
|
1600
1600
|
source: "scam_topology",
|
|
1601
|
-
source_victim_address: stringValue(record["source_victim_address"]) ?? "",
|
|
1602
|
-
source_incident_timestamp_ms: numberValue$
|
|
1601
|
+
source_victim_address: stringValue$1(record["source_victim_address"]) ?? "",
|
|
1602
|
+
source_incident_timestamp_ms: numberValue$2(record["source_incident_timestamp_ms"]) ?? 0
|
|
1603
1603
|
});
|
|
1604
1604
|
}
|
|
1605
1605
|
return result;
|
|
@@ -1646,7 +1646,7 @@ function buildGraph(seeds, edges, rolesByAddress, facts) {
|
|
|
1646
1646
|
const addFlowTotals = (address, direction, amount) => {
|
|
1647
1647
|
const node = nodesById.get(address) ?? mergeNode(address, []);
|
|
1648
1648
|
const key = direction === "in" ? "flow_in_usd" : "flow_out_usd";
|
|
1649
|
-
node[key] = (numberValue$
|
|
1649
|
+
node[key] = (numberValue$2(node[key]) ?? 0) + amount;
|
|
1650
1650
|
nodesById.set(address, node);
|
|
1651
1651
|
};
|
|
1652
1652
|
for (const edge of edges) {
|
|
@@ -1853,7 +1853,7 @@ function buildScamTopologyReport(facts, files) {
|
|
|
1853
1853
|
"| Deposit | Exchange | Names | Hop | amount_sum | tx_count |",
|
|
1854
1854
|
"|---|---|---|---:|---:|---:|",
|
|
1855
1855
|
...facts.exchange_deposits.map((entry) => {
|
|
1856
|
-
return `| \`${stringValue(entry["deposit_address"]) ?? ""}\` | \`${stringValue(entry["exchange_address"]) ?? ""}\` | ${stringArray(entry["exchange_names"]).join(", ") || ""} | ${entry["hop"] ?? ""} | ${entry["amount_sum"] ?? ""} | ${entry["tx_count"] ?? ""} |`;
|
|
1856
|
+
return `| \`${stringValue$1(entry["deposit_address"]) ?? ""}\` | \`${stringValue$1(entry["exchange_address"]) ?? ""}\` | ${stringArray(entry["exchange_names"]).join(", ") || ""} | ${entry["hop"] ?? ""} | ${entry["amount_sum"] ?? ""} | ${entry["tx_count"] ?? ""} |`;
|
|
1857
1857
|
}),
|
|
1858
1858
|
"",
|
|
1859
1859
|
"## Label Candidates",
|
|
@@ -1925,7 +1925,7 @@ async function scamTopology(remoteClient, config, options) {
|
|
|
1925
1925
|
const victimAddresses = parseAddressList$1(options.victimAddress ?? legacyOptions.victimAddresses);
|
|
1926
1926
|
const scammerAddresses = parseAddressList$1(legacyOptions.scammerAddresses);
|
|
1927
1927
|
const incidentTimestampMs = validateNonNegativeNumber(options.incidentTimestampMs, "incident_timestamp_ms");
|
|
1928
|
-
const maxHops = clampInt(options.maxHops, SCAM_TOPOLOGY_DEFAULT_MAX_HOPS, 1, SCAM_TOPOLOGY_MAX_HOPS);
|
|
1928
|
+
const maxHops = clampInt$1(options.maxHops, SCAM_TOPOLOGY_DEFAULT_MAX_HOPS, 1, SCAM_TOPOLOGY_MAX_HOPS);
|
|
1929
1929
|
const perAddressLimit = SCAM_TOPOLOGY_FRONTIER_LIMIT;
|
|
1930
1930
|
const minAmountSum = void 0;
|
|
1931
1931
|
const activityPolicyMode = validateActivityPolicyMode(options.activityPolicyMode);
|
|
@@ -2028,6 +2028,401 @@ async function scamTopology(remoteClient, config, options) {
|
|
|
2028
2028
|
};
|
|
2029
2029
|
}
|
|
2030
2030
|
//#endregion
|
|
2031
|
+
//#region src/investigation/stake-insights.ts
|
|
2032
|
+
const STAKE_INSIGHTS_QUERY_TIMEOUT_SECONDS = 120;
|
|
2033
|
+
const STAKE_INSIGHTS_REQUEST_TIMEOUT_MS = 300 * 1e3;
|
|
2034
|
+
function escapeCypherString$1(value) {
|
|
2035
|
+
return value.replaceAll("\\", "\\\\").replaceAll("\"", "\\\"");
|
|
2036
|
+
}
|
|
2037
|
+
function textFromToolResult$1(result) {
|
|
2038
|
+
return (result.content ?? []).filter((item) => item.type === "text").map((item) => item.text).join("\n");
|
|
2039
|
+
}
|
|
2040
|
+
function parseGraphBatchResult$1(result) {
|
|
2041
|
+
const text = textFromToolResult$1(result).trim();
|
|
2042
|
+
if (!text) throw new Error("graph_query_batch returned no text content");
|
|
2043
|
+
const parsed = JSON.parse(text);
|
|
2044
|
+
if (!parsed.facts?.queries) throw new Error("graph_query_batch response did not include facts.queries");
|
|
2045
|
+
return parsed;
|
|
2046
|
+
}
|
|
2047
|
+
function stringValue(value) {
|
|
2048
|
+
return typeof value === "string" && value.trim() ? value.trim() : void 0;
|
|
2049
|
+
}
|
|
2050
|
+
function numberValue$1(value) {
|
|
2051
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
2052
|
+
if (typeof value === "string" && value.trim()) {
|
|
2053
|
+
const parsed = Number(value);
|
|
2054
|
+
if (Number.isFinite(parsed)) return parsed;
|
|
2055
|
+
}
|
|
2056
|
+
}
|
|
2057
|
+
function nonZeroNumber(value) {
|
|
2058
|
+
const parsed = numberValue$1(value);
|
|
2059
|
+
return parsed !== void 0 && parsed !== 0 ? parsed : void 0;
|
|
2060
|
+
}
|
|
2061
|
+
function clampInt(value, fallback, min, max) {
|
|
2062
|
+
if (!Number.isFinite(value)) return fallback;
|
|
2063
|
+
return Math.max(min, Math.min(max, Math.trunc(value)));
|
|
2064
|
+
}
|
|
2065
|
+
function resolveSubject(options) {
|
|
2066
|
+
const candidates = [
|
|
2067
|
+
["address", options.address],
|
|
2068
|
+
["coldkey", options.coldkey],
|
|
2069
|
+
["hotkey", options.hotkey]
|
|
2070
|
+
].filter((entry) => !!entry[1]?.trim());
|
|
2071
|
+
if (candidates.length !== 1) throw new Error("Provide exactly one of address, coldkey, or hotkey");
|
|
2072
|
+
return {
|
|
2073
|
+
role: candidates[0][0],
|
|
2074
|
+
address: candidates[0][1].trim()
|
|
2075
|
+
};
|
|
2076
|
+
}
|
|
2077
|
+
function validateOptions(options) {
|
|
2078
|
+
const network = options.network.trim();
|
|
2079
|
+
if (!network) throw new Error("network is required");
|
|
2080
|
+
if (options.startBlock !== void 0 || options.endBlock !== void 0) throw new Error("Block windows are not available on the current stake graph surface; use start_timestamp_ms/end_timestamp_ms");
|
|
2081
|
+
return {
|
|
2082
|
+
network,
|
|
2083
|
+
subject: resolveSubject(options),
|
|
2084
|
+
depth: clampInt(options.depth ?? options.maxHops, 1, 1, 3)
|
|
2085
|
+
};
|
|
2086
|
+
}
|
|
2087
|
+
function subjectPredicate(subject) {
|
|
2088
|
+
const address = escapeCypherString$1(subject.address);
|
|
2089
|
+
if (subject.role === "coldkey") return `coldkey.address = "${address}"`;
|
|
2090
|
+
if (subject.role === "hotkey") return `hotkey.address = "${address}"`;
|
|
2091
|
+
return `(coldkey.address = "${address}" OR hotkey.address = "${address}")`;
|
|
2092
|
+
}
|
|
2093
|
+
function stakeRelationshipQuery(topologyGraph, subject, options, depth) {
|
|
2094
|
+
const predicates = [subjectPredicate(subject)];
|
|
2095
|
+
if (options.netuid !== void 0) predicates.push(`stake.netuid = ${Math.trunc(options.netuid)}`);
|
|
2096
|
+
if (options.startTimestampMs !== void 0) predicates.push(`stake.last_activity_timestamp >= ${Math.trunc(options.startTimestampMs)}`);
|
|
2097
|
+
if (options.endTimestampMs !== void 0) predicates.push(`stake.first_activity_timestamp <= ${Math.trunc(options.endTimestampMs)}`);
|
|
2098
|
+
const limit = Math.min(500, Math.max(50, depth * 100));
|
|
2099
|
+
return {
|
|
2100
|
+
id: topologyGraph === "live_topology" ? "live_stake_relationships" : "archive_stake_relationships",
|
|
2101
|
+
query: [
|
|
2102
|
+
`USE ${topologyGraph}`,
|
|
2103
|
+
"MATCH (coldkey:Address)-[stake:STAKES_IN]->(hotkey:Address)",
|
|
2104
|
+
`WHERE ${predicates.join(" AND ")}`,
|
|
2105
|
+
[
|
|
2106
|
+
"RETURN coldkey.address AS coldkey",
|
|
2107
|
+
"hotkey.address AS hotkey",
|
|
2108
|
+
"stake.netuid AS netuid",
|
|
2109
|
+
"stake.amount AS amount",
|
|
2110
|
+
"stake.source_role AS source_role",
|
|
2111
|
+
"stake.destination_role AS destination_role",
|
|
2112
|
+
"stake.stake_added_amount AS stake_added_amount",
|
|
2113
|
+
"stake.stake_removed_amount AS stake_removed_amount",
|
|
2114
|
+
"stake.stake_moved_in_amount AS stake_moved_in_amount",
|
|
2115
|
+
"stake.stake_moved_out_amount AS stake_moved_out_amount",
|
|
2116
|
+
"stake.net_stake_change AS net_stake_change",
|
|
2117
|
+
"stake.stake_event_count AS stake_event_count",
|
|
2118
|
+
"stake.first_seen_timestamp AS first_seen_timestamp",
|
|
2119
|
+
"stake.last_seen_timestamp AS last_seen_timestamp",
|
|
2120
|
+
"stake.first_activity_timestamp AS first_activity_timestamp",
|
|
2121
|
+
"stake.last_activity_timestamp AS last_activity_timestamp",
|
|
2122
|
+
"stake.first_tx_id AS first_tx_id",
|
|
2123
|
+
"stake.last_tx_id AS last_tx_id",
|
|
2124
|
+
"stake.active_days AS active_days",
|
|
2125
|
+
"stake.granularity AS granularity",
|
|
2126
|
+
"stake.source_stake_rows AS source_stake_rows",
|
|
2127
|
+
"stake.source_backend AS source_backend",
|
|
2128
|
+
`"${topologyGraph}" AS topology_graph`
|
|
2129
|
+
].join(", "),
|
|
2130
|
+
"ORDER BY stake.amount DESC",
|
|
2131
|
+
`LIMIT ${limit}`
|
|
2132
|
+
].join(" ")
|
|
2133
|
+
};
|
|
2134
|
+
}
|
|
2135
|
+
async function callGraphBatch$1(remoteClient, network, queries) {
|
|
2136
|
+
const result = await remoteClient.callTool({
|
|
2137
|
+
name: "graph_query_batch",
|
|
2138
|
+
arguments: {
|
|
2139
|
+
network,
|
|
2140
|
+
queries,
|
|
2141
|
+
per_query_timeout_seconds: STAKE_INSIGHTS_QUERY_TIMEOUT_SECONDS
|
|
2142
|
+
}
|
|
2143
|
+
}, void 0, {
|
|
2144
|
+
timeout: STAKE_INSIGHTS_REQUEST_TIMEOUT_MS,
|
|
2145
|
+
maxTotalTimeout: STAKE_INSIGHTS_REQUEST_TIMEOUT_MS
|
|
2146
|
+
});
|
|
2147
|
+
if (result.isError) throw new Error(textFromToolResult$1(result) || "graph_query_batch failed");
|
|
2148
|
+
return parseGraphBatchResult$1(result);
|
|
2149
|
+
}
|
|
2150
|
+
function topologyGraphForQueryId(id) {
|
|
2151
|
+
return id.startsWith("archive_") ? "archive_topology" : "live_topology";
|
|
2152
|
+
}
|
|
2153
|
+
function collectRelationships(batch) {
|
|
2154
|
+
const failures = [];
|
|
2155
|
+
const evidence = [];
|
|
2156
|
+
const live = [];
|
|
2157
|
+
const archive = [];
|
|
2158
|
+
for (const query of batch.facts?.queries ?? []) {
|
|
2159
|
+
const id = query.id ?? "unknown";
|
|
2160
|
+
const topologyGraph = topologyGraphForQueryId(id);
|
|
2161
|
+
if (query.ok === false) {
|
|
2162
|
+
failures.push({
|
|
2163
|
+
id,
|
|
2164
|
+
error: query.error || "unknown error"
|
|
2165
|
+
});
|
|
2166
|
+
evidence.push({
|
|
2167
|
+
id,
|
|
2168
|
+
topology_graph: topologyGraph,
|
|
2169
|
+
ok: false,
|
|
2170
|
+
row_count: 0,
|
|
2171
|
+
error: query.error || "unknown error"
|
|
2172
|
+
});
|
|
2173
|
+
continue;
|
|
2174
|
+
}
|
|
2175
|
+
const rows = (query.results ?? []).map((row) => normalizeRelationship(row, topologyGraph));
|
|
2176
|
+
if (topologyGraph === "live_topology") live.push(...rows);
|
|
2177
|
+
else archive.push(...rows);
|
|
2178
|
+
evidence.push({
|
|
2179
|
+
id,
|
|
2180
|
+
topology_graph: topologyGraph,
|
|
2181
|
+
ok: true,
|
|
2182
|
+
row_count: rows.length,
|
|
2183
|
+
source_backends: [...new Set(rows.map((row) => row.source_backend).filter(Boolean))]
|
|
2184
|
+
});
|
|
2185
|
+
}
|
|
2186
|
+
return {
|
|
2187
|
+
live,
|
|
2188
|
+
archive,
|
|
2189
|
+
failures,
|
|
2190
|
+
evidence
|
|
2191
|
+
};
|
|
2192
|
+
}
|
|
2193
|
+
function normalizeRelationship(row, topologyGraph) {
|
|
2194
|
+
return {
|
|
2195
|
+
coldkey: String(row["coldkey"] ?? ""),
|
|
2196
|
+
hotkey: String(row["hotkey"] ?? ""),
|
|
2197
|
+
netuid: numberValue$1(row["netuid"]),
|
|
2198
|
+
amount: numberValue$1(row["amount"]),
|
|
2199
|
+
source_role: stringValue(row["source_role"]),
|
|
2200
|
+
destination_role: stringValue(row["destination_role"]),
|
|
2201
|
+
stake_added_amount: numberValue$1(row["stake_added_amount"]),
|
|
2202
|
+
stake_removed_amount: numberValue$1(row["stake_removed_amount"]),
|
|
2203
|
+
stake_moved_in_amount: numberValue$1(row["stake_moved_in_amount"]),
|
|
2204
|
+
stake_moved_out_amount: numberValue$1(row["stake_moved_out_amount"]),
|
|
2205
|
+
net_stake_change: numberValue$1(row["net_stake_change"]),
|
|
2206
|
+
stake_event_count: numberValue$1(row["stake_event_count"]),
|
|
2207
|
+
first_seen_timestamp: numberValue$1(row["first_seen_timestamp"]),
|
|
2208
|
+
last_seen_timestamp: numberValue$1(row["last_seen_timestamp"]),
|
|
2209
|
+
first_activity_timestamp: numberValue$1(row["first_activity_timestamp"]),
|
|
2210
|
+
last_activity_timestamp: numberValue$1(row["last_activity_timestamp"]),
|
|
2211
|
+
first_tx_id: stringValue(row["first_tx_id"]),
|
|
2212
|
+
last_tx_id: stringValue(row["last_tx_id"]),
|
|
2213
|
+
active_days: numberValue$1(row["active_days"]),
|
|
2214
|
+
granularity: stringValue(row["granularity"]),
|
|
2215
|
+
source_stake_rows: numberValue$1(row["source_stake_rows"]),
|
|
2216
|
+
source_backend: stringValue(row["source_backend"]) ?? (topologyGraph === "live_topology" ? "memgraph_live" : "starrocks_archive"),
|
|
2217
|
+
topology_graph: topologyGraph
|
|
2218
|
+
};
|
|
2219
|
+
}
|
|
2220
|
+
function firstTimestamp(rows) {
|
|
2221
|
+
const timestamps = rows.map((row) => row.first_activity_timestamp).filter((value) => value !== void 0);
|
|
2222
|
+
return timestamps.length > 0 ? Math.min(...timestamps) : void 0;
|
|
2223
|
+
}
|
|
2224
|
+
function lastTimestamp(rows) {
|
|
2225
|
+
const timestamps = rows.map((row) => row.last_activity_timestamp).filter((value) => value !== void 0);
|
|
2226
|
+
return timestamps.length > 0 ? Math.max(...timestamps) : void 0;
|
|
2227
|
+
}
|
|
2228
|
+
function sum(rows, selector) {
|
|
2229
|
+
return rows.reduce((total, row) => total + (selector(row) ?? 0), 0);
|
|
2230
|
+
}
|
|
2231
|
+
function stakeTotals(rows) {
|
|
2232
|
+
return {
|
|
2233
|
+
amount_unit: "tao",
|
|
2234
|
+
total_staked: sum(rows, (row) => row.stake_added_amount),
|
|
2235
|
+
total_unstaked: sum(rows, (row) => row.stake_removed_amount),
|
|
2236
|
+
total_moved_in: sum(rows, (row) => row.stake_moved_in_amount),
|
|
2237
|
+
total_moved_out: sum(rows, (row) => row.stake_moved_out_amount),
|
|
2238
|
+
net_staked: rows.some((row) => row.net_stake_change !== void 0) ? sum(rows, (row) => row.net_stake_change) : sum(rows, (row) => row.amount),
|
|
2239
|
+
relationship_count: rows.length,
|
|
2240
|
+
first_activity_timestamp: firstTimestamp(rows),
|
|
2241
|
+
last_activity_timestamp: lastTimestamp(rows)
|
|
2242
|
+
};
|
|
2243
|
+
}
|
|
2244
|
+
function movementRows(rows) {
|
|
2245
|
+
const movements = [];
|
|
2246
|
+
for (const row of rows) {
|
|
2247
|
+
const base = {
|
|
2248
|
+
coldkey: row.coldkey,
|
|
2249
|
+
hotkey: row.hotkey,
|
|
2250
|
+
netuid: row.netuid,
|
|
2251
|
+
source_backend: row.source_backend,
|
|
2252
|
+
first_activity_timestamp: row.first_activity_timestamp,
|
|
2253
|
+
last_activity_timestamp: row.last_activity_timestamp
|
|
2254
|
+
};
|
|
2255
|
+
const added = nonZeroNumber(row.stake_added_amount);
|
|
2256
|
+
if (added !== void 0) movements.push({
|
|
2257
|
+
...base,
|
|
2258
|
+
movement_type: "stake_added",
|
|
2259
|
+
direction: "coldkey_to_hotkey",
|
|
2260
|
+
amount: added
|
|
2261
|
+
});
|
|
2262
|
+
const removed = nonZeroNumber(row.stake_removed_amount);
|
|
2263
|
+
if (removed !== void 0) movements.push({
|
|
2264
|
+
...base,
|
|
2265
|
+
movement_type: "stake_removed",
|
|
2266
|
+
direction: "hotkey_to_coldkey",
|
|
2267
|
+
amount: removed
|
|
2268
|
+
});
|
|
2269
|
+
const movedIn = nonZeroNumber(row.stake_moved_in_amount);
|
|
2270
|
+
if (movedIn !== void 0) movements.push({
|
|
2271
|
+
...base,
|
|
2272
|
+
movement_type: "stake_moved_in",
|
|
2273
|
+
direction: "counterparty_to_relationship",
|
|
2274
|
+
amount: movedIn
|
|
2275
|
+
});
|
|
2276
|
+
const movedOut = nonZeroNumber(row.stake_moved_out_amount);
|
|
2277
|
+
if (movedOut !== void 0) movements.push({
|
|
2278
|
+
...base,
|
|
2279
|
+
movement_type: "stake_moved_out",
|
|
2280
|
+
direction: "relationship_to_counterparty",
|
|
2281
|
+
amount: movedOut
|
|
2282
|
+
});
|
|
2283
|
+
}
|
|
2284
|
+
return movements;
|
|
2285
|
+
}
|
|
2286
|
+
function topCounterparties(subject, rows) {
|
|
2287
|
+
const byAddress = /* @__PURE__ */ new Map();
|
|
2288
|
+
for (const row of rows) {
|
|
2289
|
+
const counterparties = [];
|
|
2290
|
+
if (subject.role === "coldkey") counterparties.push({
|
|
2291
|
+
address: row.hotkey,
|
|
2292
|
+
role: "hotkey"
|
|
2293
|
+
});
|
|
2294
|
+
else if (subject.role === "hotkey") counterparties.push({
|
|
2295
|
+
address: row.coldkey,
|
|
2296
|
+
role: "coldkey"
|
|
2297
|
+
});
|
|
2298
|
+
else {
|
|
2299
|
+
if (row.coldkey === subject.address) counterparties.push({
|
|
2300
|
+
address: row.hotkey,
|
|
2301
|
+
role: "hotkey"
|
|
2302
|
+
});
|
|
2303
|
+
if (row.hotkey === subject.address) counterparties.push({
|
|
2304
|
+
address: row.coldkey,
|
|
2305
|
+
role: "coldkey"
|
|
2306
|
+
});
|
|
2307
|
+
}
|
|
2308
|
+
for (const counterparty of counterparties.filter((entry) => entry.address)) {
|
|
2309
|
+
const current = byAddress.get(counterparty.address) ?? {
|
|
2310
|
+
address: counterparty.address,
|
|
2311
|
+
role: counterparty.role,
|
|
2312
|
+
amount: 0,
|
|
2313
|
+
relationship_count: 0,
|
|
2314
|
+
stake_event_count: 0
|
|
2315
|
+
};
|
|
2316
|
+
current.amount += row.amount ?? row.net_stake_change ?? 0;
|
|
2317
|
+
current.relationship_count += 1;
|
|
2318
|
+
current.stake_event_count += row.stake_event_count ?? 0;
|
|
2319
|
+
byAddress.set(counterparty.address, current);
|
|
2320
|
+
}
|
|
2321
|
+
}
|
|
2322
|
+
return [...byAddress.values()].sort((left, right) => Math.abs(right.amount) - Math.abs(left.amount)).slice(0, 10);
|
|
2323
|
+
}
|
|
2324
|
+
function graphData(rows, subject, network) {
|
|
2325
|
+
const nodes = /* @__PURE__ */ new Map();
|
|
2326
|
+
const ensureNode = (address, role) => {
|
|
2327
|
+
const existing = nodes.get(address) ?? {
|
|
2328
|
+
id: address,
|
|
2329
|
+
address,
|
|
2330
|
+
node_type: "address",
|
|
2331
|
+
labels: [],
|
|
2332
|
+
roles: []
|
|
2333
|
+
};
|
|
2334
|
+
const roles = Array.isArray(existing["roles"]) ? existing["roles"].map(String) : [];
|
|
2335
|
+
nodes.set(address, {
|
|
2336
|
+
...existing,
|
|
2337
|
+
roles: [...new Set([...roles, role])]
|
|
2338
|
+
});
|
|
2339
|
+
};
|
|
2340
|
+
ensureNode(subject.address, "subject");
|
|
2341
|
+
const edges = rows.map((row) => {
|
|
2342
|
+
ensureNode(row.coldkey, "coldkey");
|
|
2343
|
+
ensureNode(row.hotkey, "hotkey");
|
|
2344
|
+
return {
|
|
2345
|
+
source: row.coldkey,
|
|
2346
|
+
target: row.hotkey,
|
|
2347
|
+
edge_type: "stakes_in",
|
|
2348
|
+
amount: row.amount ?? row.net_stake_change ?? 0,
|
|
2349
|
+
netuid: row.netuid,
|
|
2350
|
+
source_backend: row.source_backend,
|
|
2351
|
+
topology_graph: row.topology_graph,
|
|
2352
|
+
first_activity_timestamp: row.first_activity_timestamp,
|
|
2353
|
+
last_activity_timestamp: row.last_activity_timestamp
|
|
2354
|
+
};
|
|
2355
|
+
});
|
|
2356
|
+
return require_graph_normalizer.normalizeGraphPayload({
|
|
2357
|
+
schema: "chain-insights.graph.v1",
|
|
2358
|
+
nodes: [...nodes.values()],
|
|
2359
|
+
edges,
|
|
2360
|
+
flows: [],
|
|
2361
|
+
edge_anchors: [],
|
|
2362
|
+
metadata: {
|
|
2363
|
+
network,
|
|
2364
|
+
subject_address: subject.address,
|
|
2365
|
+
subject_role: subject.role,
|
|
2366
|
+
generated_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
2367
|
+
}
|
|
2368
|
+
});
|
|
2369
|
+
}
|
|
2370
|
+
function summaryLines(network, subject, rows, totals, failures) {
|
|
2371
|
+
const lines = [
|
|
2372
|
+
`Stake insights for ${network}:${subject.address}`,
|
|
2373
|
+
"",
|
|
2374
|
+
`Subject role: ${subject.role}`,
|
|
2375
|
+
`Relationships: ${rows.length}`,
|
|
2376
|
+
`Net staked: ${totals["net_staked"] ?? 0} TAO`,
|
|
2377
|
+
`Total staked: ${totals["total_staked"] ?? 0} TAO`,
|
|
2378
|
+
`Total unstaked: ${totals["total_unstaked"] ?? 0} TAO`,
|
|
2379
|
+
`First activity: ${totals["first_activity_timestamp"] ?? "unknown"}`,
|
|
2380
|
+
`Last activity: ${totals["last_activity_timestamp"] ?? "unknown"}`
|
|
2381
|
+
];
|
|
2382
|
+
if (rows.length > 0) {
|
|
2383
|
+
lines.push("", "Top staking relationships");
|
|
2384
|
+
for (const row of rows.slice(0, 10)) lines.push(`- ${row.coldkey} -> ${row.hotkey} netuid ${row.netuid ?? "unknown"} amount ${row.amount ?? row.net_stake_change ?? "unknown"} (${row.source_backend})`);
|
|
2385
|
+
} else lines.push("", "No stake relationships matched the requested filters.");
|
|
2386
|
+
if (failures.length > 0) lines.push("", "Partial query failures", failures.map((failure) => `- ${failure.id}: ${failure.error}`).join("\n"));
|
|
2387
|
+
return lines.join("\n");
|
|
2388
|
+
}
|
|
2389
|
+
async function stakeInsights(remoteClient, options) {
|
|
2390
|
+
const { network, subject, depth } = validateOptions(options);
|
|
2391
|
+
const { live, archive, failures, evidence } = collectRelationships(await callGraphBatch$1(remoteClient, network, [stakeRelationshipQuery("live_topology", subject, options, depth), stakeRelationshipQuery("archive_topology", subject, options, depth)]));
|
|
2392
|
+
if (live.length === 0 && archive.length === 0 && failures.length > 0) throw new Error(`Stake insights unavailable: ${failures.map((failure) => `${failure.id}: ${failure.error}`).join("; ")}`);
|
|
2393
|
+
const rows = live.length > 0 ? live : archive;
|
|
2394
|
+
const totals = stakeTotals(rows);
|
|
2395
|
+
const facts = {
|
|
2396
|
+
subject: {
|
|
2397
|
+
network,
|
|
2398
|
+
address: subject.address,
|
|
2399
|
+
role: subject.role,
|
|
2400
|
+
netuid: options.netuid,
|
|
2401
|
+
start_timestamp_ms: options.startTimestampMs,
|
|
2402
|
+
end_timestamp_ms: options.endTimestampMs,
|
|
2403
|
+
depth
|
|
2404
|
+
},
|
|
2405
|
+
backend_used: [...new Set(rows.map((row) => row.source_backend).filter(Boolean))],
|
|
2406
|
+
primary_topology_graph: live.length > 0 ? "live_topology" : "archive_topology",
|
|
2407
|
+
stake_totals: totals,
|
|
2408
|
+
active_relationships: rows,
|
|
2409
|
+
stake_movements: movementRows(rows),
|
|
2410
|
+
top_counterparties: topCounterparties(subject, rows),
|
|
2411
|
+
query_evidence: evidence,
|
|
2412
|
+
partial_query_errors: failures.length > 0 ? failures : void 0
|
|
2413
|
+
};
|
|
2414
|
+
return {
|
|
2415
|
+
summaryText: summaryLines(network, subject, rows, totals, failures),
|
|
2416
|
+
structuredContent: {
|
|
2417
|
+
schema: "chain-insights.result.v1",
|
|
2418
|
+
tool: "stake_insights",
|
|
2419
|
+
facts,
|
|
2420
|
+
hint: rows.length > 0 ? "Review active_relationships and stake_movements before treating stake behavior as generic money flow." : "No matching stake relationships were found; confirm the address role, netuid, and time window."
|
|
2421
|
+
},
|
|
2422
|
+
graphData: graphData(rows, subject, network)
|
|
2423
|
+
};
|
|
2424
|
+
}
|
|
2425
|
+
//#endregion
|
|
2031
2426
|
//#region src/investigation/public-tools.ts
|
|
2032
2427
|
const GRAPH_QUERY_BATCH_TIMEOUT_SECONDS = 120;
|
|
2033
2428
|
const GRAPH_QUERY_BATCH_REQUEST_TIMEOUT_MS = 300 * 1e3;
|
|
@@ -2553,4 +2948,5 @@ async function trackFunds(remoteClient, config, options) {
|
|
|
2553
2948
|
//#endregion
|
|
2554
2949
|
exports.addressRisk = addressRisk;
|
|
2555
2950
|
exports.scamTopology = scamTopology;
|
|
2951
|
+
exports.stakeInsights = stakeInsights;
|
|
2556
2952
|
exports.trackFunds = trackFunds;
|