solana-privacy-scanner-core 0.3.0 → 0.3.1

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/dist/index.cjs CHANGED
@@ -38,12 +38,14 @@ __export(index_exports, {
38
38
  collectTransactionData: () => collectTransactionData,
39
39
  collectWalletData: () => collectWalletData,
40
40
  createDefaultLabelProvider: () => createDefaultLabelProvider,
41
+ detectAddressReuse: () => detectAddressReuse,
41
42
  detectAmountReuse: () => detectAmountReuse,
42
43
  detectBalanceTraceability: () => detectBalanceTraceability,
43
44
  detectCounterpartyReuse: () => detectCounterpartyReuse,
44
45
  detectFeePayerReuse: () => detectFeePayerReuse,
45
46
  detectInstructionFingerprinting: () => detectInstructionFingerprinting,
46
47
  detectKnownEntityInteraction: () => detectKnownEntityInteraction,
48
+ detectMemoExposure: () => detectMemoExposure,
47
49
  detectSignerOverlap: () => detectSignerOverlap,
48
50
  detectTimingPatterns: () => detectTimingPatterns,
49
51
  detectTokenAccountLifecycle: () => detectTokenAccountLifecycle,
@@ -1152,8 +1154,8 @@ function detectAmountReuse(context) {
1152
1154
  severity: "LOW",
1153
1155
  category: "behavioral",
1154
1156
  reason: `${roundNumbers.length} round-number transfers detected (e.g., 1 SOL, 10 SOL).`,
1155
- impact: "Round numbers are common on Solana and relatively benign alone. Combined with other patterns, they can contribute to fingerprinting.",
1156
- mitigation: "Vary amounts slightly if possible, but this is low priority on Solana.",
1157
+ impact: "Round numbers are common on Solana. Combined with other patterns, they can contribute to fingerprinting.",
1158
+ mitigation: "Vary amounts slightly if possible, but this is low priority.",
1157
1159
  evidence: [{
1158
1160
  description: `${roundNumbers.length} round-number transfers: ${roundNumbers.slice(0, 5).join(", ")}...`,
1159
1161
  severity: "LOW",
@@ -1206,7 +1208,7 @@ function detectAmountReuse(context) {
1206
1208
  severity: "LOW",
1207
1209
  category: "behavioral",
1208
1210
  reason: `${signerReuse.length} amount(s) are reused multiple times with consistent signers.`,
1209
- impact: "Amount reuse alone is relatively weak on Solana, but combined with other signals it contributes to behavioral fingerprinting.",
1211
+ impact: "Amount reuse alone is relatively weak, but combined with other signals it contributes to behavioral fingerprinting.",
1210
1212
  mitigation: "Vary transaction amounts to reduce pattern visibility.",
1211
1213
  evidence
1212
1214
  });
@@ -1240,62 +1242,128 @@ function detectAmountReuse(context) {
1240
1242
 
1241
1243
  // src/heuristics/timing-patterns.ts
1242
1244
  function detectTimingPatterns(context) {
1245
+ const signals = [];
1243
1246
  if (!context.timeRange.earliest || !context.timeRange.latest) {
1244
- return null;
1247
+ return signals;
1245
1248
  }
1246
1249
  if (context.transactionCount < 3) {
1247
- return null;
1250
+ return signals;
1248
1251
  }
1249
1252
  const timeSpanSeconds = context.timeRange.latest - context.timeRange.earliest;
1250
1253
  const timeSpanHours = timeSpanSeconds / 3600;
1251
1254
  if (timeSpanHours === 0) {
1252
- return null;
1255
+ return signals;
1253
1256
  }
1254
1257
  const txRate = context.transactionCount / timeSpanHours;
1255
- let severity = "LOW";
1256
1258
  let isBurst = false;
1259
+ let burstSeverity = "LOW";
1257
1260
  if (txRate > 10) {
1258
- severity = "HIGH";
1261
+ burstSeverity = "HIGH";
1259
1262
  isBurst = true;
1260
1263
  } else if (txRate > 5) {
1261
- severity = "MEDIUM";
1264
+ burstSeverity = "MEDIUM";
1262
1265
  isBurst = true;
1263
1266
  } else if (timeSpanHours < 1 && context.transactionCount >= 3) {
1264
- severity = "MEDIUM";
1267
+ burstSeverity = "MEDIUM";
1265
1268
  isBurst = true;
1266
1269
  }
1267
- if (!isBurst) {
1268
- return null;
1269
- }
1270
- const evidence = [
1271
- {
1272
- type: "timing",
1273
- description: `${context.transactionCount} transactions in ${timeSpanHours.toFixed(1)} hours`,
1274
- data: {
1275
- transactionCount: context.transactionCount,
1276
- timeSpanHours: timeSpanHours.toFixed(2),
1277
- transactionRate: txRate.toFixed(2)
1270
+ if (isBurst) {
1271
+ signals.push({
1272
+ id: "timing-burst",
1273
+ name: "Transaction Burst Pattern",
1274
+ severity: burstSeverity,
1275
+ confidence: 0.8,
1276
+ category: "behavioral",
1277
+ reason: `Concentrated activity: ${context.transactionCount} transactions in ${timeSpanHours.toFixed(1)} hours`,
1278
+ impact: "Concentrated transaction activity creates timing fingerprints that can be used to correlate your transactions and link them to specific events or behaviors.",
1279
+ mitigation: "Spread transactions over longer time periods, use scheduled transactions, or batch operations to reduce timing correlation.",
1280
+ evidence: [{
1281
+ description: `${context.transactionCount} transactions in ${timeSpanHours.toFixed(1)} hours (${txRate.toFixed(2)} tx/hour)`,
1282
+ severity: burstSeverity,
1283
+ reference: void 0
1284
+ }]
1285
+ });
1286
+ }
1287
+ if (context.transactions && context.transactions.length >= 5) {
1288
+ const timestamps = context.transactions.map((tx) => tx.blockTime).filter((time) => time !== void 0).sort((a, b) => a - b);
1289
+ if (timestamps.length >= 5) {
1290
+ const gaps = [];
1291
+ for (let i = 1; i < timestamps.length; i++) {
1292
+ gaps.push(timestamps[i] - timestamps[i - 1]);
1293
+ }
1294
+ const avgGap = gaps.reduce((sum, gap) => sum + gap, 0) / gaps.length;
1295
+ const variance = gaps.reduce((sum, gap) => sum + Math.pow(gap - avgGap, 2), 0) / gaps.length;
1296
+ const stdDev = Math.sqrt(variance);
1297
+ const coefficientOfVariation = stdDev / avgGap;
1298
+ if (coefficientOfVariation < 0.3 && avgGap > 60) {
1299
+ const intervalMinutes = Math.round(avgGap / 60);
1300
+ const intervalHours = avgGap / 3600;
1301
+ let severity = "LOW";
1302
+ if (intervalHours >= 23 && intervalHours <= 25) {
1303
+ severity = "HIGH";
1304
+ } else if (intervalHours >= 0.9 && intervalHours <= 1.1) {
1305
+ severity = "HIGH";
1306
+ } else if (gaps.length >= 10) {
1307
+ severity = "MEDIUM";
1308
+ }
1309
+ signals.push({
1310
+ id: "timing-regular-interval",
1311
+ name: "Regular Transaction Interval",
1312
+ severity,
1313
+ confidence: 0.85,
1314
+ category: "behavioral",
1315
+ reason: `Transactions occur at regular ${intervalMinutes < 60 ? `${intervalMinutes}-minute` : `${intervalHours.toFixed(1)}-hour`} intervals.`,
1316
+ impact: "Regular timing patterns are highly distinctive fingerprints. They suggest automated behavior and can reveal timezone, schedule, or bot configuration.",
1317
+ mitigation: "Add random delays between transactions. Vary the timing to avoid predictable patterns.",
1318
+ evidence: [{
1319
+ description: `${gaps.length} transactions with average ${intervalMinutes}-minute intervals (${(coefficientOfVariation * 100).toFixed(1)}% variation)`,
1320
+ severity,
1321
+ reference: void 0
1322
+ }]
1323
+ });
1278
1324
  }
1279
1325
  }
1280
- ];
1281
- return {
1282
- id: "timing-correlation",
1283
- name: "Transaction Burst Pattern",
1284
- severity,
1285
- reason: `Concentrated activity: ${context.transactionCount} transactions in ${timeSpanHours.toFixed(1)} hours`,
1286
- impact: "Concentrated transaction activity creates timing fingerprints that can be used to correlate your transactions and link them to specific events or behaviors.",
1287
- evidence,
1288
- mitigation: "Spread transactions over longer time periods, use scheduled transactions, or batch operations to reduce timing correlation.",
1289
- confidence: 0.8
1290
- };
1326
+ }
1327
+ if (context.transactions && context.transactions.length >= 10) {
1328
+ const timestamps = context.transactions.map((tx) => tx.blockTime).filter((time) => time !== void 0);
1329
+ if (timestamps.length >= 10) {
1330
+ const hours = timestamps.map((ts) => new Date(ts * 1e3).getUTCHours());
1331
+ const hourCounts = /* @__PURE__ */ new Map();
1332
+ hours.forEach((hour) => {
1333
+ hourCounts.set(hour, (hourCounts.get(hour) || 0) + 1);
1334
+ });
1335
+ const maxCount = Math.max(...Array.from(hourCounts.values()));
1336
+ const concentration = maxCount / hours.length;
1337
+ if (concentration > 0.4) {
1338
+ const mostActiveHours = Array.from(hourCounts.entries()).filter(([_, count]) => count >= maxCount * 0.8).map(([hour]) => hour).sort((a, b) => a - b);
1339
+ signals.push({
1340
+ id: "timing-timezone-pattern",
1341
+ name: "Consistent Time-of-Day Pattern",
1342
+ severity: "MEDIUM",
1343
+ confidence: 0.7,
1344
+ category: "behavioral",
1345
+ reason: `${Math.round(concentration * 100)}% of transactions occur during specific hours (${mostActiveHours.map((h) => `${h}:00`).join(", ")} UTC).`,
1346
+ impact: "Time-of-day patterns can reveal timezone or daily schedule, contributing to identity fingerprinting.",
1347
+ mitigation: "Vary transaction times across different hours of the day. Use scheduled transactions or automation to obscure your timezone.",
1348
+ evidence: [{
1349
+ description: `${maxCount}/${hours.length} transactions during ${mostActiveHours.length} hour(s)`,
1350
+ severity: "MEDIUM",
1351
+ reference: void 0
1352
+ }]
1353
+ });
1354
+ }
1355
+ }
1356
+ }
1357
+ return signals;
1291
1358
  }
1292
1359
 
1293
1360
  // src/heuristics/known-entity.ts
1294
1361
  function detectKnownEntityInteraction(context) {
1362
+ const signals = [];
1295
1363
  if (context.labels.size === 0) {
1296
- return null;
1364
+ return signals;
1297
1365
  }
1298
- const entityInteractions = [];
1366
+ const entityTypeGroups = /* @__PURE__ */ new Map();
1299
1367
  for (const [address, label] of context.labels.entries()) {
1300
1368
  let interactionCount = 0;
1301
1369
  const relatedTxs = [];
@@ -1308,101 +1376,243 @@ function detectKnownEntityInteraction(context) {
1308
1376
  }
1309
1377
  }
1310
1378
  if (interactionCount > 0) {
1311
- entityInteractions.push({
1312
- type: "label",
1313
- description: `${interactionCount} interaction(s) with ${label.name} (${label.type})`,
1314
- data: {
1315
- entityName: label.name,
1316
- entityType: label.type,
1317
- address,
1318
- interactionCount,
1319
- transactions: relatedTxs
1320
- },
1321
- reference: address
1379
+ if (!entityTypeGroups.has(label.type)) {
1380
+ entityTypeGroups.set(label.type, []);
1381
+ }
1382
+ entityTypeGroups.get(label.type).push({
1383
+ address,
1384
+ label,
1385
+ count: interactionCount,
1386
+ txs: relatedTxs
1322
1387
  });
1323
1388
  }
1324
1389
  }
1325
- if (entityInteractions.length === 0) {
1326
- return null;
1390
+ if (entityTypeGroups.size === 0) {
1391
+ return signals;
1327
1392
  }
1328
- let severity = "MEDIUM";
1329
- const hasExchangeInteraction = Array.from(context.labels.values()).some((label) => label.type === "exchange");
1330
- if (hasExchangeInteraction) {
1331
- severity = "HIGH";
1332
- } else if (entityInteractions.length >= 3) {
1333
- severity = "HIGH";
1393
+ const exchanges = entityTypeGroups.get("exchange");
1394
+ if (exchanges && exchanges.length > 0) {
1395
+ const evidence = exchanges.map((entity) => ({
1396
+ description: `${entity.count} interaction(s) with ${entity.label.name}`,
1397
+ severity: "HIGH",
1398
+ reference: entity.address
1399
+ }));
1400
+ const totalExchangeTxs = exchanges.reduce((sum, e) => sum + e.count, 0);
1401
+ signals.push({
1402
+ id: "known-entity-exchange",
1403
+ name: "Centralized Exchange Interaction",
1404
+ severity: "HIGH",
1405
+ confidence: 0.95,
1406
+ category: "identity-linkage",
1407
+ reason: `Wallet interacted with ${exchanges.length} centralized exchange(s) in ${totalExchangeTxs} transaction(s).`,
1408
+ impact: "Centralized exchanges have KYC data. Direct interactions can link your on-chain address to your real-world identity through account records, IP addresses, and withdrawal/deposit patterns.",
1409
+ mitigation: "Use intermediate wallets to break the direct link. Deposit to privacy protocols before going to CEX. Consider DEXs for better privacy.",
1410
+ evidence
1411
+ });
1334
1412
  }
1335
- return {
1336
- id: "known-entity-interaction",
1337
- name: "Known Entity Interaction",
1338
- severity,
1339
- reason: `Wallet interacted with ${entityInteractions.length} known entit${entityInteractions.length === 1 ? "y" : "ies"}`,
1340
- impact: "Interactions with centralized exchanges, bridges, or other known entities can link your on-chain address to your real-world identity through KYC data, IP addresses, and off-chain records.",
1341
- evidence: entityInteractions,
1342
- mitigation: "Use privacy-preserving bridges, avoid direct CEX interactions from privacy-sensitive wallets, or use intermediate wallets to break the link.",
1343
- confidence: 0.95
1344
- };
1413
+ const bridges = entityTypeGroups.get("bridge");
1414
+ if (bridges && bridges.length > 0) {
1415
+ const evidence = bridges.map((entity) => ({
1416
+ description: `${entity.count} interaction(s) with ${entity.label.name}`,
1417
+ severity: "MEDIUM",
1418
+ reference: entity.address
1419
+ }));
1420
+ signals.push({
1421
+ id: "known-entity-bridge",
1422
+ name: "Bridge Protocol Interaction",
1423
+ severity: "MEDIUM",
1424
+ confidence: 0.85,
1425
+ category: "identity-linkage",
1426
+ reason: `Wallet interacted with ${bridges.length} bridge protocol(s).`,
1427
+ impact: "Bridge transactions can link your Solana address to addresses on other chains, expanding the tracking surface.",
1428
+ mitigation: "Use privacy-preserving bridges when available. Create separate addresses for cross-chain activity.",
1429
+ evidence
1430
+ });
1431
+ }
1432
+ const others = Array.from(entityTypeGroups.entries()).filter(([type]) => type !== "exchange" && type !== "bridge");
1433
+ if (others.length > 0) {
1434
+ const allOtherEntities = others.flatMap(([_, entities]) => entities);
1435
+ const evidence = allOtherEntities.slice(0, 5).map((entity) => ({
1436
+ description: `${entity.count} interaction(s) with ${entity.label.name} (${entity.label.type})`,
1437
+ severity: "LOW",
1438
+ reference: entity.address
1439
+ }));
1440
+ const totalOtherTxs = allOtherEntities.reduce((sum, e) => sum + e.count, 0);
1441
+ signals.push({
1442
+ id: "known-entity-other",
1443
+ name: "Known Entity Interactions",
1444
+ severity: "LOW",
1445
+ confidence: 0.75,
1446
+ category: "behavioral",
1447
+ reason: `Wallet interacted with ${allOtherEntities.length} known entit${allOtherEntities.length === 1 ? "y" : "ies"} (${totalOtherTxs} transactions).`,
1448
+ impact: "Interactions with known entities create reference points in your transaction history. These can be used to correlate activity and build behavioral profiles.",
1449
+ mitigation: "While interacting with known protocols is often necessary, be aware it creates public association with those services.",
1450
+ evidence
1451
+ });
1452
+ }
1453
+ for (const [address, label] of context.labels.entries()) {
1454
+ let interactionCount = 0;
1455
+ for (const transfer of context.transfers) {
1456
+ if (transfer.from === address || transfer.to === address) {
1457
+ interactionCount++;
1458
+ }
1459
+ }
1460
+ const concentration = interactionCount / context.transfers.length;
1461
+ if (concentration > 0.3 && interactionCount >= 5) {
1462
+ signals.push({
1463
+ id: `known-entity-frequent-${address.slice(0, 8)}`,
1464
+ name: "Frequent Single Entity Interaction",
1465
+ severity: label.type === "exchange" ? "HIGH" : "MEDIUM",
1466
+ confidence: 0.85,
1467
+ category: "behavioral",
1468
+ reason: `${Math.round(concentration * 100)}% of transfers (${interactionCount}/${context.transfers.length}) involve ${label.name}.`,
1469
+ impact: "Heavy concentration of activity with one entity creates a strong link and behavioral dependency that is easily identified.",
1470
+ mitigation: "Diversify your interactions across multiple services. Use different addresses for different service providers.",
1471
+ evidence: [{
1472
+ description: `${interactionCount} transfers with ${label.name} (${label.type})`,
1473
+ severity: label.type === "exchange" ? "HIGH" : "MEDIUM",
1474
+ reference: address
1475
+ }]
1476
+ });
1477
+ }
1478
+ }
1479
+ return signals;
1345
1480
  }
1346
1481
 
1347
1482
  // src/heuristics/balance-traceability.ts
1348
1483
  function detectBalanceTraceability(context) {
1484
+ const signals = [];
1349
1485
  if (context.targetType !== "wallet" || context.transfers.length < 2) {
1350
- return null;
1486
+ return signals;
1351
1487
  }
1352
- const fullBalanceTransfers = [];
1353
- const suspiciousPatterns = [];
1354
1488
  const amountPairs = /* @__PURE__ */ new Map();
1355
1489
  for (const transfer of context.transfers) {
1356
1490
  const amountKey = transfer.amount.toFixed(6);
1357
1491
  amountPairs.set(amountKey, (amountPairs.get(amountKey) || 0) + 1);
1358
1492
  }
1359
- const matchingPairs = Array.from(amountPairs.entries()).filter(([_, count]) => count >= 2);
1493
+ const matchingPairs = Array.from(amountPairs.entries()).filter(([_, count]) => count >= 2).sort((a, b) => b[1] - a[1]);
1360
1494
  if (matchingPairs.length >= 2) {
1361
- suspiciousPatterns.push("Multiple matching send/receive amounts detected");
1495
+ const evidence = matchingPairs.slice(0, 5).map(([amount, count]) => ({
1496
+ description: `Amount ${amount} appears in ${count} transfers`,
1497
+ severity: count >= 4 ? "HIGH" : "MEDIUM",
1498
+ reference: void 0
1499
+ }));
1500
+ const severity = matchingPairs.length >= 4 ? "HIGH" : matchingPairs.length >= 3 ? "MEDIUM" : "LOW";
1501
+ signals.push({
1502
+ id: "balance-matching-pairs",
1503
+ name: "Matching Send/Receive Amounts",
1504
+ severity,
1505
+ confidence: 0.7,
1506
+ category: "traceability",
1507
+ reason: `${matchingPairs.length} amount(s) appear in multiple transfers, suggesting balance movements.`,
1508
+ impact: "Matching amounts can be used to trace balance flows. If you receive X and later send X, observers can link these transactions.",
1509
+ mitigation: "Split large transfers into multiple smaller ones with varied amounts. Avoid sending exact amounts you received.",
1510
+ evidence
1511
+ });
1362
1512
  }
1513
+ const sequentialPairs = [];
1363
1514
  for (let i = 0; i < context.transfers.length - 1; i++) {
1364
1515
  const current = context.transfers[i];
1365
1516
  const next = context.transfers[i + 1];
1366
1517
  if (current.blockTime && next.blockTime) {
1367
1518
  const timeDiff = Math.abs(next.blockTime - current.blockTime);
1368
- if (timeDiff < 3600 && Math.abs(current.amount - next.amount) < current.amount * 0.1) {
1369
- suspiciousPatterns.push("Sequential transfers of similar amounts");
1370
- break;
1519
+ const amountDiff = Math.abs(current.amount - next.amount);
1520
+ const percentDiff = amountDiff / Math.max(current.amount, next.amount);
1521
+ if (timeDiff < 3600 && percentDiff < 0.1 && current.amount > 0.1) {
1522
+ sequentialPairs.push({
1523
+ index: i,
1524
+ amount1: current.amount,
1525
+ amount2: next.amount,
1526
+ timeDiff
1527
+ });
1371
1528
  }
1372
1529
  }
1373
1530
  }
1374
- if (suspiciousPatterns.length === 0 && matchingPairs.length === 0) {
1375
- return null;
1376
- }
1377
- const evidence = [];
1378
- if (matchingPairs.length > 0) {
1379
- evidence.push({
1380
- type: "pattern",
1381
- description: `${matchingPairs.length} matching send/receive amount pair(s)`,
1382
- data: { matchingPairs: matchingPairs.length }
1531
+ if (sequentialPairs.length >= 2) {
1532
+ const evidence = sequentialPairs.slice(0, 3).map((pair) => ({
1533
+ description: `${pair.amount1.toFixed(4)} \u2192 ${pair.amount2.toFixed(4)} (${Math.round(pair.timeDiff / 60)} minutes apart)`,
1534
+ severity: pair.timeDiff < 600 ? "HIGH" : "MEDIUM",
1535
+ reference: void 0
1536
+ }));
1537
+ signals.push({
1538
+ id: "balance-sequential-similar",
1539
+ name: "Sequential Similar Amount Transfers",
1540
+ severity: "MEDIUM",
1541
+ confidence: 0.65,
1542
+ category: "traceability",
1543
+ reason: `${sequentialPairs.length} instance(s) of similar amounts transferred in quick succession.`,
1544
+ impact: "Sequential similar amounts suggest balance movements and make it easy to trace funds through intermediate addresses.",
1545
+ mitigation: "Add random delays between transactions. Vary amounts to obscure the flow path.",
1546
+ evidence
1383
1547
  });
1384
1548
  }
1385
- for (const pattern of suspiciousPatterns) {
1386
- evidence.push({
1387
- type: "pattern",
1388
- description: pattern,
1389
- data: {}
1549
+ const roundNumbers = context.transfers.filter((t) => {
1550
+ const amount = t.amount;
1551
+ if (amount === 0) return false;
1552
+ return (amount === Math.floor(amount) || // Whole number
1553
+ amount * 10 === Math.floor(amount * 10) || // One decimal
1554
+ amount * 100 === Math.floor(amount * 100)) && (amount % 1 === 0 || // 1, 10, 100
1555
+ amount * 10 % 1 === 0 || // 0.1, 0.5
1556
+ amount * 100 % 1 === 0);
1557
+ });
1558
+ const roundNumberRatio = roundNumbers.length / context.transfers.length;
1559
+ if (roundNumberRatio > 0.7 && context.transfers.length >= 5) {
1560
+ signals.push({
1561
+ id: "balance-round-numbers",
1562
+ name: "High Proportion of Round Number Transfers",
1563
+ severity: "LOW",
1564
+ confidence: 0.6,
1565
+ category: "behavioral",
1566
+ reason: `${Math.round(roundNumberRatio * 100)}% of transfers use round numbers.`,
1567
+ impact: "Round numbers are easier to remember and track. They can contribute to balance traceability when combined with other patterns.",
1568
+ mitigation: "Use more varied amounts. Add small random values to make amounts less predictable.",
1569
+ evidence: [{
1570
+ description: `${roundNumbers.length}/${context.transfers.length} transfers are round numbers`,
1571
+ severity: "LOW",
1572
+ reference: void 0
1573
+ }]
1390
1574
  });
1391
1575
  }
1392
- let severity = "MEDIUM";
1393
- if (matchingPairs.length >= 3 || suspiciousPatterns.length >= 2) {
1394
- severity = "HIGH";
1576
+ const tokenTransfers = /* @__PURE__ */ new Map();
1577
+ for (const transfer of context.transfers) {
1578
+ const token = transfer.token || "SOL";
1579
+ if (!tokenTransfers.has(token)) {
1580
+ tokenTransfers.set(token, []);
1581
+ }
1582
+ tokenTransfers.get(token).push(transfer);
1583
+ }
1584
+ for (const [token, transfers] of tokenTransfers) {
1585
+ if (transfers.length < 2) continue;
1586
+ const receives = transfers.filter((t) => t.to === context.target);
1587
+ const sends = transfers.filter((t) => t.from === context.target);
1588
+ for (const receive of receives) {
1589
+ for (const send of sends) {
1590
+ if (!receive.blockTime || !send.blockTime) continue;
1591
+ if (send.blockTime <= receive.blockTime) continue;
1592
+ const timeDiff = send.blockTime - receive.blockTime;
1593
+ const percentDiff = Math.abs(send.amount - receive.amount) / receive.amount;
1594
+ if (percentDiff < 0.05 && timeDiff < 86400 && receive.amount > 1) {
1595
+ signals.push({
1596
+ id: `balance-full-movement-${token}`,
1597
+ name: "Full Balance Movement Detected",
1598
+ severity: "HIGH",
1599
+ confidence: 0.8,
1600
+ category: "traceability",
1601
+ reason: `Received ${receive.amount.toFixed(4)} ${token}, then sent ${send.amount.toFixed(4)} ${token} shortly after.`,
1602
+ impact: "Moving entire received balances makes fund flow trivially traceable. The path from source to destination is clear.",
1603
+ mitigation: "Split received funds before sending. Mix with other funds. Add delays and intermediate steps.",
1604
+ evidence: [{
1605
+ description: `Received ${receive.amount.toFixed(4)} \u2192 Sent ${send.amount.toFixed(4)} (${Math.round(timeDiff / 60)} minutes later)`,
1606
+ severity: "HIGH",
1607
+ reference: void 0
1608
+ }]
1609
+ });
1610
+ break;
1611
+ }
1612
+ }
1613
+ }
1395
1614
  }
1396
- return {
1397
- id: "balance-traceability",
1398
- name: "Balance Traceability",
1399
- severity,
1400
- reason: "Wallet shows patterns that enable balance tracking",
1401
- impact: "Traceable balance movements allow observers to follow funds through the blockchain, linking your transactions and revealing your financial activity.",
1402
- evidence,
1403
- mitigation: "Split large transfers into multiple smaller ones, introduce timing delays, or use privacy protocols that obscure amounts.",
1404
- confidence: 0.7
1405
- };
1615
+ return signals;
1406
1616
  }
1407
1617
 
1408
1618
  // src/heuristics/fee-payer-reuse.ts
@@ -1931,12 +2141,284 @@ function detectTokenAccountLifecycle(context) {
1931
2141
  return signals;
1932
2142
  }
1933
2143
 
2144
+ // src/heuristics/memo-exposure.ts
2145
+ function detectMemoExposure(context) {
2146
+ const signals = [];
2147
+ if (context.transactionCount === 0) {
2148
+ return signals;
2149
+ }
2150
+ if (!context.transactions || context.transactions.length === 0) {
2151
+ return signals;
2152
+ }
2153
+ const MEMO_PROGRAM = "MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr";
2154
+ const MEMO_PROGRAM_V1 = "Memo1UhkJRfHyvLMcVucJwxXeuD728EqVDDwQDxFMNo";
2155
+ const memoInstructions = context.instructions.filter(
2156
+ (inst) => inst.programId === MEMO_PROGRAM || inst.programId === MEMO_PROGRAM_V1
2157
+ );
2158
+ if (memoInstructions.length === 0) {
2159
+ return signals;
2160
+ }
2161
+ const suspiciousMemos = [];
2162
+ for (const inst of memoInstructions) {
2163
+ if (!inst.data || typeof inst.data !== "string") continue;
2164
+ const memoText = inst.data.trim();
2165
+ if (memoText.length === 0) continue;
2166
+ let severity = "LOW";
2167
+ const patterns = [];
2168
+ if (/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/.test(memoText)) {
2169
+ patterns.push("email address");
2170
+ severity = "HIGH";
2171
+ }
2172
+ if (/https?:\/\/[^\s]+/.test(memoText)) {
2173
+ patterns.push("URL");
2174
+ severity = severity === "HIGH" ? "HIGH" : "MEDIUM";
2175
+ }
2176
+ if (/(\+\d{1,3}[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}/.test(memoText)) {
2177
+ patterns.push("phone number");
2178
+ severity = "HIGH";
2179
+ }
2180
+ const capitalizedWords = memoText.match(/\b[A-Z][a-z]+(\s+[A-Z][a-z]+)+\b/g);
2181
+ if (capitalizedWords && capitalizedWords.length > 0) {
2182
+ patterns.push("likely name(s)");
2183
+ severity = severity === "HIGH" ? "HIGH" : "MEDIUM";
2184
+ }
2185
+ if (memoText.length > 50 && !patterns.length) {
2186
+ patterns.push("long descriptive text");
2187
+ severity = "MEDIUM";
2188
+ }
2189
+ if (/invoice|payment|order|transaction|ref|reference|id|bill/i.test(memoText)) {
2190
+ patterns.push("payment reference");
2191
+ severity = severity === "HIGH" ? "HIGH" : "MEDIUM";
2192
+ }
2193
+ if (patterns.length > 0) {
2194
+ suspiciousMemos.push({
2195
+ content: memoText.length > 100 ? memoText.slice(0, 100) + "..." : memoText,
2196
+ signature: inst.signature,
2197
+ severity
2198
+ });
2199
+ }
2200
+ }
2201
+ if (suspiciousMemos.length === 0) {
2202
+ signals.push({
2203
+ id: "memo-usage",
2204
+ name: "Memo Program Usage",
2205
+ severity: "LOW",
2206
+ confidence: 0.6,
2207
+ category: "information-leak",
2208
+ reason: `${memoInstructions.length} transaction(s) use memo program. Memos are permanently visible on-chain.`,
2209
+ impact: "Memo data is public and permanent. Even non-sensitive memos can contribute to behavioral fingerprinting.",
2210
+ mitigation: "Avoid using memos unless necessary. Never include personal information.",
2211
+ evidence: [{
2212
+ description: `${memoInstructions.length} transaction(s) with memos`,
2213
+ severity: "LOW",
2214
+ reference: void 0
2215
+ }]
2216
+ });
2217
+ } else {
2218
+ const highSeverity = suspiciousMemos.filter((m) => m.severity === "HIGH");
2219
+ const mediumSeverity = suspiciousMemos.filter((m) => m.severity === "MEDIUM");
2220
+ if (highSeverity.length > 0) {
2221
+ const evidence = highSeverity.map((memo) => ({
2222
+ description: `"${memo.content}"`,
2223
+ severity: "HIGH",
2224
+ reference: `https://solscan.io/tx/${memo.signature}`
2225
+ }));
2226
+ signals.push({
2227
+ id: "memo-pii-exposure",
2228
+ name: "Personal Information in Memo",
2229
+ severity: "HIGH",
2230
+ confidence: 0.9,
2231
+ category: "information-leak",
2232
+ reason: `${highSeverity.length} memo(s) contain personal identifying information (email, phone, name).`,
2233
+ impact: "CRITICAL: Personal information in memos is permanently public. This can directly link your wallet to your real-world identity.",
2234
+ mitigation: "Never put personal information in memos. Contact addresses involved to stop this practice if possible.",
2235
+ evidence
2236
+ });
2237
+ }
2238
+ if (mediumSeverity.length > 0) {
2239
+ const evidence = mediumSeverity.slice(0, 5).map((memo) => ({
2240
+ description: `"${memo.content}"`,
2241
+ severity: "MEDIUM",
2242
+ reference: `https://solscan.io/tx/${memo.signature}`
2243
+ }));
2244
+ signals.push({
2245
+ id: "memo-descriptive-content",
2246
+ name: "Descriptive Content in Memo",
2247
+ severity: "MEDIUM",
2248
+ confidence: 0.7,
2249
+ category: "information-leak",
2250
+ reason: `${mediumSeverity.length} memo(s) contain descriptive or identifying content.`,
2251
+ impact: "Descriptive memos create behavioral fingerprints and can indirectly reveal identity or transaction purpose.",
2252
+ mitigation: "Minimize memo usage. Use generic or coded references instead of descriptive text.",
2253
+ evidence
2254
+ });
2255
+ }
2256
+ }
2257
+ return signals;
2258
+ }
2259
+
2260
+ // src/heuristics/address-reuse.ts
2261
+ function detectAddressReuse(context) {
2262
+ const signals = [];
2263
+ if (context.targetType !== "wallet" || context.transactionCount < 5) {
2264
+ return signals;
2265
+ }
2266
+ const activityTypes = /* @__PURE__ */ new Set();
2267
+ const activityDetails = /* @__PURE__ */ new Map();
2268
+ const DEFI_PROGRAMS = /* @__PURE__ */ new Set([
2269
+ "JUP",
2270
+ "Raydium",
2271
+ "Orca",
2272
+ "Marinade",
2273
+ "Lido",
2274
+ "Lifinity",
2275
+ "Serum"
2276
+ // Add more as needed
2277
+ ]);
2278
+ const NFT_PROGRAMS = /* @__PURE__ */ new Set([
2279
+ "Magic Eden",
2280
+ "Tensor",
2281
+ "OpenSea",
2282
+ "Metaplex"
2283
+ ]);
2284
+ const GAMING_PROGRAMS = /* @__PURE__ */ new Set([
2285
+ "Star Atlas",
2286
+ "Genopets",
2287
+ "Aurory"
2288
+ ]);
2289
+ const DAO_PROGRAMS = /* @__PURE__ */ new Set([
2290
+ "Realms",
2291
+ "Squads",
2292
+ "Tribeca"
2293
+ ]);
2294
+ for (const inst of context.instructions) {
2295
+ const label = context.labels.get(inst.programId);
2296
+ const programName = label?.name || "";
2297
+ if (DEFI_PROGRAMS.has(programName) || /swap|pool|stake|lend|borrow/i.test(programName)) {
2298
+ activityTypes.add("DeFi");
2299
+ if (!activityDetails.has("DeFi")) {
2300
+ activityDetails.set("DeFi", { count: 0, programs: /* @__PURE__ */ new Set() });
2301
+ }
2302
+ const details = activityDetails.get("DeFi");
2303
+ details.count++;
2304
+ details.programs.add(programName || inst.programId.slice(0, 8));
2305
+ }
2306
+ if (NFT_PROGRAMS.has(programName) || /nft|marketplace|mint/i.test(programName)) {
2307
+ activityTypes.add("NFT");
2308
+ if (!activityDetails.has("NFT")) {
2309
+ activityDetails.set("NFT", { count: 0, programs: /* @__PURE__ */ new Set() });
2310
+ }
2311
+ const details = activityDetails.get("NFT");
2312
+ details.count++;
2313
+ details.programs.add(programName || inst.programId.slice(0, 8));
2314
+ }
2315
+ if (GAMING_PROGRAMS.has(programName) || /game|play/i.test(programName)) {
2316
+ activityTypes.add("Gaming");
2317
+ if (!activityDetails.has("Gaming")) {
2318
+ activityDetails.set("Gaming", { count: 0, programs: /* @__PURE__ */ new Set() });
2319
+ }
2320
+ const details = activityDetails.get("Gaming");
2321
+ details.count++;
2322
+ details.programs.add(programName || inst.programId.slice(0, 8));
2323
+ }
2324
+ if (DAO_PROGRAMS.has(programName) || /dao|governance|vote/i.test(programName)) {
2325
+ activityTypes.add("DAO");
2326
+ if (!activityDetails.has("DAO")) {
2327
+ activityDetails.set("DAO", { count: 0, programs: /* @__PURE__ */ new Set() });
2328
+ }
2329
+ const details = activityDetails.get("DAO");
2330
+ details.count++;
2331
+ details.programs.add(programName || inst.programId.slice(0, 8));
2332
+ }
2333
+ }
2334
+ const hasExchangeInteraction = Array.from(context.labels.values()).some((label) => label.type === "exchange");
2335
+ if (hasExchangeInteraction) {
2336
+ activityTypes.add("Exchange");
2337
+ activityDetails.set("Exchange", {
2338
+ count: context.transfers.filter(
2339
+ (t) => context.labels.has(t.from) || context.labels.has(t.to)
2340
+ ).length,
2341
+ programs: /* @__PURE__ */ new Set(["CEX"])
2342
+ });
2343
+ }
2344
+ const simpleTransfers = context.transfers.filter((t) => {
2345
+ const tx = context.transactions?.find((tx2) => tx2.signature === t.signature);
2346
+ return tx && tx.programs && tx.programs.length <= 2;
2347
+ });
2348
+ if (simpleTransfers.length >= 3) {
2349
+ activityTypes.add("P2P Transfers");
2350
+ activityDetails.set("P2P Transfers", {
2351
+ count: simpleTransfers.length,
2352
+ programs: /* @__PURE__ */ new Set(["Direct"])
2353
+ });
2354
+ }
2355
+ const diversityCount = activityTypes.size;
2356
+ if (diversityCount >= 4) {
2357
+ const evidence = Array.from(activityDetails.entries()).map(([type, details]) => ({
2358
+ description: `${type}: ${details.count} transaction(s) across ${details.programs.size} program(s)`,
2359
+ severity: "HIGH",
2360
+ reference: void 0
2361
+ }));
2362
+ signals.push({
2363
+ id: "address-high-diversity",
2364
+ name: "High Activity Diversity on Single Address",
2365
+ severity: "HIGH",
2366
+ confidence: 0.85,
2367
+ category: "linkability",
2368
+ reason: `This address is used for ${diversityCount} distinct activity types: ${Array.from(activityTypes).join(", ")}.`,
2369
+ impact: "Using one address for multiple unrelated activities links them all together. This creates a comprehensive behavioral profile.",
2370
+ mitigation: "Use separate addresses for different purposes: one for DeFi, one for NFTs, one for DAO participation, etc. This isolates activities and prevents cross-linkage.",
2371
+ evidence
2372
+ });
2373
+ } else if (diversityCount === 3) {
2374
+ const evidence = Array.from(activityDetails.entries()).map(([type, details]) => ({
2375
+ description: `${type}: ${details.count} transaction(s)`,
2376
+ severity: "MEDIUM",
2377
+ reference: void 0
2378
+ }));
2379
+ signals.push({
2380
+ id: "address-moderate-diversity",
2381
+ name: "Moderate Activity Diversity on Single Address",
2382
+ severity: "MEDIUM",
2383
+ confidence: 0.7,
2384
+ category: "linkability",
2385
+ reason: `This address is used for ${diversityCount} activity types: ${Array.from(activityTypes).join(", ")}.`,
2386
+ impact: "Multiple activity types on one address create linkage between otherwise separate behaviors.",
2387
+ mitigation: "Consider using separate addresses for different activities to improve privacy compartmentalization.",
2388
+ evidence
2389
+ });
2390
+ }
2391
+ if (context.timeRange.earliest && context.timeRange.latest) {
2392
+ const timeSpanDays = (context.timeRange.latest - context.timeRange.earliest) / (60 * 60 * 24);
2393
+ if (timeSpanDays > 180 && context.transactionCount > 50) {
2394
+ signals.push({
2395
+ id: "address-long-term-usage",
2396
+ name: "Long-Term Single Address Usage",
2397
+ severity: "MEDIUM",
2398
+ confidence: 0.75,
2399
+ category: "behavioral",
2400
+ reason: `This address has been actively used for ${Math.round(timeSpanDays)} days with ${context.transactionCount} transactions.`,
2401
+ impact: "Long-term address usage accumulates a rich behavioral history. All activities over time are permanently linked.",
2402
+ mitigation: "Periodically rotate to new addresses to compartmentalize different time periods of activity.",
2403
+ evidence: [{
2404
+ description: `${context.transactionCount} transactions over ${Math.round(timeSpanDays)} days`,
2405
+ severity: "MEDIUM",
2406
+ reference: void 0
2407
+ }]
2408
+ });
2409
+ }
2410
+ }
2411
+ return signals;
2412
+ }
2413
+
1934
2414
  // src/scanner/index.ts
1935
2415
  var REPORT_VERSION = "1.0.0";
1936
2416
  var HEURISTICS = [
1937
2417
  // Solana-specific (highest priority)
1938
2418
  detectFeePayerReuse,
1939
2419
  detectSignerOverlap,
2420
+ detectMemoExposure,
2421
+ detectAddressReuse,
1940
2422
  detectKnownEntityInteraction,
1941
2423
  detectCounterpartyReuse,
1942
2424
  detectInstructionFingerprinting,
@@ -1980,15 +2462,24 @@ function generateMitigations(signals) {
1980
2462
  if (signalIds.has("token-account-churn") || signalIds.has("rent-refund-clustering")) {
1981
2463
  mitigations.add("Avoid closing token accounts if privacy is important - the rent refund creates linkage.");
1982
2464
  }
1983
- if (signalIds.has("known-entity-interaction")) {
2465
+ if (signalIds.has("memo-pii-exposure") || signalIds.has("memo-descriptive-content") || signalIds.has("memo-usage")) {
2466
+ mitigations.add("Never include personal information in transaction memos - they are permanently public.");
2467
+ }
2468
+ if (signalIds.has("address-high-diversity") || signalIds.has("address-moderate-diversity") || signalIds.has("address-long-term-usage")) {
2469
+ mitigations.add("Use separate addresses for different activity types to compartmentalize your behavior.");
2470
+ }
2471
+ if (signalIds.has("known-entity-exchange") || signalIds.has("known-entity-bridge") || signalIds.has("known-entity-other") || signalIds.has("known-entity-interaction")) {
1984
2472
  mitigations.add("Avoid direct interactions between privacy-sensitive wallets and KYC services.");
1985
2473
  }
1986
2474
  if (signalIds.has("counterparty-reuse") || signalIds.has("pda-reuse")) {
1987
2475
  mitigations.add("Use different addresses for different counterparties or contexts.");
1988
2476
  }
1989
- if (signalIds.has("timing-correlation") || signalIds.has("balance-traceability")) {
2477
+ if (signalIds.has("timing-burst") || signalIds.has("timing-regular-interval") || signalIds.has("timing-timezone-pattern") || signalIds.has("timing-correlation")) {
1990
2478
  mitigations.add("Introduce timing delays and vary transaction patterns to reduce correlation.");
1991
2479
  }
2480
+ if (signalIds.has("balance-matching-pairs") || signalIds.has("balance-sequential-similar") || signalIds.has("balance-full-movement") || signalIds.has("balance-traceability")) {
2481
+ mitigations.add("Vary transfer amounts and add delays to reduce balance traceability.");
2482
+ }
1992
2483
  if (signalIds.has("amount-reuse")) {
1993
2484
  mitigations.add("Vary transaction amounts to avoid creating fingerprints.");
1994
2485
  }
@@ -2128,12 +2619,14 @@ function createDefaultLabelProvider() {
2128
2619
  collectTransactionData,
2129
2620
  collectWalletData,
2130
2621
  createDefaultLabelProvider,
2622
+ detectAddressReuse,
2131
2623
  detectAmountReuse,
2132
2624
  detectBalanceTraceability,
2133
2625
  detectCounterpartyReuse,
2134
2626
  detectFeePayerReuse,
2135
2627
  detectInstructionFingerprinting,
2136
2628
  detectKnownEntityInteraction,
2629
+ detectMemoExposure,
2137
2630
  detectSignerOverlap,
2138
2631
  detectTimingPatterns,
2139
2632
  detectTokenAccountLifecycle,