chain-insights 0.2.21 → 0.2.24

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 (38) hide show
  1. package/README.md +2 -1
  2. package/dist/{capabilities-Bm0JDbV7.cjs → capabilities-B4hvro_I.cjs} +1 -1
  3. package/dist/{capabilities-BShqspb-.mjs → capabilities-mXm_rCe8.mjs} +2 -2
  4. package/dist/{capabilities-BShqspb-.mjs.map → capabilities-mXm_rCe8.mjs.map} +1 -1
  5. package/dist/cli.cjs +32 -16
  6. package/dist/cli.mjs +32 -16
  7. package/dist/cli.mjs.map +1 -1
  8. package/dist/{client-DPc2eyVN.cjs → client-BYnFGA0y.cjs} +45 -10
  9. package/dist/{client-D4_hd4AP.mjs → client-Dl-uHrh1.mjs} +46 -11
  10. package/dist/client-Dl-uHrh1.mjs.map +1 -0
  11. package/dist/index.cjs +2 -2
  12. package/dist/index.d.cts.map +1 -1
  13. package/dist/index.d.mts.map +1 -1
  14. package/dist/index.mjs +2 -2
  15. package/dist/{init-4tn7jfhN.mjs → init-CB_ga4_8.mjs} +2 -2
  16. package/dist/init-CB_ga4_8.mjs.map +1 -0
  17. package/dist/{init-TCQY5RDJ.cjs → init-jhOZ_RvC.cjs} +1 -1
  18. package/dist/mcp-proxy.cjs +8 -8
  19. package/dist/mcp-proxy.mjs +8 -8
  20. package/dist/mcp-proxy.mjs.map +1 -1
  21. package/dist/{public-tools-BC1fi0DV.cjs → public-tools-q4NMdmDX.cjs} +227 -10
  22. package/dist/{public-tools-B13J0MJZ.mjs → public-tools-w7En2m3q.mjs} +228 -11
  23. package/dist/public-tools-w7En2m3q.mjs.map +1 -0
  24. package/dist/{runner-DIs04IhN.mjs → runner-BBH5Ks6q.mjs} +2 -2
  25. package/dist/{runner-DIs04IhN.mjs.map → runner-BBH5Ks6q.mjs.map} +1 -1
  26. package/dist/{runner-ZYowxCVl.cjs → runner-e9slg6R2.cjs} +1 -1
  27. package/dist/tools-D6RBAhSX.mjs +298 -0
  28. package/dist/tools-D6RBAhSX.mjs.map +1 -0
  29. package/dist/tools-UH5hRXYG.cjs +343 -0
  30. package/dist/topup-server-BJgVw6Jt.mjs.map +1 -1
  31. package/docs/mcp-proxy.md +4 -2
  32. package/package.json +1 -1
  33. package/dist/client-D4_hd4AP.mjs.map +0 -1
  34. package/dist/init-4tn7jfhN.mjs.map +0 -1
  35. package/dist/public-tools-B13J0MJZ.mjs.map +0 -1
  36. package/dist/tools-DY8h0WbE.cjs +0 -139
  37. package/dist/tools-Py6SXg6J.mjs +0 -100
  38. package/dist/tools-Py6SXg6J.mjs.map +0 -1
@@ -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)),
@@ -1009,11 +1009,87 @@ function isExchangeFlag(value) {
1009
1009
  if (typeof value === "number") return value === 1;
1010
1010
  return false;
1011
1011
  }
