chain-insights 0.2.20 → 0.2.23

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 CHANGED
@@ -4,12 +4,11 @@
4
4
 
5
5
  Chain Insights is an open-source AML investigation toolkit for AI agents and
6
6
  analysts. Install it from npm to screen blockchain addresses, trace funds,
7
- expand scam topologies, manage case evidence, and generate graph reports from
8
- Chain Insights graph intelligence.
7
+ expand scam topologies, manage case evidence, and generate graph reports.
9
8
 
10
- The hosted GraphRAG MCP access path is paid through x402. The CLI and MCP proxy
11
- handle local wallet status, paid graph calls, approved test access, case files,
12
- evidence pointers, dossiers, and reports.
9
+ Graph access is configuration-driven. The package defaults to a local GraphRAG
10
+ MCP endpoint for development; hosted endpoints are set explicitly with
11
+ `graphMcpEndpoint` or `CHAIN_INSIGHTS_GRAPH_MCP_ENDPOINT`.
13
12
 
14
13
  ## What You Can Do Today
15
14
 
@@ -53,26 +52,32 @@ cd ./chain-insights-investigations
53
52
  cia init .
54
53
  ```
55
54
 
56
- ## Configure MCP server address
55
+ ## Configure GraphRAG MCP Endpoint
57
56
 
58
- `cia` uses `graphMcpEndpoint` for all GraphRAG MCP calls. Configure it explicitly per environment.
57
+ `cia` uses `graphMcpEndpoint` for all GraphRAG MCP calls. The npm package does
58
+ not hardcode a hosted endpoint. Configure the endpoint explicitly for the
59
+ environment you intend to use.
59
60
 
60
- 1. Local GraphRAG MCP (loopback HTTP allowed):
61
+ Local development endpoint (default):
61
62
 
62
63
  ```bash
63
64
  cia config set graphMcpEndpoint http://127.0.0.1:8012/mcp
64
65
  ```
65
66
 
66
- 2. Hosted staging/production GraphRAG MCP (HTTPS required):
67
+ Hosted staging endpoint for approved testers:
67
68
 
68
69
  ```bash
69
70
  cia config set graphMcpEndpoint https://staging-mcp.chain-insights.ai/mcp
70
71
  ```
71
72
 
72
- 3. Optional one-shot override from environment (highest precedence for that process):
73
+ Hosted access also needs an access mode, such as an approved access key or a
74
+ prepared wallet. Keep those credentials out of README examples; setup commands
75
+ live in [MCP proxy](docs/mcp-proxy.md).
76
+
77
+ Optional one-shot override from the environment:
73
78
 
74
79
  ```bash
75
- export CHAIN_INSIGHTS_GRAPH_MCP_ENDPOINT=https://prod-mcp.example.com/mcp
80
+ export CHAIN_INSIGHTS_GRAPH_MCP_ENDPOINT=https://staging-mcp.chain-insights.ai/mcp
76
81
  ```
77
82
 
78
83
  Validation rules:
@@ -91,15 +96,13 @@ Check the configured endpoint and current GraphRAG MCP capabilities:
91
96
 
92
97
  ```bash
93
98
  cia config get graphMcpEndpoint
94
- cia wallet balance
95
99
  cia mcp networks
96
100
  cia mcp tools --refresh
