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 +22 -18
- package/dist/cli.cjs +8 -8
- package/dist/cli.mjs +8 -8
- package/dist/mcp-proxy.cjs +4 -4
- package/dist/mcp-proxy.mjs +4 -4
- package/dist/{public-tools-BC1fi0DV.cjs → public-tools-q4NMdmDX.cjs} +227 -10
- package/dist/{public-tools-B13J0MJZ.mjs → public-tools-w7En2m3q.mjs} +228 -11
- package/dist/public-tools-w7En2m3q.mjs.map +1 -0
- package/docs/architecture.md +4 -0
- package/docs/mcp-proxy.md +41 -0
- package/package.json +2 -2
- package/dist/public-tools-B13J0MJZ.mjs.map +0 -1
|
@@ -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) =>
|
|
1044
|
+
return labels.some((label) => {
|
|
1045
|
+
const normalized = label.trim().toLowerCase();
|
|
1046
|
+
return normalized === "exchange" || /(^|,)\s*exchange$/.test(normalized);
|
|
1047
|
+
});
|
|
1014
1048
|
}
|
|
1015
|
-
|
|
1016
|
-
|
|
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
|
|
1092
|
-
const
|
|
1093
|
-
const
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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-
|
|
3166
|
+
//# sourceMappingURL=public-tools-w7En2m3q.mjs.map
|