1012
+ /**
1013
+ * Address-type values that mark a node as part of the scam topology itself
1014
+ * (a written scam label or the protected victim role). Such nodes must never be
1015
+ * read back as exchange infrastructure, even when their label text contains a
1016
+ * brand or the word "exchange" — that text is our own output syncing back into
1017
+ * the graph, not authoritative exchange signal.
1018
+ */
1019
+ const NON_EXCHANGE_ADDRESS_TYPES = new Set(["scam", "victim"]);
1020
+ /**
1021
+ * Determine whether a node's `address_type` marks it as scam- or victim-typed,
1022
+ * which disqualifies it from being treated as an exchange endpoint.
1023
+ *
1024
+ * @param addressType - The node's `address_type` property, if present.
1025
+ * @returns `true` when the type is a scam/victim role.
1026
+ */
1027
+ function isScamOrVictimType(addressType) {
1028
+ if (addressType === void 0) return false;
1029
+ return NON_EXCHANGE_ADDRESS_TYPES.has(addressType.trim().toLowerCase());
1030
+ }
1031
+ /**
1032
+ * Detect an authoritative exchange label.
1033
+ *
1034
+ * Only an exact `exchange` token or a trailing `, exchange` registry suffix
1035
+ * (e.g. `"Binance, exchange"`) counts. Loose substring matching such as
1036
+ * `includes('exchange')` is deliberately rejected because scam labels written
1037
+ * by this tool (e.g. `"... -> Kucoin"`, subtype text containing "exchange")
1038
+ * sync back into the graph and would otherwise be mis-read as exchanges.
1039
+ *
1040
+ * @param labels - Node label strings from the graph.
1041
+ * @returns `true` when at least one label is an authoritative exchange marker.
1042
+ */
1012
1043
  function hasExchangeLabel(labels) {
1013
- return labels.some((label) => label.toLowerCase() === "exchange" || label.toLowerCase().includes("exchange"));
1044
+ return labels.some((label) => {
1045
+ const normalized = label.trim().toLowerCase();
1046
+ return normalized === "exchange" || /(^|,)\s*exchange$/.test(normalized);
1047
+ });
1014
1048
  }