97
101
  ```
98
102
 
99
- GraphRAG MCP calls use x402 paid mode by default unless you configure approved
100
- test access or local debug access. If network or tool discovery fails, fix
101
- endpoint/auth/payment first; the CLI can still initialize workspaces and manage
102
- cases without a reachable GraphRAG MCP endpoint.
103
+ If network or tool discovery fails, check the endpoint and access mode first.
104
+ The CLI can still initialize workspaces and manage cases without a reachable
105
+ GraphRAG MCP endpoint.
103
106
 
104
107
  Open a case and run a small investigation:
105
108
 
@@ -175,7 +178,8 @@ Graph queries must choose the right read layer explicitly:
175
178
  | `facts` | Labels, features, risk scores, assets, and enrichment |
176
179
 
177
180
  Use `graph_query_batch` when related reads should share one call and one
178
- result envelope. Paid hosted calls are settled through x402.
181
+ result envelope. Endpoint access and authentication are configured separately;
182
+ see [MCP proxy](docs/mcp-proxy.md).
179
183
 
180
184
  ## AML Tools
181
185
 
@@ -201,7 +205,7 @@ reports under the workspace instead of embedding large payloads in case notes.
201
205
  | --- | --- |
202
206
  | [Graph tools](docs/graph-tools.md) | GraphRAG MCP layers, `graph_query`, `graph_query_batch`, AML tool contracts, graph reports, evidence pointers |
203
207
  | [Investigation workspaces](docs/investigation-workspaces.md) | `cia init`, case layout, evidence, dossiers, imports, templates, sessions, reports |
204
- | [MCP proxy](docs/mcp-proxy.md) | Stdio proxy behavior, agent installers, local tools, auth modes, Inspector validation |
208
+ | [MCP proxy](docs/mcp-proxy.md) | Stdio proxy behavior, endpoint configuration, agent installers, local tools, auth modes, Inspector validation |
205
209
  | [Architecture](docs/architecture.md) | Product layers, data flow, local storage, security model, config keys |
206
210
  | [Development](docs/development.md) | Build, test, and local install commands |
207
211
  | [Contributing](docs/contributing.md) | Development workflow, pull requests, release expectations |
package/dist/cli.cjs CHANGED
@@ -378,7 +378,7 @@ program.command("mcp").description("Interact with the Chain Insights MCP endpoin
378
378
  }));
379
379
  return;
380
380
  }
381
- const { addressRisk } = await Promise.resolve().then(() => require("./public-tools-BC1fi0DV.cjs"));
381
+ const { addressRisk } = await Promise.resolve().then(() => require("./public-tools-q4NMdmDX.cjs"));
382
382
  const result = await addressRisk(client, {
383
383
  address: opts.address,
384
384
  network: opts.network,
@@ -406,7 +406,7 @@ program.command("mcp").description("Interact with the Chain Insights MCP endpoin
406
406
  }));
407
407
  return;
408
408
  }
409
- const { trackFunds } = await Promise.resolve().then(() => require("./public-tools-BC1fi0DV.cjs"));
409
+ const { trackFunds } = await Promise.resolve().then(() => require("./public-tools-q4NMdmDX.cjs"));
410
410
  const caseId = opts.case ? await resolveCaseSelector(opts.case) : void 0;
411
411
  const result = await trackFunds(client, config, {
412
412
  trustedAddresses: opts.trustedAddresses,
@@ -429,7 +429,7 @@ program.command("mcp").description("Interact with the Chain Insights MCP endpoin
429
429
  const { requireWorkspaceRoot } = await Promise.resolve().then(() => require("./output-root-YIbl6PwF.cjs")).then((n) => n.output_root_exports);
430
430
  requireWorkspaceRoot();
431
431
  await withGraphMcpClient("chain-insights-cli-scam-topology", async (client, config) => {
432
- const { scamTopology } = await Promise.resolve().then(() => require("./public-tools-BC1fi0DV.cjs"));
432
+ const { scamTopology } = await Promise.resolve().then(() => require("./public-tools-q4NMdmDX.cjs"));
433
433
  const incidentTimestampMs = optionalNumber(opts.incidentTimestampMs);
434
434
  if (incidentTimestampMs === void 0) throw new Error("incident-timestamp-ms is required");
435
435
  const caseId = opts.case ? await resolveCaseSelector(opts.case) : void 0;
@@ -451,7 +451,7 @@ program.command("mcp").description("Interact with the Chain Insights MCP endpoin
451
451
  })).addCommand(new commander.Command("stake-insights").description("Explain Bittensor staking behavior around an address, coldkey, or hotkey").requiredOption("--network <network>", "Network to query. Run `cia mcp networks` for supported networks.").option("--address <address>", "Full Bittensor address to inspect as either coldkey or hotkey").option("--coldkey <address>", "Full Bittensor coldkey address to inspect").option("--hotkey <address>", "Full Bittensor hotkey address to inspect").option("--netuid <number>", "Optional subnet netuid filter").option("--start-timestamp-ms <milliseconds>", "Optional inclusive lower activity timestamp bound").option("--end-timestamp-ms <milliseconds>", "Optional inclusive upper activity timestamp bound").option("--start-block <number>", "Optional start block. Current stake graph parity may require timestamp windows instead.").option("--end-block <number>", "Optional end block. Current stake graph parity may require timestamp windows instead.").option("--depth <number>", "Optional expansion depth limit, default 1, max 3").action(async (opts) => {
452
452
  try {
453
453
  await withGraphMcpClient("chain-insights-cli-stake-insights", async (client) => {
454
- const { stakeInsights } = await Promise.resolve().then(() => require("./public-tools-BC1fi0DV.cjs"));
454
+ const { stakeInsights } = await Promise.resolve().then(() => require("./public-tools-q4NMdmDX.cjs"));
455
455
  const result = await stakeInsights(client, {
456
456
  network: opts.network,
457
457
  address: opts.address,
@@ -479,7 +479,7 @@ program.command("mcp").description("Interact with the Chain Insights MCP endpoin
479
479
  assertPublicMcpToolName(tool);
480
480
  await withGraphMcpClient("chain-insights-cli-call", async (client, config) => {
481
481
  if (tool === "address_risk") {
482
- const { addressRisk } = await Promise.resolve().then(() => require("./public-tools-BC1fi0DV.cjs"));
482
+ const { addressRisk } = await Promise.resolve().then(() => require("./public-tools-q4NMdmDX.cjs"));
483
483
  const result = await addressRisk(client, {
484
484
  address: String(args["address"] ?? ""),
485
485
  network: String(args["network"] ?? ""),
@@ -489,7 +489,7 @@ program.command("mcp").description("Interact with the Chain Insights MCP endpoin
489
489
  return;
490
490
  }
491
491
  if (tool === "track_funds") {
492
- const { trackFunds } = await Promise.resolve().then(() => require("./public-tools-BC1fi0DV.cjs"));
492
+ const { trackFunds } = await Promise.resolve().then(() => require("./public-tools-q4NMdmDX.cjs"));
493
493
  const result = await trackFunds(client, config, {
494
494
  trustedAddresses: args["trusted_addresses"] ?? "",
495
495
  untrustedAddresses: args["untrusted_addresses"],
@@ -504,7 +504,7 @@ program.command("mcp").description("Interact with the Chain Insights MCP endpoin
504
504
  return;
505
505
  }
506
506
  if (tool === "scam_topology") {
507
- const { scamTopology } = await Promise.resolve().then(() => require("./public-tools-BC1fi0DV.cjs"));
507
+ const { scamTopology } = await Promise.resolve().then(() => require("./public-tools-q4NMdmDX.cjs"));
508
508
  const victimAddress = String(args["victim_address"] ?? "").trim();
509
509
  if (!victimAddress) throw new Error("victim_address is required");
510
510
  const incidentTimestampMs = optionalNumberArg(args["incident_timestamp_ms"], "incident_timestamp_ms");
@@ -522,7 +522,7 @@ program.command("mcp").description("Interact with the Chain Insights MCP endpoin
522
522
  return;
523
523
  }
524
524
  if (tool === "stake_insights") {
525
- const { stakeInsights } = await Promise.resolve().then(() => require("./public-tools-BC1fi0DV.cjs"));
525
+ const { stakeInsights } = await Promise.resolve().then(() => require("./public-tools-q4NMdmDX.cjs"));
526
526
  const result = await stakeInsights(client, {
527
527
  network: String(args["network"] ?? ""),
528
528
  address: args["address"] === void 0 ? void 0 : String(args["address"]),
package/dist/cli.mjs CHANGED
@@ -376,7 +376,7 @@ program.command("mcp").description("Interact with the Chain Insights MCP endpoin
376
376
  }));
377
377
  return;
378
378
  }
379
- const { addressRisk } = await import("./public-tools-B13J0MJZ.mjs");
379
+ const { addressRisk } = await import("./public-tools-w7En2m3q.mjs");
380
380
  const result = await addressRisk(client, {
381
381
  address: opts.address,
382
382
  network: opts.network,
@@ -404,7 +404,7 @@ program.command("mcp").description("Interact with the Chain Insights MCP endpoin
404
404
  }));
405
405
  return;
406
406
  }
407
- const { trackFunds } = await import("./public-tools-B13J0MJZ.mjs");
407
+ const { trackFunds } = await import("./public-tools-w7En2m3q.mjs");
408
408
  const caseId = opts.case ? await resolveCaseSelector(opts.case) : void 0;
409
409
  const result = await trackFunds(client, config, {
410
410
  trustedAddresses: opts.trustedAddresses,
@@ -427,7 +427,7 @@ program.command("mcp").description("Interact with the Chain Insights MCP endpoin
427
427
  const { requireWorkspaceRoot } = await import("./output-root-BRhzhhXZ.mjs").then((n) => n.t);
428
428
  requireWorkspaceRoot();
429
429
  await withGraphMcpClient("chain-insights-cli-scam-topology", async (client, config) => {
430
- const { scamTopology } = await import("./public-tools-B13J0MJZ.mjs");
430
+ const { scamTopology } = await import("./public-tools-w7En2m3q.mjs");
431
431
  const incidentTimestampMs = optionalNumber(opts.incidentTimestampMs);
432
432
  if (incidentTimestampMs === void 0) throw new Error("incident-timestamp-ms is required");
433
433
  const caseId = opts.case ? await resolveCaseSelector(opts.case) : void 0;
@@ -449,7 +449,7 @@ program.command("mcp").description("Interact with the Chain Insights MCP endpoin
449
449
  })).addCommand(new Command("stake-insights").description("Explain Bittensor staking behavior around an address, coldkey, or hotkey").requiredOption("--network <network>", "Network to query. Run `cia mcp networks` for supported networks.").option("--address <address>", "Full Bittensor address to inspect as either coldkey or hotkey").option("--coldkey <address>", "Full Bittensor coldkey address to inspect").option("--hotkey <address>", "Full Bittensor hotkey address to inspect").option("--netuid <number>", "Optional subnet netuid filter").option("--start-timestamp-ms <milliseconds>", "Optional inclusive lower activity timestamp bound").option("--end-timestamp-ms <milliseconds>", "Optional inclusive upper activity timestamp bound").option("--start-block <number>", "Optional start block. Current stake graph parity may require timestamp windows instead.").option("--end-block <number>", "Optional end block. Current stake graph parity may require timestamp windows instead.").option("--depth <number>", "Optional expansion depth limit, default 1, max 3").action(async (opts) => {
450
450
  try {
451
451
  await withGraphMcpClient("chain-insights-cli-stake-insights", async (client) => {
452
- const { stakeInsights } = await import("./public-tools-B13J0MJZ.mjs");
452
+ const { stakeInsights } = await import("./public-tools-w7En2m3q.mjs");
453
453
  const result = await stakeInsights(client, {
454
454
  network: opts.network,
455
455
  address: opts.address,
@@ -477,7 +477,7 @@ program.command("mcp").description("Interact with the Chain Insights MCP endpoin
477
477
  assertPublicMcpToolName(tool);
478
478
  await withGraphMcpClient("chain-insights-cli-call", async (client, config) => {
479
479
  if (tool === "address_risk") {
480
- const { addressRisk } = await import("./public-tools-B13J0MJZ.mjs");
480
+ const { addressRisk } = await import("./public-tools-w7En2m3q.mjs");
481
481
  const result = await addressRisk(client, {
482
482
  address: String(args["address"] ?? ""),
483
483
  network: String(args["network"] ?? ""),
@@ -487,7 +487,7 @@ program.command("mcp").description("Interact with the Chain Insights MCP endpoin
487
487
  return;
488
488
  }
489
489
  if (tool === "track_funds") {
490
- const { trackFunds } = await import("./public-tools-B13J0MJZ.mjs");
490
+ const { trackFunds } = await import("./public-tools-w7En2m3q.mjs");
491
491
  const result = await trackFunds(client, config, {
492
492
  trustedAddresses: args["trusted_addresses"] ?? "",
493
493
  untrustedAddresses: args["untrusted_addresses"],
@@ -502,7 +502,7 @@ program.command("mcp").description("Interact with the Chain Insights MCP endpoin
502
502
  return;
503
503
  }
504
504
  if (tool === "scam_topology") {
505
- const { scamTopology } = await import("./public-tools-B13J0MJZ.mjs");
505
+ const { scamTopology } = await import("./public-tools-w7En2m3q.mjs");
506
506
  const victimAddress = String(args["victim_address"] ?? "").trim();
507
507
  if (!victimAddress) throw new Error("victim_address is required");
508
508
  const incidentTimestampMs = optionalNumberArg(args["incident_timestamp_ms"], "incident_timestamp_ms");
@@ -520,7 +520,7 @@ program.command("mcp").description("Interact with the Chain Insights MCP endpoin
520
520
  return;
521
521
  }
522
522
  if (tool === "stake_insights") {
523
- const { stakeInsights } = await import("./public-tools-B13J0MJZ.mjs");
523
+ const { stakeInsights } = await import("./public-tools-w7En2m3q.mjs");
524
524
  const result = await stakeInsights(client, {
525
525
  network: String(args["network"] ?? ""),
526
526
  address: args["address"] === void 0 ? void 0 : String(args["address"]),
@@ -995,7 +995,7 @@ async function createProxy() {
995
995
  }],
996
996
  isError: true
997
997
  };
998
- const { addressRisk } = await Promise.resolve().then(() => require("./public-tools-BC1fi0DV.cjs"));
998
+ const { addressRisk } = await Promise.resolve().then(() => require("./public-tools-q4NMdmDX.cjs"));
999
999
  const { writeGraphReport } = await Promise.resolve().then(() => require("./graph-reports-B3mkLP8Z.cjs"));
1000
1000
  const { ensureArtifactServer } = await Promise.resolve().then(() => require("./artifact-server-XbN16DwU.cjs"));
1001
1001
  const result = await addressRisk(remoteClient, {
@@ -1066,7 +1066,7 @@ async function createProxy() {
1066
1066
  }],
1067
1067
  isError: true
1068
1068
  };
1069
- const { trackFunds } = await Promise.resolve().then(() => require("./public-tools-BC1fi0DV.cjs"));
1069
+ const { trackFunds } = await Promise.resolve().then(() => require("./public-tools-q4NMdmDX.cjs"));
1070
1070
  const { writeGraphReport } = await Promise.resolve().then(() => require("./graph-reports-B3mkLP8Z.cjs"));
1071
1071
  const { ensureArtifactServer } = await Promise.resolve().then(() => require("./artifact-server-XbN16DwU.cjs"));
1072
1072
  const result = await trackFunds(remoteClient, config, {
@@ -1139,7 +1139,7 @@ async function createProxy() {
1139
1139
  }],
1140
1140
  isError: true
1141
1141
  };
1142
- const { scamTopology } = await Promise.resolve().then(() => require("./public-tools-BC1fi0DV.cjs"));
1142
+ const { scamTopology } = await Promise.resolve().then(() => require("./public-tools-q4NMdmDX.cjs"));
1143
1143
  const { writeGraphReport } = await Promise.resolve().then(() => require("./graph-reports-B3mkLP8Z.cjs"));
1144
1144
  const { ensureArtifactServer } = await Promise.resolve().then(() => require("./artifact-server-XbN16DwU.cjs"));
1145
1145
  const result = await scamTopology(remoteClient, config, {
@@ -1216,7 +1216,7 @@ async function createProxy() {
1216
1216
  }],
1217
1217
  isError: true
1218
1218
  };
1219
- const { stakeInsights } = await Promise.resolve().then(() => require("./public-tools-BC1fi0DV.cjs"));
1219
+ const { stakeInsights } = await Promise.resolve().then(() => require("./public-tools-q4NMdmDX.cjs"));
1220
1220
  const { writeGraphReport } = await Promise.resolve().then(() => require("./graph-reports-B3mkLP8Z.cjs"));
1221
1221
  const { ensureArtifactServer } = await Promise.resolve().then(() => require("./artifact-server-XbN16DwU.cjs"));
1222
1222
  const result = await stakeInsights(remoteClient, {
@@ -991,7 +991,7 @@ async function createProxy() {
991
991
  }],
992
992
  isError: true
993
993
  };
994
- const { addressRisk } = await import("./public-tools-B13J0MJZ.mjs");
994
+ const { addressRisk } = await import("./public-tools-w7En2m3q.mjs");
995
995
  const { writeGraphReport } = await import("./graph-reports-BDELxmpi.mjs");
996
996
  const { ensureArtifactServer } = await import("./artifact-server-CP6LXQ9d.mjs");
997
997
  const result = await addressRisk(remoteClient, {
@@ -1062,7 +1062,7 @@ async function createProxy() {
1062
1062
  }],
1063
1063
  isError: true
1064
1064
  };
1065
- const { trackFunds } = await import("./public-tools-B13J0MJZ.mjs");
1065
+ const { trackFunds } = await import("./public-tools-w7En2m3q.mjs");
1066
1066
  const { writeGraphReport } = await import("./graph-reports-BDELxmpi.mjs");
1067
1067
  const { ensureArtifactServer } = await import("./artifact-server-CP6LXQ9d.mjs");
1068
1068
  const result = await trackFunds(remoteClient, config, {
@@ -1135,7 +1135,7 @@ async function createProxy() {
1135
1135
  }],
1136
1136
  isError: true
1137
1137
  };
1138
- const { scamTopology } = await import("./public-tools-B13J0MJZ.mjs");
1138
+ const { scamTopology } = await import("./public-tools-w7En2m3q.mjs");
1139
1139
  const { writeGraphReport } = await import("./graph-reports-BDELxmpi.mjs");
1140
1140
  const { ensureArtifactServer } = await import("./artifact-server-CP6LXQ9d.mjs");
1141
1141
  const result = await scamTopology(remoteClient, config, {
@@ -1212,7 +1212,7 @@ async function createProxy() {
1212
1212
  }],
1213
1213
  isError: true
1214
1214
  };
1215
- const { stakeInsights } = await import("./public-tools-B13J0MJZ.mjs");
1215
+ const { stakeInsights } = await import("./public-tools-w7En2m3q.mjs");
1216
1216
  const { writeGraphReport } = await import("./graph-reports-BDELxmpi.mjs");
1217
1217
  const { ensureArtifactServer } = await import("./artifact-server-CP6LXQ9d.mjs");
1218
1218
  const result = await stakeInsights(remoteClient, {
@@ -1011,11 +1011,87 @@ function isExchangeFlag(value) {
1011
1011
  if (typeof value === "number") return value === 1;
1012
1012
  return false;
1013
1013
  }
1014
+ /**
1015
+ * Address-type values that mark a node as part of the scam topology itself
1016
+ * (a written scam label or the protected victim role). Such nodes must never be
1017
+ * read back as exchange infrastructure, even when their label text contains a
1018
+ * brand or the word "exchange" — that text is our own output syncing back into
1019
+ * the graph, not authoritative exchange signal.
1020
+ */
1021
+ const NON_EXCHANGE_ADDRESS_TYPES = new Set(["scam", "victim"]);
1022
+ /**
1023
+ * Determine whether a node's `address_type` marks it as scam- or victim-typed,
1024
+ * which disqualifies it from being treated as an exchange endpoint.
1025
+ *
1026
+ * @param addressType - The node's `address_type` property, if present.
1027
+ * @returns `true` when the type is a scam/victim role.
1028
+ */
1029
+ function isScamOrVictimType(addressType) {
1030
+ if (addressType === void 0) return false;
1031
+ return NON_EXCHANGE_ADDRESS_TYPES.has(addressType.trim().toLowerCase());
1032
+ }
1033
+ /**
1034
+ * Detect an authoritative exchange label.
1035
+ *
1036
+ * Only an exact `exchange` token or a trailing `, exchange` registry suffix
1037
+ * (e.g. `"Binance, exchange"`) counts. Loose substring matching such as
1038
+ * `includes('exchange')` is deliberately rejected because scam labels written
1039
+ * by this tool (e.g. `"... -> Kucoin"`, subtype text containing "exchange")
1040
+ * sync back into the graph and would otherwise be mis-read as exchanges.
1041
+ *
1042
+ * @param labels - Node label strings from the graph.
1043
+ * @returns `true` when at least one label is an authoritative exchange marker.
1044
+ */
1014
1045
  function hasExchangeLabel(labels) {
1015
- return labels.some((label) => label.toLowerCase() === "exchange" || label.toLowerCase().includes("exchange"));
1046
+ return labels.some((label) => {
1047
+ const normalized = label.trim().toLowerCase();
1048
+ return normalized === "exchange" || /(^|,)\s*exchange$/.test(normalized);
1049
+ });
1016
1050
  }
1017
- function isExchangeEndpoint(labels, isExchange, roles) {
1018
- return isExchangeFlag(isExchange) || hasExchangeLabel(labels) || roles.some((role) => role.toLowerCase().includes("exchange"));
1051
+ /**
1052
+ * Decide whether a node is an exchange endpoint that terminates traversal.
1053
+ *
1054
+ * Exchange status keys off the authoritative `is_exchange` flag first, then an
1055
+ * exact `exchange` registry label. A node whose `address_type` is SCAM or
1056
+ * VICTIM is never an exchange endpoint, regardless of its label or role text,
1057
+ * because that signal originates from this tool's own labels rather than from
1058
+ * exchange enrichment.
1059
+ *
1060
+ * @param labels - Destination node label strings.
1061
+ * @param isExchange - The node's authoritative `is_exchange` property.
1062
+ * @param roles - Enrichment role strings for the node.
1063
+ * @param addressType - The node's `address_type` property, if present.
1064
+ * @returns `true` when the node should be treated as an exchange endpoint.
1065
+ */
1066
+ function isExchangeEndpoint(labels, isExchange, roles, addressType) {
1067
+ if (isScamOrVictimType(addressType)) return false;
1068
+ return isExchangeFlag(isExchange) || hasExchangeLabel(labels) || roles.some((role) => role.trim().toLowerCase() === "exchange");
1069
+ }
1070
+ /**
1071
+ * Transaction-count threshold above which a deposit edge is treated as shared
1072
+ * exchange infrastructure rather than a scammer-dedicated cash-out address.
1073
+ */
1074
+ const SHARED_EXCHANGE_DEPOSIT_TX_COUNT = 1e3;
1075
+ /**
1076
+ * USD-volume threshold above which a deposit edge is treated as shared exchange
1077
+ * infrastructure rather than a scammer-dedicated cash-out address.
1078
+ */
1079
+ const SHARED_EXCHANGE_DEPOSIT_USD_SUM = 5e6;
1080
+ /**
1081
+ * Decide whether a penultimate-to-exchange edge represents shared exchange
1082
+ * deposit infrastructure (an omnibus or routing address many users fund)
1083
+ * rather than a scammer-dedicated cash-out address.
1084
+ *
1085
+ * A single scammer's cash-out deposit for one incident does not aggregate
1086
+ * thousands of transfers or tens of millions of USD; an edge that does is
1087
+ * exchange-side infrastructure and must not be auto-labeled scam.
1088
+ *
1089
+ * @param edge - A `terminal_exchange` topology edge (deposit -> exchange).
1090
+ * @returns `true` when the edge's `tx_count` or `amount_usd_sum` exceeds the
1091
+ * shared-infrastructure thresholds.
1092
+ */
1093
+ function isSharedExchangeDeposit(edge) {
1094
+ return edge.tx_count !== void 0 && edge.tx_count >= SHARED_EXCHANGE_DEPOSIT_TX_COUNT || edge.amount_usd_sum !== void 0 && edge.amount_usd_sum >= SHARED_EXCHANGE_DEPOSIT_USD_SUM;
1019
1095
  }
1020
1096
  function isGenericContextLabel(label) {
1021
1097
  const normalized = label.trim().toLowerCase();
@@ -1032,6 +1108,10 @@ function traversalProjection() {
1032
1108
  "dst.labels AS dst_labels",
1033
1109
  "src.is_exchange AS src_is_exchange",
1034
1110
  "dst.is_exchange AS dst_is_exchange",
1111
+ "src.address_type AS src_address_type",
1112
+ "dst.address_type AS dst_address_type",
1113
+ "src.address_subtypes AS src_address_subtypes",
1114
+ "dst.address_subtypes AS dst_address_subtypes",
1035
1115
  "r.amount_sum AS amount_sum",
1036
1116
  "r.amount_usd_sum AS amount_usd_sum",
1037
1117
  "r.tx_count AS tx_count",
@@ -1090,9 +1170,14 @@ function edgeFromRow(row, graphScope, hop, context) {
1090
1170
  const dstLabels = stringArray(row["dst_labels"]);
1091
1171
  const srcRoles = stringArray(row["src_roles"]);
1092
1172
  const dstRoles = stringArray(row["dst_roles"]);
1093
- const srcIsExchange = isExchangeEndpoint(srcLabels, row["src_is_exchange"], srcRoles);
1094
- const dstIsExchange = isExchangeEndpoint(dstLabels, row["dst_is_exchange"], dstRoles);
1095
- const genericLabeledBoundary = dstLabels.length > 0 && !dstIsExchange;
1173
+ const srcAddressType = stringValue$1(row["src_address_type"]);
1174
+ const dstAddressType = stringValue$1(row["dst_address_type"]);
1175
+ const srcAddressSubtypes = stringArray(row["src_address_subtypes"]);
1176
+ const dstAddressSubtypes = stringArray(row["dst_address_subtypes"]);
1177
+ const srcIsExchange = isExchangeEndpoint(srcLabels, row["src_is_exchange"], srcRoles, srcAddressType);
1178
+ const dstIsExchange = isExchangeEndpoint(dstLabels, row["dst_is_exchange"], dstRoles, dstAddressType);
1179
+ const dstIsScamTyped = isScamOrVictimType(dstAddressType);
1180
+ const genericLabeledBoundary = dstLabels.length > 0 && !dstIsExchange && !dstIsScamTyped;
1096
1181
  return {
1097
1182
  relation: dstIsExchange ? "terminal_exchange" : genericLabeledBoundary ? "context_boundary" : hop === 1 ? "seed_outflow" : "traversal_edge",
1098
1183
  src,
@@ -1112,7 +1197,11 @@ function edgeFromRow(row, graphScope, hop, context) {
1112
1197
  src_labels: srcLabels,
1113
1198
  dst_labels: dstLabels,
1114
1199
  src_is_exchange: srcIsExchange,
1115
- dst_is_exchange: dstIsExchange
1200
+ dst_is_exchange: dstIsExchange,
1201
+ ...srcAddressType !== void 0 ? { src_address_type: srcAddressType } : {},
1202
+ ...dstAddressType !== void 0 ? { dst_address_type: dstAddressType } : {},
1203
+ ...srcAddressSubtypes.length > 0 ? { src_address_subtypes: srcAddressSubtypes } : {},
1204
+ ...dstAddressSubtypes.length > 0 ? { dst_address_subtypes: dstAddressSubtypes } : {}
1116
1205
  };
1117
1206
  }
1118
1207
  function edgeKey(edge) {
@@ -1318,6 +1407,126 @@ function labelForSubtype(subtype) {
1318
1407
  case "exchange_deposit_candidate": return "Scam exchange deposit candidate";
1319
1408
  }
1320
1409
  }
1410
+ /**
1411
+ * Per-hop multiplicative decay applied to a candidate's base confidence. Each
1412
+ * additional hop from the seed multiplies confidence by this factor, so deeper
1413
+ * addresses score strictly lower than closer ones at equal value.
1414
+ */
1415
+ const SCAM_TOPOLOGY_HOP_DECAY = .85;
1416
+ /**
1417
+ * Floor on the hop-decay multiplier so very deep chains do not collapse to
1418
+ * zero; keeps confidence bounded and positive while still ranking deep edges
1419
+ * below shallow ones.
1420
+ */
1421
+ const SCAM_TOPOLOGY_MIN_HOP_FACTOR = .25;
1422
+ /**
1423
+ * Native/USD value at or above which the value factor saturates to its maximum
1424
+ * contribution. Chosen so a large incident-scale transfer earns full value
1425
+ * weight while small dust transfers earn little.
1426
+ */
1427
+ const SCAM_TOPOLOGY_VALUE_SATURATION = 1e5;
1428
+ /**
1429
+ * Fraction of confidence governed by carried value (the remainder is the fixed
1430
+ * base). A high-value edge keeps near-base confidence; a dust edge is damped.
1431
+ */
1432
+ const SCAM_TOPOLOGY_VALUE_WEIGHT = .5;
1433
+ /**
1434
+ * Confidence threshold at or above which a close-hop candidate is auto-promoted
1435
+ * to `promote_confirmed` instead of `review_required`. Tuned so that only a
1436
+ * near-full-value, close-hop core reaches it: a hop-1 edge carrying
1437
+ * incident-scale value retains its full base confidence (victim-seeded base is
1438
+ * 0.72), while dust or deeper edges fall below the bar and stay review-only.
1439
+ */
1440
+ const SCAM_TOPOLOGY_PROMOTE_CONFIDENCE = .72;
1441
+ /**
1442
+ * Maximum hop distance eligible for auto-promotion. Only the close-hop core of
1443
+ * a topology can promote automatically; the diluted tail stays review-only.
1444
+ */
1445
+ const SCAM_TOPOLOGY_PROMOTE_MAX_HOP = 2;
1446
+ /**
1447
+ * Choose the value used for confidence scoring.
1448
+ *
1449
+ * Deep-hop `amount_usd_sum` is frequently inconsistent with the native amount
1450
+ * (e.g. hundreds of tokens reported as a few dollars) because price coverage is
1451
+ * missing at depth or the price join is wrong. Trusting such USD would deflate
1452
+ * confidence for genuinely high-value transfers, so the native `amount_sum` is
1453
+ * always preferred when present and positive. USD is used only as a fallback
1454
+ * when no usable native amount exists. Native units are consistent within a
1455
+ * single asset's topology, so value comparisons across edges remain meaningful.
1456
+ *
1457
+ * @param amountSum - Native transferred amount on the edge, if present.
1458
+ * @param amountUsdSum - Reported USD value on the edge, if present.
1459
+ * @returns A positive scoring value, or `undefined` when neither amount is
1460
+ * usable.
1461
+ */
1462
+ function reliableScoringValue(amountSum, amountUsdSum) {
1463
+ const native = amountSum !== void 0 && Number.isFinite(amountSum) && amountSum > 0 ? amountSum : void 0;
1464
+ if (native !== void 0) return native;
1465
+ return amountUsdSum !== void 0 && Number.isFinite(amountUsdSum) && amountUsdSum > 0 ? amountUsdSum : void 0;
1466
+ }
1467
+ /**
1468
+ * Compute a bounded [0, 1] value factor from a reliable scoring value using a
1469
+ * logarithmic scale, so confidence increases monotonically with carried value
1470
+ * and saturates for incident-scale transfers.
1471
+ *
1472
+ * @param value - A non-negative scoring value, or `undefined`.
1473
+ * @returns A factor in [0, 1]; `0` when no value is available.
1474
+ */
1475
+ function valueFactor(value) {
1476
+ if (value === void 0 || value <= 0) return 0;
1477
+ return Math.log10(1 + Math.min(value, SCAM_TOPOLOGY_VALUE_SATURATION)) / Math.log10(100001);
1478
+ }
1479
+ /**
1480
+ * Confidence model for a topology edge: a base confidence that decays
1481
+ * multiplicatively with hop distance and scales with carried value.
1482
+ *
1483
+ * The result is `base * hopFactor * (1 - VALUE_WEIGHT + VALUE_WEIGHT * valueFactor)`,
1484
+ * where `hopFactor = max(MIN_HOP_FACTOR, HOP_DECAY^(hop - 1))`. It is bounded in
1485
+ * `(0, base]`, strictly decreasing in `hop`, and increasing in carried value, so
1486
+ * a hop-1 high-value edge always outranks a deeper low-value edge. Carried value
1487
+ * is read from the native amount when available (see {@link reliableScoringValue}),
1488
+ * so unreliable deep-hop USD pricing cannot distort the score.
1489
+ *
1490
+ * @param edge - The topology edge being scored.
1491
+ * @param baseConfidence - The relation-and-role base confidence in `(0, 1]`.
1492
+ * @returns A confidence score in `(0, 1]`.
1493
+ */
1494
+ function decayedConfidence(edge, baseConfidence) {
1495
+ const hop = Number.isFinite(edge.hop) && edge.hop > 0 ? edge.hop : 1;
1496
+ const hopFactor = Math.max(SCAM_TOPOLOGY_MIN_HOP_FACTOR, Math.pow(SCAM_TOPOLOGY_HOP_DECAY, hop - 1));
1497
+ const value = reliableScoringValue(edge.amount_sum, edge.amount_usd_sum);
1498
+ const valueScale = 1 - SCAM_TOPOLOGY_VALUE_WEIGHT + SCAM_TOPOLOGY_VALUE_WEIGHT * valueFactor(value);
1499
+ const confidence = baseConfidence * hopFactor * valueScale;
1500
+ return Math.min(baseConfidence, Math.max(0, confidence));
1501
+ }
1502
+ /**
1503
+ * Decide the promotion tier for a scored candidate. Only a close-hop,
1504
+ * high-confidence core auto-promotes to `promote_confirmed`; everything else
1505
+ * stays `review_required` for human triage.
1506
+ *
1507
+ * @param edge - The topology edge backing the candidate.
1508
+ * @param confidence - The candidate's decayed confidence score.
1509
+ * @returns The promotion status for the candidate.
1510
+ */
1511
+ function promotionTier(edge, confidence) {
1512
+ const hop = Number.isFinite(edge.hop) && edge.hop > 0 ? edge.hop : 1;
1513
+ return confidence >= SCAM_TOPOLOGY_PROMOTE_CONFIDENCE && hop <= SCAM_TOPOLOGY_PROMOTE_MAX_HOP ? "promote_confirmed" : "review_required";
1514
+ }
1515
+ /**
1516
+ * Build a scam label candidate from a topology edge with hop-and-value decayed
1517
+ * confidence and an automatic promotion tier.
1518
+ *
1519
+ * @param address - The candidate address.
1520
+ * @param subtype - The candidate scam subtype.
1521
+ * @param evidence - Structured evidence for the candidate.
1522
+ * @param edge - The topology edge backing the candidate.
1523
+ * @param baseConfidence - The relation-and-role base confidence in `(0, 1]`.
1524
+ * @returns A scored label candidate.
1525
+ */
1526
+ function makeScoredCandidate(address, subtype, evidence, edge, baseConfidence) {
1527
+ const confidence = decayedConfidence(edge, baseConfidence);
1528
+ return makeCandidate(address, subtype, evidence, confidence, promotionTier(edge, confidence));
1529
+ }
1321
1530
  function makeCandidate(address, subtype, evidence, confidence, promotionStatus) {
1322
1531
  return {
1323
1532
  address,
@@ -1429,7 +1638,7 @@ function classifyTopology(seeds, edges) {
1429
1638
  seed_role: edge.seed_role
1430
1639
  });
1431
1640
  addRole(rolesByAddress, edge.src, "laundering_intermediate");
1432
- mergeCandidate(candidates, makeCandidate(edge.src, "laundering_intermediate", edgeEvidence(edge, "Address sends into an exchange-deposit cluster reached from a known scam topology seed."), edge.seed_role === "scammer" ? .78 : .64, "review_required"));
1641
+ mergeCandidate(candidates, makeScoredCandidate(edge.src, "laundering_intermediate", edgeEvidence(edge, "Address sends into an exchange-deposit cluster reached from a known scam topology seed."), edge, edge.seed_role === "scammer" ? .78 : .64));
1433
1642
  continue;
1434
1643
  }
1435
1644
  if (edge.relation === "terminal_exchange") {
@@ -1488,7 +1697,15 @@ function classifyTopology(seeds, edges) {
1488
1697
  seed_role: edge.seed_role
1489
1698
  });
1490
1699
  addRole(rolesByAddress, edge.src, "exchange_deposit_candidate");
1491
- mergeCandidate(candidates, makeCandidate(edge.src, "exchange_deposit_candidate", edgeEvidence(edge, "Address is the penultimate hop before an exchange endpoint."), edge.seed_role === "scammer" ? .8 : .68, "review_required"));
1700
+ if (isSharedExchangeDeposit(edge)) pushSafetyDecision(safetyDecisions, {
1701
+ address: edge.src,
1702
+ decision: "do_not_label_shared_exchange_deposit",
1703
+ reason: "Penultimate address shows shared exchange-deposit throughput (high tx_count or USD volume); treated as exchange-adjacent context, not an automatic scam candidate.",
1704
+ tx_count: edge.tx_count,
1705
+ amount_usd_sum: edge.amount_usd_sum,
1706
+ seed_address: edge.seed_address
1707
+ });
1708
+ else mergeCandidate(candidates, makeScoredCandidate(edge.src, "exchange_deposit_candidate", edgeEvidence(edge, "Address is the penultimate hop before an exchange endpoint."), edge, edge.seed_role === "scammer" ? .8 : .68));
1492
1709
  }
1493
1710
  continue;
1494
1711
  }
@@ -1533,7 +1750,7 @@ function classifyTopology(seeds, edges) {
1533
1750
  seed_role: edge.seed_role
1534
1751
  });
1535
1752
  addRole(rolesByAddress, edge.dst, "laundering_intermediate");
1536
- mergeCandidate(candidates, makeCandidate(edge.dst, "laundering_intermediate", edgeEvidence(edge, "Address appears on an outward path from a known scam topology seed."), edge.seed_role === "scammer" ? .85 : .72, "review_required"));
1753
+ mergeCandidate(candidates, makeScoredCandidate(edge.dst, "laundering_intermediate", edgeEvidence(edge, "Address appears on an outward path from a known scam topology seed."), edge, edge.seed_role === "scammer" ? .85 : .72));
1537
1754
  }
1538
1755
  return {
1539
1756
  labelCandidates: [...candidates.values()].sort((a, b) => b.confidence_score - a.confidence_score || a.address.localeCompare(b.address)),