1015
- function isExchangeEndpoint(labels, isExchange, roles) {
1016
- return isExchangeFlag(isExchange) || hasExchangeLabel(labels) || roles.some((role) => role.toLowerCase().includes("exchange"));
1049
+ /**
1050
+ * Decide whether a node is an exchange endpoint that terminates traversal.
1051
+ *
1052
+ * Exchange status keys off the authoritative `is_exchange` flag first, then an
1053
+ * exact `exchange` registry label. A node whose `address_type` is SCAM or
1054
+ * VICTIM is never an exchange endpoint, regardless of its label or role text,
1055
+ * because that signal originates from this tool's own labels rather than from
1056
+ * exchange enrichment.
1057
+ *
1058
+ * @param labels - Destination node label strings.
1059
+ * @param isExchange - The node's authoritative `is_exchange` property.
1060
+ * @param roles - Enrichment role strings for the node.
1061
+ * @param addressType - The node's `address_type` property, if present.
1062
+ * @returns `true` when the node should be treated as an exchange endpoint.
1063
+ */
1064
+ function isExchangeEndpoint(labels, isExchange, roles, addressType) {
1065
+ if (isScamOrVictimType(addressType)) return false;
1066
+ return isExchangeFlag(isExchange) || hasExchangeLabel(labels) || roles.some((role) => role.trim().toLowerCase() === "exchange");
1067
+ }
1068
+ /**
1069
+ * Transaction-count threshold above which a deposit edge is treated as shared
1070
+ * exchange infrastructure rather than a scammer-dedicated cash-out address.
1071
+ */
1072
+ const SHARED_EXCHANGE_DEPOSIT_TX_COUNT = 1e3;
1073
+ /**
1074
+ * USD-volume threshold above which a deposit edge is treated as shared exchange
1075
+ * infrastructure rather than a scammer-dedicated cash-out address.
1076
+ */
1077
+ const SHARED_EXCHANGE_DEPOSIT_USD_SUM = 5e6;
1078
+ /**
1079
+ * Decide whether a penultimate-to-exchange edge represents shared exchange
1080
+ * deposit infrastructure (an omnibus or routing address many users fund)
1081
+ * rather than a scammer-dedicated cash-out address.
1082
+ *
1083
+ * A single scammer's cash-out deposit for one incident does not aggregate
1084
+ * thousands of transfers or tens of millions of USD; an edge that does is
1085
+ * exchange-side infrastructure and must not be auto-labeled scam.
1086
+ *
1087
+ * @param edge - A `terminal_exchange` topology edge (deposit -> exchange).
1088
+ * @returns `true` when the edge's `tx_count` or `amount_usd_sum` exceeds the
1089
+ * shared-infrastructure thresholds.
1090
+ */
1091
+ function isSharedExchangeDeposit(edge) {
1092
+ 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;
1017
1093
  }
1018
1094
  function isGenericContextLabel(label) {
1019
1095
  const normalized = label.trim().toLowerCase();
@@ -1030,6 +1106,10 @@ function traversalProjection() {
1030
1106
  "dst.labels AS dst_labels",
1031
1107
  "src.is_exchange AS src_is_exchange",
1032
1108
  "dst.is_exchange AS dst_is_exchange",
1109
+ "src.address_type AS src_address_type",
1110
+ "dst.address_type AS dst_address_type",
1111
+ "src.address_subtypes AS src_address_subtypes",
1112
+ "dst.address_subtypes AS dst_address_subtypes",
1033
1113
  "r.amount_sum AS amount_sum",
1034
1114
  "r.amount_usd_sum AS amount_usd_sum",
1035
1115
  "r.tx_count AS tx_count",
@@ -1088,9 +1168,14 @@ function edgeFromRow(row, graphScope, hop, context) {
1088
1168
  const dstLabels = stringArray(row["dst_labels"]);
1089
1169
  const srcRoles = stringArray(row["src_roles"]);
1090
1170
  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;
1171
+ const srcAddressType = stringValue$1(row["src_address_type"]);
1172
+ const dstAddressType = stringValue$1(row["dst_address_type"]);
1173
+ const srcAddressSubtypes = stringArray(row["src_address_subtypes"]);
1174
+ const dstAddressSubtypes = stringArray(row["dst_address_subtypes"]);
1175
+ const srcIsExchange = isExchangeEndpoint(srcLabels, row["src_is_exchange"], srcRoles, srcAddressType);
1176
+ const dstIsExchange = isExchangeEndpoint(dstLabels, row["dst_is_exchange"], dstRoles, dstAddressType);
1177
+ const dstIsScamTyped = isScamOrVictimType(dstAddressType);
1178
+ const genericLabeledBoundary = dstLabels.length > 0 && !dstIsExchange && !dstIsScamTyped;
1094
1179
  return {
1095
1180
  relation: dstIsExchange ? "terminal_exchange" : genericLabeledBoundary ? "context_boundary" : hop === 1 ? "seed_outflow" : "traversal_edge",
1096
1181
  src,
@@ -1110,7 +1195,11 @@ function edgeFromRow(row, graphScope, hop, context) {
1110
1195
  src_labels: srcLabels,
1111
1196
  dst_labels: dstLabels,
1112
1197
  src_is_exchange: srcIsExchange,
1113
- dst_is_exchange: dstIsExchange
1198
+ dst_is_exchange: dstIsExchange,
1199
+ ...srcAddressType !== void 0 ? { src_address_type: srcAddressType } : {},
1200
+ ...dstAddressType !== void 0 ? { dst_address_type: dstAddressType } : {},
1201
+ ...srcAddressSubtypes.length > 0 ? { src_address_subtypes: srcAddressSubtypes } : {},
1202
+ ...dstAddressSubtypes.length > 0 ? { dst_address_subtypes: dstAddressSubtypes } : {}
1114
1203
  };
1115
1204
  }
1116
1205
  function edgeKey(edge) {
@@ -1316,6 +1405,126 @@ function labelForSubtype(subtype) {
1316
1405
  case "exchange_deposit_candidate": return "Scam exchange deposit candidate";
1317
1406
  }
1318
1407
  }
1408
+ /**
1409
+ * Per-hop multiplicative decay applied to a candidate's base confidence. Each
1410
+ * additional hop from the seed multiplies confidence by this factor, so deeper
1411
+ * addresses score strictly lower than closer ones at equal value.
1412
+ */
1413
+ const SCAM_TOPOLOGY_HOP_DECAY = .85;
1414
+ /**
1415
+ * Floor on the hop-decay multiplier so very deep chains do not collapse to
1416
+ * zero; keeps confidence bounded and positive while still ranking deep edges
1417
+ * below shallow ones.
1418
+ */
1419
+ const SCAM_TOPOLOGY_MIN_HOP_FACTOR = .25;
1420
+ /**
1421
+ * Native/USD value at or above which the value factor saturates to its maximum
1422
+ * contribution. Chosen so a large incident-scale transfer earns full value
1423
+ * weight while small dust transfers earn little.
1424
+ */
1425
+ const SCAM_TOPOLOGY_VALUE_SATURATION = 1e5;
1426
+ /**
1427
+ * Fraction of confidence governed by carried value (the remainder is the fixed
1428
+ * base). A high-value edge keeps near-base confidence; a dust edge is damped.
1429
+ */
1430
+ const SCAM_TOPOLOGY_VALUE_WEIGHT = .5;
1431
+ /**
1432
+ * Confidence threshold at or above which a close-hop candidate is auto-promoted
1433
+ * to `promote_confirmed` instead of `review_required`. Tuned so that only a
1434
+ * near-full-value, close-hop core reaches it: a hop-1 edge carrying
1435
+ * incident-scale value retains its full base confidence (victim-seeded base is
1436
+ * 0.72), while dust or deeper edges fall below the bar and stay review-only.
1437
+ */
1438
+ const SCAM_TOPOLOGY_PROMOTE_CONFIDENCE = .72;
1439
+ /**
1440
+ * Maximum hop distance eligible for auto-promotion. Only the close-hop core of
1441
+ * a topology can promote automatically; the diluted tail stays review-only.
1442
+ */
1443
+ const SCAM_TOPOLOGY_PROMOTE_MAX_HOP = 2;
1444
+ /**
1445
+ * Choose the value used for confidence scoring.
1446
+ *
1447
+ * Deep-hop `amount_usd_sum` is frequently inconsistent with the native amount
1448
+ * (e.g. hundreds of tokens reported as a few dollars) because price coverage is
1449
+ * missing at depth or the price join is wrong. Trusting such USD would deflate
1450
+ * confidence for genuinely high-value transfers, so the native `amount_sum` is
1451
+ * always preferred when present and positive. USD is used only as a fallback
1452
+ * when no usable native amount exists. Native units are consistent within a
1453
+ * single asset's topology, so value comparisons across edges remain meaningful.
1454
+ *
1455
+ * @param amountSum - Native transferred amount on the edge, if present.
1456
+ * @param amountUsdSum - Reported USD value on the edge, if present.
1457
+ * @returns A positive scoring value, or `undefined` when neither amount is
1458
+ * usable.
1459
+ */
1460
+ function reliableScoringValue(amountSum, amountUsdSum) {
1461
+ const native = amountSum !== void 0 && Number.isFinite(amountSum) && amountSum > 0 ? amountSum : void 0;
1462
+ if (native !== void 0) return native;
1463
+ return amountUsdSum !== void 0 && Number.isFinite(amountUsdSum) && amountUsdSum > 0 ? amountUsdSum : void 0;
1464
+ }
1465
+ /**
1466
+ * Compute a bounded [0, 1] value factor from a reliable scoring value using a
1467
+ * logarithmic scale, so confidence increases monotonically with carried value
1468
+ * and saturates for incident-scale transfers.
1469
+ *
1470
+ * @param value - A non-negative scoring value, or `undefined`.
1471
+ * @returns A factor in [0, 1]; `0` when no value is available.
1472
+ */
1473
+ function valueFactor(value) {
1474
+ if (value === void 0 || value <= 0) return 0;
1475
+ return Math.log10(1 + Math.min(value, SCAM_TOPOLOGY_VALUE_SATURATION)) / Math.log10(100001);
1476
+ }
1477
+ /**
1478
+ * Confidence model for a topology edge: a base confidence that decays
1479
+ * multiplicatively with hop distance and scales with carried value.
1480
+ *
1481
+ * The result is `base * hopFactor * (1 - VALUE_WEIGHT + VALUE_WEIGHT * valueFactor)`,
1482
+ * where `hopFactor = max(MIN_HOP_FACTOR, HOP_DECAY^(hop - 1))`. It is bounded in
1483
+ * `(0, base]`, strictly decreasing in `hop`, and increasing in carried value, so
1484
+ * a hop-1 high-value edge always outranks a deeper low-value edge. Carried value
1485
+ * is read from the native amount when available (see {@link reliableScoringValue}),
1486
+ * so unreliable deep-hop USD pricing cannot distort the score.
1487
+ *
1488
+ * @param edge - The topology edge being scored.
1489
+ * @param baseConfidence - The relation-and-role base confidence in `(0, 1]`.
1490
+ * @returns A confidence score in `(0, 1]`.
1491
+ */
1492
+ function decayedConfidence(edge, baseConfidence) {
1493
+ const hop = Number.isFinite(edge.hop) && edge.hop > 0 ? edge.hop : 1;
1494
+ const hopFactor = Math.max(SCAM_TOPOLOGY_MIN_HOP_FACTOR, Math.pow(SCAM_TOPOLOGY_HOP_DECAY, hop - 1));
1495
+ const value = reliableScoringValue(edge.amount_sum, edge.amount_usd_sum);
1496
+ const valueScale = 1 - SCAM_TOPOLOGY_VALUE_WEIGHT + SCAM_TOPOLOGY_VALUE_WEIGHT * valueFactor(value);
1497
+ const confidence = baseConfidence * hopFactor * valueScale;
1498
+ return Math.min(baseConfidence, Math.max(0, confidence));
1499
+ }
1500
+ /**
1501
+ * Decide the promotion tier for a scored candidate. Only a close-hop,
1502
+ * high-confidence core auto-promotes to `promote_confirmed`; everything else
1503
+ * stays `review_required` for human triage.
1504
+ *
1505
+ * @param edge - The topology edge backing the candidate.
1506
+ * @param confidence - The candidate's decayed confidence score.
1507
+ * @returns The promotion status for the candidate.
1508
+ */
1509
+ function promotionTier(edge, confidence) {
1510
+ const hop = Number.isFinite(edge.hop) && edge.hop > 0 ? edge.hop : 1;
1511
+ return confidence >= SCAM_TOPOLOGY_PROMOTE_CONFIDENCE && hop <= SCAM_TOPOLOGY_PROMOTE_MAX_HOP ? "promote_confirmed" : "review_required";
1512
+ }
1513
+ /**
1514
+ * Build a scam label candidate from a topology edge with hop-and-value decayed
1515
+ * confidence and an automatic promotion tier.
1516
+ *
1517
+ * @param address - The candidate address.
1518
+ * @param subtype - The candidate scam subtype.
1519
+ * @param evidence - Structured evidence for the candidate.
1520
+ * @param edge - The topology edge backing the candidate.
1521
+ * @param baseConfidence - The relation-and-role base confidence in `(0, 1]`.
1522
+ * @returns A scored label candidate.
1523
+ */
1524
+ function makeScoredCandidate(address, subtype, evidence, edge, baseConfidence) {
1525
+ const confidence = decayedConfidence(edge, baseConfidence);
1526
+ return makeCandidate(address, subtype, evidence, confidence, promotionTier(edge, confidence));
1527
+ }
1319
1528
  function makeCandidate(address, subtype, evidence, confidence, promotionStatus) {
1320
1529
  return {
1321
1530
  address,
@@ -1427,7 +1636,7 @@ function classifyTopology(seeds, edges) {
1427
1636
  seed_role: edge.seed_role
1428
1637
  });
1429
1638
  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"));
1639
+ 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));
1431
1640
  continue;
1432
1641
  }
1433
1642
  if (edge.relation === "terminal_exchange") {
@@ -1486,7 +1695,15 @@ function classifyTopology(seeds, edges) {
1486
1695
  seed_role: edge.seed_role
1487
1696
  });
1488
1697
  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"));
1698
+ if (isSharedExchangeDeposit(edge)) pushSafetyDecision(safetyDecisions, {
1699
+ address: edge.src,
1700
+ decision: "do_not_label_shared_exchange_deposit",
1701
+ reason: "Penultimate address shows shared exchange-deposit throughput (high tx_count or USD volume); treated as exchange-adjacent context, not an automatic scam candidate.",
1702
+ tx_count: edge.tx_count,
1703
+ amount_usd_sum: edge.amount_usd_sum,
1704
+ seed_address: edge.seed_address
1705
+ });
1706
+ 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));
1490
1707
  }
1491
1708
  continue;
1492
1709
  }
@@ -1531,7 +1748,7 @@ function classifyTopology(seeds, edges) {
1531
1748
  seed_role: edge.seed_role
1532
1749
  });
1533
1750
  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"));
1751
+ 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));
1535
1752
  }
1536
1753
  return {
1537
1754
  labelCandidates: [...candidates.values()].sort((a, b) => b.confidence_score - a.confidence_score || a.address.localeCompare(b.address)),
@@ -2946,4 +3163,4 @@ async function trackFunds(remoteClient, config, options) {
2946
3163
  //#endregion
2947
3164
  export { addressRisk, scamTopology, stakeInsights, trackFunds };
2948
3165
 
2949
- //# sourceMappingURL=public-tools-B13J0MJZ.mjs.map
3166
+ //# sourceMappingURL=public-tools-w7En2m3q.mjs.map