solana-privacy-scanner 0.2.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.js +643 -140
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import { Command } from "commander";
|
|
5
5
|
import * as dotenv from "dotenv";
|
|
6
6
|
|
|
7
|
-
// src/commands/wallet.
|
|
7
|
+
// src/commands/wallet.ts
|
|
8
8
|
import { writeFileSync } from "fs";
|
|
9
9
|
|
|
10
10
|
// ../core/dist/index.js
|
|
@@ -12,6 +12,8 @@ import { Connection } from "@solana/web3.js";
|
|
|
12
12
|
import { readFileSync } from "fs";
|
|
13
13
|
import { fileURLToPath } from "url";
|
|
14
14
|
import { dirname, join } from "path";
|
|
15
|
+
var DEFAULT_RPC_URL = "https://late-hardworking-waterfall.solana-mainnet.quiknode.pro/4017b48acf3a2a1665603cac096822ce4bec3a90/";
|
|
16
|
+
var VERSION = "0.4.0";
|
|
15
17
|
var RateLimiter = class {
|
|
16
18
|
constructor(maxConcurrency) {
|
|
17
19
|
this.maxConcurrency = maxConcurrency;
|
|
@@ -52,8 +54,8 @@ var RPCClient = class {
|
|
|
52
54
|
config;
|
|
53
55
|
rateLimiter;
|
|
54
56
|
constructor(configOrUrl) {
|
|
55
|
-
const config2 = typeof configOrUrl === "string" ? { rpcUrl: configOrUrl } : configOrUrl;
|
|
56
|
-
const rpcUrl = config2.rpcUrl.trim();
|
|
57
|
+
const config2 = !configOrUrl ? {} : typeof configOrUrl === "string" ? { rpcUrl: configOrUrl } : configOrUrl;
|
|
58
|
+
const rpcUrl = (config2.rpcUrl || DEFAULT_RPC_URL).trim();
|
|
57
59
|
this.config = {
|
|
58
60
|
maxRetries: config2.maxRetries ?? 3,
|
|
59
61
|
retryDelay: config2.retryDelay ?? 1e3,
|
|
@@ -1093,8 +1095,8 @@ function detectAmountReuse(context) {
|
|
|
1093
1095
|
severity: "LOW",
|
|
1094
1096
|
category: "behavioral",
|
|
1095
1097
|
reason: `${roundNumbers.length} round-number transfers detected (e.g., 1 SOL, 10 SOL).`,
|
|
1096
|
-
impact: "Round numbers are common on Solana
|
|
1097
|
-
mitigation: "Vary amounts slightly if possible, but this is low priority
|
|
1098
|
+
impact: "Round numbers are common on Solana. Combined with other patterns, they can contribute to fingerprinting.",
|
|
1099
|
+
mitigation: "Vary amounts slightly if possible, but this is low priority.",
|
|
1098
1100
|
evidence: [{
|
|
1099
1101
|
description: `${roundNumbers.length} round-number transfers: ${roundNumbers.slice(0, 5).join(", ")}...`,
|
|
1100
1102
|
severity: "LOW",
|
|
@@ -1147,7 +1149,7 @@ function detectAmountReuse(context) {
|
|
|
1147
1149
|
severity: "LOW",
|
|
1148
1150
|
category: "behavioral",
|
|
1149
1151
|
reason: `${signerReuse.length} amount(s) are reused multiple times with consistent signers.`,
|
|
1150
|
-
impact: "Amount reuse alone is relatively weak
|
|
1152
|
+
impact: "Amount reuse alone is relatively weak, but combined with other signals it contributes to behavioral fingerprinting.",
|
|
1151
1153
|
mitigation: "Vary transaction amounts to reduce pattern visibility.",
|
|
1152
1154
|
evidence
|
|
1153
1155
|
});
|
|
@@ -1179,60 +1181,126 @@ function detectAmountReuse(context) {
|
|
|
1179
1181
|
return signals;
|
|
1180
1182
|
}
|
|
1181
1183
|
function detectTimingPatterns(context) {
|
|
1184
|
+
const signals = [];
|
|
1182
1185
|
if (!context.timeRange.earliest || !context.timeRange.latest) {
|
|
1183
|
-
return
|
|
1186
|
+
return signals;
|
|
1184
1187
|
}
|
|
1185
1188
|
if (context.transactionCount < 3) {
|
|
1186
|
-
return
|
|
1189
|
+
return signals;
|
|
1187
1190
|
}
|
|
1188
1191
|
const timeSpanSeconds = context.timeRange.latest - context.timeRange.earliest;
|
|
1189
1192
|
const timeSpanHours = timeSpanSeconds / 3600;
|
|
1190
1193
|
if (timeSpanHours === 0) {
|
|
1191
|
-
return
|
|
1194
|
+
return signals;
|
|
1192
1195
|
}
|
|
1193
1196
|
const txRate = context.transactionCount / timeSpanHours;
|
|
1194
|
-
let severity = "LOW";
|
|
1195
1197
|
let isBurst = false;
|
|
1198
|
+
let burstSeverity = "LOW";
|
|
1196
1199
|
if (txRate > 10) {
|
|
1197
|
-
|
|
1200
|
+
burstSeverity = "HIGH";
|
|
1198
1201
|
isBurst = true;
|
|
1199
1202
|
} else if (txRate > 5) {
|
|
1200
|
-
|
|
1203
|
+
burstSeverity = "MEDIUM";
|
|
1201
1204
|
isBurst = true;
|
|
1202
1205
|
} else if (timeSpanHours < 1 && context.transactionCount >= 3) {
|
|
1203
|
-
|
|
1206
|
+
burstSeverity = "MEDIUM";
|
|
1204
1207
|
isBurst = true;
|
|
1205
1208
|
}
|
|
1206
|
-
if (
|
|
1207
|
-
|
|
1209
|
+
if (isBurst) {
|
|
1210
|
+
signals.push({
|
|
1211
|
+
id: "timing-burst",
|
|
1212
|
+
name: "Transaction Burst Pattern",
|
|
1213
|
+
severity: burstSeverity,
|
|
1214
|
+
confidence: 0.8,
|
|
1215
|
+
category: "behavioral",
|
|
1216
|
+
reason: `Concentrated activity: ${context.transactionCount} transactions in ${timeSpanHours.toFixed(1)} hours`,
|
|
1217
|
+
impact: "Concentrated transaction activity creates timing fingerprints that can be used to correlate your transactions and link them to specific events or behaviors.",
|
|
1218
|
+
mitigation: "Spread transactions over longer time periods, use scheduled transactions, or batch operations to reduce timing correlation.",
|
|
1219
|
+
evidence: [{
|
|
1220
|
+
description: `${context.transactionCount} transactions in ${timeSpanHours.toFixed(1)} hours (${txRate.toFixed(2)} tx/hour)`,
|
|
1221
|
+
severity: burstSeverity,
|
|
1222
|
+
reference: void 0
|
|
1223
|
+
}]
|
|
1224
|
+
});
|
|
1208
1225
|
}
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1226
|
+
if (context.transactions && context.transactions.length >= 5) {
|
|
1227
|
+
const timestamps = context.transactions.map((tx) => tx.blockTime).filter((time) => time !== void 0).sort((a, b) => a - b);
|
|
1228
|
+
if (timestamps.length >= 5) {
|
|
1229
|
+
const gaps = [];
|
|
1230
|
+
for (let i = 1; i < timestamps.length; i++) {
|
|
1231
|
+
gaps.push(timestamps[i] - timestamps[i - 1]);
|
|
1232
|
+
}
|
|
1233
|
+
const avgGap = gaps.reduce((sum, gap) => sum + gap, 0) / gaps.length;
|
|
1234
|
+
const variance = gaps.reduce((sum, gap) => sum + Math.pow(gap - avgGap, 2), 0) / gaps.length;
|
|
1235
|
+
const stdDev = Math.sqrt(variance);
|
|
1236
|
+
const coefficientOfVariation = stdDev / avgGap;
|
|
1237
|
+
if (coefficientOfVariation < 0.3 && avgGap > 60) {
|
|
1238
|
+
const intervalMinutes = Math.round(avgGap / 60);
|
|
1239
|
+
const intervalHours = avgGap / 3600;
|
|
1240
|
+
let severity = "LOW";
|
|
1241
|
+
if (intervalHours >= 23 && intervalHours <= 25) {
|
|
1242
|
+
severity = "HIGH";
|
|
1243
|
+
} else if (intervalHours >= 0.9 && intervalHours <= 1.1) {
|
|
1244
|
+
severity = "HIGH";
|
|
1245
|
+
} else if (gaps.length >= 10) {
|
|
1246
|
+
severity = "MEDIUM";
|
|
1247
|
+
}
|
|
1248
|
+
signals.push({
|
|
1249
|
+
id: "timing-regular-interval",
|
|
1250
|
+
name: "Regular Transaction Interval",
|
|
1251
|
+
severity,
|
|
1252
|
+
confidence: 0.85,
|
|
1253
|
+
category: "behavioral",
|
|
1254
|
+
reason: `Transactions occur at regular ${intervalMinutes < 60 ? `${intervalMinutes}-minute` : `${intervalHours.toFixed(1)}-hour`} intervals.`,
|
|
1255
|
+
impact: "Regular timing patterns are highly distinctive fingerprints. They suggest automated behavior and can reveal timezone, schedule, or bot configuration.",
|
|
1256
|
+
mitigation: "Add random delays between transactions. Vary the timing to avoid predictable patterns.",
|
|
1257
|
+
evidence: [{
|
|
1258
|
+
description: `${gaps.length} transactions with average ${intervalMinutes}-minute intervals (${(coefficientOfVariation * 100).toFixed(1)}% variation)`,
|
|
1259
|
+
severity,
|
|
1260
|
+
reference: void 0
|
|
1261
|
+
}]
|
|
1262
|
+
});
|
|
1217
1263
|
}
|
|
1218
1264
|
}
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1265
|
+
}
|
|
1266
|
+
if (context.transactions && context.transactions.length >= 10) {
|
|
1267
|
+
const timestamps = context.transactions.map((tx) => tx.blockTime).filter((time) => time !== void 0);
|
|
1268
|
+
if (timestamps.length >= 10) {
|
|
1269
|
+
const hours = timestamps.map((ts) => new Date(ts * 1e3).getUTCHours());
|
|
1270
|
+
const hourCounts = /* @__PURE__ */ new Map();
|
|
1271
|
+
hours.forEach((hour) => {
|
|
1272
|
+
hourCounts.set(hour, (hourCounts.get(hour) || 0) + 1);
|
|
1273
|
+
});
|
|
1274
|
+
const maxCount = Math.max(...Array.from(hourCounts.values()));
|
|
1275
|
+
const concentration = maxCount / hours.length;
|
|
1276
|
+
if (concentration > 0.4) {
|
|
1277
|
+
const mostActiveHours = Array.from(hourCounts.entries()).filter(([_, count]) => count >= maxCount * 0.8).map(([hour]) => hour).sort((a, b) => a - b);
|
|
1278
|
+
signals.push({
|
|
1279
|
+
id: "timing-timezone-pattern",
|
|
1280
|
+
name: "Consistent Time-of-Day Pattern",
|
|
1281
|
+
severity: "MEDIUM",
|
|
1282
|
+
confidence: 0.7,
|
|
1283
|
+
category: "behavioral",
|
|
1284
|
+
reason: `${Math.round(concentration * 100)}% of transactions occur during specific hours (${mostActiveHours.map((h) => `${h}:00`).join(", ")} UTC).`,
|
|
1285
|
+
impact: "Time-of-day patterns can reveal timezone or daily schedule, contributing to identity fingerprinting.",
|
|
1286
|
+
mitigation: "Vary transaction times across different hours of the day. Use scheduled transactions or automation to obscure your timezone.",
|
|
1287
|
+
evidence: [{
|
|
1288
|
+
description: `${maxCount}/${hours.length} transactions during ${mostActiveHours.length} hour(s)`,
|
|
1289
|
+
severity: "MEDIUM",
|
|
1290
|
+
reference: void 0
|
|
1291
|
+
}]
|
|
1292
|
+
});
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
return signals;
|
|
1230
1297
|
}
|
|
1231
1298
|
function detectKnownEntityInteraction(context) {
|
|
1299
|
+
const signals = [];
|
|
1232
1300
|
if (context.labels.size === 0) {
|
|
1233
|
-
return
|
|
1301
|
+
return signals;
|
|
1234
1302
|
}
|
|
1235
|
-
const
|
|
1303
|
+
const entityTypeGroups = /* @__PURE__ */ new Map();
|
|
1236
1304
|
for (const [address, label] of context.labels.entries()) {
|
|
1237
1305
|
let interactionCount = 0;
|
|
1238
1306
|
const relatedTxs = [];
|
|
@@ -1245,99 +1313,241 @@ function detectKnownEntityInteraction(context) {
|
|
|
1245
1313
|
}
|
|
1246
1314
|
}
|
|
1247
1315
|
if (interactionCount > 0) {
|
|
1248
|
-
|
|
1249
|
-
type
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
transactions: relatedTxs
|
|
1257
|
-
},
|
|
1258
|
-
reference: address
|
|
1316
|
+
if (!entityTypeGroups.has(label.type)) {
|
|
1317
|
+
entityTypeGroups.set(label.type, []);
|
|
1318
|
+
}
|
|
1319
|
+
entityTypeGroups.get(label.type).push({
|
|
1320
|
+
address,
|
|
1321
|
+
label,
|
|
1322
|
+
count: interactionCount,
|
|
1323
|
+
txs: relatedTxs
|
|
1259
1324
|
});
|
|
1260
1325
|
}
|
|
1261
1326
|
}
|
|
1262
|
-
if (
|
|
1263
|
-
return
|
|
1327
|
+
if (entityTypeGroups.size === 0) {
|
|
1328
|
+
return signals;
|
|
1264
1329
|
}
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1330
|
+
const exchanges = entityTypeGroups.get("exchange");
|
|
1331
|
+
if (exchanges && exchanges.length > 0) {
|
|
1332
|
+
const evidence = exchanges.map((entity) => ({
|
|
1333
|
+
description: `${entity.count} interaction(s) with ${entity.label.name}`,
|
|
1334
|
+
severity: "HIGH",
|
|
1335
|
+
reference: entity.address
|
|
1336
|
+
}));
|
|
1337
|
+
const totalExchangeTxs = exchanges.reduce((sum, e) => sum + e.count, 0);
|
|
1338
|
+
signals.push({
|
|
1339
|
+
id: "known-entity-exchange",
|
|
1340
|
+
name: "Centralized Exchange Interaction",
|
|
1341
|
+
severity: "HIGH",
|
|
1342
|
+
confidence: 0.95,
|
|
1343
|
+
category: "identity-linkage",
|
|
1344
|
+
reason: `Wallet interacted with ${exchanges.length} centralized exchange(s) in ${totalExchangeTxs} transaction(s).`,
|
|
1345
|
+
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.",
|
|
1346
|
+
mitigation: "Use intermediate wallets to break the direct link. Deposit to privacy protocols before going to CEX. Consider DEXs for better privacy.",
|
|
1347
|
+
evidence
|
|
1348
|
+
});
|
|
1271
1349
|
}
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1350
|
+
const bridges = entityTypeGroups.get("bridge");
|
|
1351
|
+
if (bridges && bridges.length > 0) {
|
|
1352
|
+
const evidence = bridges.map((entity) => ({
|
|
1353
|
+
description: `${entity.count} interaction(s) with ${entity.label.name}`,
|
|
1354
|
+
severity: "MEDIUM",
|
|
1355
|
+
reference: entity.address
|
|
1356
|
+
}));
|
|
1357
|
+
signals.push({
|
|
1358
|
+
id: "known-entity-bridge",
|
|
1359
|
+
name: "Bridge Protocol Interaction",
|
|
1360
|
+
severity: "MEDIUM",
|
|
1361
|
+
confidence: 0.85,
|
|
1362
|
+
category: "identity-linkage",
|
|
1363
|
+
reason: `Wallet interacted with ${bridges.length} bridge protocol(s).`,
|
|
1364
|
+
impact: "Bridge transactions can link your Solana address to addresses on other chains, expanding the tracking surface.",
|
|
1365
|
+
mitigation: "Use privacy-preserving bridges when available. Create separate addresses for cross-chain activity.",
|
|
1366
|
+
evidence
|
|
1367
|
+
});
|
|
1368
|
+
}
|
|
1369
|
+
const others = Array.from(entityTypeGroups.entries()).filter(([type]) => type !== "exchange" && type !== "bridge");
|
|
1370
|
+
if (others.length > 0) {
|
|
1371
|
+
const allOtherEntities = others.flatMap(([_, entities]) => entities);
|
|
1372
|
+
const evidence = allOtherEntities.slice(0, 5).map((entity) => ({
|
|
1373
|
+
description: `${entity.count} interaction(s) with ${entity.label.name} (${entity.label.type})`,
|
|
1374
|
+
severity: "LOW",
|
|
1375
|
+
reference: entity.address
|
|
1376
|
+
}));
|
|
1377
|
+
const totalOtherTxs = allOtherEntities.reduce((sum, e) => sum + e.count, 0);
|
|
1378
|
+
signals.push({
|
|
1379
|
+
id: "known-entity-other",
|
|
1380
|
+
name: "Known Entity Interactions",
|
|
1381
|
+
severity: "LOW",
|
|
1382
|
+
confidence: 0.75,
|
|
1383
|
+
category: "behavioral",
|
|
1384
|
+
reason: `Wallet interacted with ${allOtherEntities.length} known entit${allOtherEntities.length === 1 ? "y" : "ies"} (${totalOtherTxs} transactions).`,
|
|
1385
|
+
impact: "Interactions with known entities create reference points in your transaction history. These can be used to correlate activity and build behavioral profiles.",
|
|
1386
|
+
mitigation: "While interacting with known protocols is often necessary, be aware it creates public association with those services.",
|
|
1387
|
+
evidence
|
|
1388
|
+
});
|
|
1389
|
+
}
|
|
1390
|
+
for (const [address, label] of context.labels.entries()) {
|
|
1391
|
+
let interactionCount = 0;
|
|
1392
|
+
for (const transfer of context.transfers) {
|
|
1393
|
+
if (transfer.from === address || transfer.to === address) {
|
|
1394
|
+
interactionCount++;
|
|
1395
|
+
}
|
|
1396
|
+
}
|
|
1397
|
+
const concentration = interactionCount / context.transfers.length;
|
|
1398
|
+
if (concentration > 0.3 && interactionCount >= 5) {
|
|
1399
|
+
signals.push({
|
|
1400
|
+
id: `known-entity-frequent-${address.slice(0, 8)}`,
|
|
1401
|
+
name: "Frequent Single Entity Interaction",
|
|
1402
|
+
severity: label.type === "exchange" ? "HIGH" : "MEDIUM",
|
|
1403
|
+
confidence: 0.85,
|
|
1404
|
+
category: "behavioral",
|
|
1405
|
+
reason: `${Math.round(concentration * 100)}% of transfers (${interactionCount}/${context.transfers.length}) involve ${label.name}.`,
|
|
1406
|
+
impact: "Heavy concentration of activity with one entity creates a strong link and behavioral dependency that is easily identified.",
|
|
1407
|
+
mitigation: "Diversify your interactions across multiple services. Use different addresses for different service providers.",
|
|
1408
|
+
evidence: [{
|
|
1409
|
+
description: `${interactionCount} transfers with ${label.name} (${label.type})`,
|
|
1410
|
+
severity: label.type === "exchange" ? "HIGH" : "MEDIUM",
|
|
1411
|
+
reference: address
|
|
1412
|
+
}]
|
|
1413
|
+
});
|
|
1414
|
+
}
|
|
1415
|
+
}
|
|
1416
|
+
return signals;
|
|
1282
1417
|
}
|
|
1283
1418
|
function detectBalanceTraceability(context) {
|
|
1419
|
+
const signals = [];
|
|
1284
1420
|
if (context.targetType !== "wallet" || context.transfers.length < 2) {
|
|
1285
|
-
return
|
|
1421
|
+
return signals;
|
|
1286
1422
|
}
|
|
1287
|
-
const fullBalanceTransfers = [];
|
|
1288
|
-
const suspiciousPatterns = [];
|
|
1289
1423
|
const amountPairs = /* @__PURE__ */ new Map();
|
|
1290
1424
|
for (const transfer of context.transfers) {
|
|
1291
1425
|
const amountKey = transfer.amount.toFixed(6);
|
|
1292
1426
|
amountPairs.set(amountKey, (amountPairs.get(amountKey) || 0) + 1);
|
|
1293
1427
|
}
|
|
1294
|
-
const matchingPairs = Array.from(amountPairs.entries()).filter(([_, count]) => count >= 2);
|
|
1428
|
+
const matchingPairs = Array.from(amountPairs.entries()).filter(([_, count]) => count >= 2).sort((a, b) => b[1] - a[1]);
|
|
1295
1429
|
if (matchingPairs.length >= 2) {
|
|
1296
|
-
|
|
1430
|
+
const evidence = matchingPairs.slice(0, 5).map(([amount, count]) => ({
|
|
1431
|
+
description: `Amount ${amount} appears in ${count} transfers`,
|
|
1432
|
+
severity: count >= 4 ? "HIGH" : "MEDIUM",
|
|
1433
|
+
reference: void 0
|
|
1434
|
+
}));
|
|
1435
|
+
const severity = matchingPairs.length >= 4 ? "HIGH" : matchingPairs.length >= 3 ? "MEDIUM" : "LOW";
|
|
1436
|
+
signals.push({
|
|
1437
|
+
id: "balance-matching-pairs",
|
|
1438
|
+
name: "Matching Send/Receive Amounts",
|
|
1439
|
+
severity,
|
|
1440
|
+
confidence: 0.7,
|
|
1441
|
+
category: "traceability",
|
|
1442
|
+
reason: `${matchingPairs.length} amount(s) appear in multiple transfers, suggesting balance movements.`,
|
|
1443
|
+
impact: "Matching amounts can be used to trace balance flows. If you receive X and later send X, observers can link these transactions.",
|
|
1444
|
+
mitigation: "Split large transfers into multiple smaller ones with varied amounts. Avoid sending exact amounts you received.",
|
|
1445
|
+
evidence
|
|
1446
|
+
});
|
|
1297
1447
|
}
|
|
1448
|
+
const sequentialPairs = [];
|
|
1298
1449
|
for (let i = 0; i < context.transfers.length - 1; i++) {
|
|
1299
1450
|
const current = context.transfers[i];
|
|
1300
1451
|
const next = context.transfers[i + 1];
|
|
1301
1452
|
if (current.blockTime && next.blockTime) {
|
|
1302
1453
|
const timeDiff = Math.abs(next.blockTime - current.blockTime);
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1454
|
+
const amountDiff = Math.abs(current.amount - next.amount);
|
|
1455
|
+
const percentDiff = amountDiff / Math.max(current.amount, next.amount);
|
|
1456
|
+
if (timeDiff < 3600 && percentDiff < 0.1 && current.amount > 0.1) {
|
|
1457
|
+
sequentialPairs.push({
|
|
1458
|
+
index: i,
|
|
1459
|
+
amount1: current.amount,
|
|
1460
|
+
amount2: next.amount,
|
|
1461
|
+
timeDiff
|
|
1462
|
+
});
|
|
1306
1463
|
}
|
|
1307
1464
|
}
|
|
1308
1465
|
}
|
|
1309
|
-
if (
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1466
|
+
if (sequentialPairs.length >= 2) {
|
|
1467
|
+
const evidence = sequentialPairs.slice(0, 3).map((pair) => ({
|
|
1468
|
+
description: `${pair.amount1.toFixed(4)} \u2192 ${pair.amount2.toFixed(4)} (${Math.round(pair.timeDiff / 60)} minutes apart)`,
|
|
1469
|
+
severity: pair.timeDiff < 600 ? "HIGH" : "MEDIUM",
|
|
1470
|
+
reference: void 0
|
|
1471
|
+
}));
|
|
1472
|
+
signals.push({
|
|
1473
|
+
id: "balance-sequential-similar",
|
|
1474
|
+
name: "Sequential Similar Amount Transfers",
|
|
1475
|
+
severity: "MEDIUM",
|
|
1476
|
+
confidence: 0.65,
|
|
1477
|
+
category: "traceability",
|
|
1478
|
+
reason: `${sequentialPairs.length} instance(s) of similar amounts transferred in quick succession.`,
|
|
1479
|
+
impact: "Sequential similar amounts suggest balance movements and make it easy to trace funds through intermediate addresses.",
|
|
1480
|
+
mitigation: "Add random delays between transactions. Vary amounts to obscure the flow path.",
|
|
1481
|
+
evidence
|
|
1318
1482
|
});
|
|
1319
1483
|
}
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1484
|
+
const roundNumbers = context.transfers.filter((t) => {
|
|
1485
|
+
const amount = t.amount;
|
|
1486
|
+
if (amount === 0) return false;
|
|
1487
|
+
return (amount === Math.floor(amount) || // Whole number
|
|
1488
|
+
amount * 10 === Math.floor(amount * 10) || // One decimal
|
|
1489
|
+
amount * 100 === Math.floor(amount * 100)) && (amount % 1 === 0 || // 1, 10, 100
|
|
1490
|
+
amount * 10 % 1 === 0 || // 0.1, 0.5
|
|
1491
|
+
amount * 100 % 1 === 0);
|
|
1492
|
+
});
|
|
1493
|
+
const roundNumberRatio = roundNumbers.length / context.transfers.length;
|
|
1494
|
+
if (roundNumberRatio > 0.7 && context.transfers.length >= 5) {
|
|
1495
|
+
signals.push({
|
|
1496
|
+
id: "balance-round-numbers",
|
|
1497
|
+
name: "High Proportion of Round Number Transfers",
|
|
1498
|
+
severity: "LOW",
|
|
1499
|
+
confidence: 0.6,
|
|
1500
|
+
category: "behavioral",
|
|
1501
|
+
reason: `${Math.round(roundNumberRatio * 100)}% of transfers use round numbers.`,
|
|
1502
|
+
impact: "Round numbers are easier to remember and track. They can contribute to balance traceability when combined with other patterns.",
|
|
1503
|
+
mitigation: "Use more varied amounts. Add small random values to make amounts less predictable.",
|
|
1504
|
+
evidence: [{
|
|
1505
|
+
description: `${roundNumbers.length}/${context.transfers.length} transfers are round numbers`,
|
|
1506
|
+
severity: "LOW",
|
|
1507
|
+
reference: void 0
|
|
1508
|
+
}]
|
|
1325
1509
|
});
|
|
1326
1510
|
}
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1511
|
+
const tokenTransfers = /* @__PURE__ */ new Map();
|
|
1512
|
+
for (const transfer of context.transfers) {
|
|
1513
|
+
const token = transfer.token || "SOL";
|
|
1514
|
+
if (!tokenTransfers.has(token)) {
|
|
1515
|
+
tokenTransfers.set(token, []);
|
|
1516
|
+
}
|
|
1517
|
+
tokenTransfers.get(token).push(transfer);
|
|
1518
|
+
}
|
|
1519
|
+
for (const [token, transfers] of tokenTransfers) {
|
|
1520
|
+
if (transfers.length < 2) continue;
|
|
1521
|
+
const receives = transfers.filter((t) => t.to === context.target);
|
|
1522
|
+
const sends = transfers.filter((t) => t.from === context.target);
|
|
1523
|
+
for (const receive of receives) {
|
|
1524
|
+
for (const send of sends) {
|
|
1525
|
+
if (!receive.blockTime || !send.blockTime) continue;
|
|
1526
|
+
if (send.blockTime <= receive.blockTime) continue;
|
|
1527
|
+
const timeDiff = send.blockTime - receive.blockTime;
|
|
1528
|
+
const percentDiff = Math.abs(send.amount - receive.amount) / receive.amount;
|
|
1529
|
+
if (percentDiff < 0.05 && timeDiff < 86400 && receive.amount > 1) {
|
|
1530
|
+
signals.push({
|
|
1531
|
+
id: `balance-full-movement-${token}`,
|
|
1532
|
+
name: "Full Balance Movement Detected",
|
|
1533
|
+
severity: "HIGH",
|
|
1534
|
+
confidence: 0.8,
|
|
1535
|
+
category: "traceability",
|
|
1536
|
+
reason: `Received ${receive.amount.toFixed(4)} ${token}, then sent ${send.amount.toFixed(4)} ${token} shortly after.`,
|
|
1537
|
+
impact: "Moving entire received balances makes fund flow trivially traceable. The path from source to destination is clear.",
|
|
1538
|
+
mitigation: "Split received funds before sending. Mix with other funds. Add delays and intermediate steps.",
|
|
1539
|
+
evidence: [{
|
|
1540
|
+
description: `Received ${receive.amount.toFixed(4)} \u2192 Sent ${send.amount.toFixed(4)} (${Math.round(timeDiff / 60)} minutes later)`,
|
|
1541
|
+
severity: "HIGH",
|
|
1542
|
+
reference: void 0
|
|
1543
|
+
}]
|
|
1544
|
+
});
|
|
1545
|
+
break;
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
}
|
|
1330
1549
|
}
|
|
1331
|
-
return
|
|
1332
|
-
id: "balance-traceability",
|
|
1333
|
-
name: "Balance Traceability",
|
|
1334
|
-
severity,
|
|
1335
|
-
reason: "Wallet shows patterns that enable balance tracking",
|
|
1336
|
-
impact: "Traceable balance movements allow observers to follow funds through the blockchain, linking your transactions and revealing your financial activity.",
|
|
1337
|
-
evidence,
|
|
1338
|
-
mitigation: "Split large transfers into multiple smaller ones, introduce timing delays, or use privacy protocols that obscure amounts.",
|
|
1339
|
-
confidence: 0.7
|
|
1340
|
-
};
|
|
1550
|
+
return signals;
|
|
1341
1551
|
}
|
|
1342
1552
|
function detectFeePayerReuse(context) {
|
|
1343
1553
|
const signals = [];
|
|
@@ -1857,11 +2067,279 @@ function detectTokenAccountLifecycle(context) {
|
|
|
1857
2067
|
}
|
|
1858
2068
|
return signals;
|
|
1859
2069
|
}
|
|
2070
|
+
function detectMemoExposure(context) {
|
|
2071
|
+
const signals = [];
|
|
2072
|
+
if (context.transactionCount === 0) {
|
|
2073
|
+
return signals;
|
|
2074
|
+
}
|
|
2075
|
+
if (!context.transactions || context.transactions.length === 0) {
|
|
2076
|
+
return signals;
|
|
2077
|
+
}
|
|
2078
|
+
const MEMO_PROGRAM = "MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr";
|
|
2079
|
+
const MEMO_PROGRAM_V1 = "Memo1UhkJRfHyvLMcVucJwxXeuD728EqVDDwQDxFMNo";
|
|
2080
|
+
const memoInstructions = context.instructions.filter(
|
|
2081
|
+
(inst) => inst.programId === MEMO_PROGRAM || inst.programId === MEMO_PROGRAM_V1
|
|
2082
|
+
);
|
|
2083
|
+
if (memoInstructions.length === 0) {
|
|
2084
|
+
return signals;
|
|
2085
|
+
}
|
|
2086
|
+
const suspiciousMemos = [];
|
|
2087
|
+
for (const inst of memoInstructions) {
|
|
2088
|
+
if (!inst.data || typeof inst.data !== "string") continue;
|
|
2089
|
+
const memoText = inst.data.trim();
|
|
2090
|
+
if (memoText.length === 0) continue;
|
|
2091
|
+
let severity = "LOW";
|
|
2092
|
+
const patterns = [];
|
|
2093
|
+
if (/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/.test(memoText)) {
|
|
2094
|
+
patterns.push("email address");
|
|
2095
|
+
severity = "HIGH";
|
|
2096
|
+
}
|
|
2097
|
+
if (/https?:\/\/[^\s]+/.test(memoText)) {
|
|
2098
|
+
patterns.push("URL");
|
|
2099
|
+
severity = severity === "HIGH" ? "HIGH" : "MEDIUM";
|
|
2100
|
+
}
|
|
2101
|
+
if (/(\+\d{1,3}[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}/.test(memoText)) {
|
|
2102
|
+
patterns.push("phone number");
|
|
2103
|
+
severity = "HIGH";
|
|
2104
|
+
}
|
|
2105
|
+
const capitalizedWords = memoText.match(/\b[A-Z][a-z]+(\s+[A-Z][a-z]+)+\b/g);
|
|
2106
|
+
if (capitalizedWords && capitalizedWords.length > 0) {
|
|
2107
|
+
patterns.push("likely name(s)");
|
|
2108
|
+
severity = severity === "HIGH" ? "HIGH" : "MEDIUM";
|
|
2109
|
+
}
|
|
2110
|
+
if (memoText.length > 50 && !patterns.length) {
|
|
2111
|
+
patterns.push("long descriptive text");
|
|
2112
|
+
severity = "MEDIUM";
|
|
2113
|
+
}
|
|
2114
|
+
if (/invoice|payment|order|transaction|ref|reference|id|bill/i.test(memoText)) {
|
|
2115
|
+
patterns.push("payment reference");
|
|
2116
|
+
severity = severity === "HIGH" ? "HIGH" : "MEDIUM";
|
|
2117
|
+
}
|
|
2118
|
+
if (patterns.length > 0) {
|
|
2119
|
+
suspiciousMemos.push({
|
|
2120
|
+
content: memoText.length > 100 ? memoText.slice(0, 100) + "..." : memoText,
|
|
2121
|
+
signature: inst.signature,
|
|
2122
|
+
severity
|
|
2123
|
+
});
|
|
2124
|
+
}
|
|
2125
|
+
}
|
|
2126
|
+
if (suspiciousMemos.length === 0) {
|
|
2127
|
+
signals.push({
|
|
2128
|
+
id: "memo-usage",
|
|
2129
|
+
name: "Memo Program Usage",
|
|
2130
|
+
severity: "LOW",
|
|
2131
|
+
confidence: 0.6,
|
|
2132
|
+
category: "information-leak",
|
|
2133
|
+
reason: `${memoInstructions.length} transaction(s) use memo program. Memos are permanently visible on-chain.`,
|
|
2134
|
+
impact: "Memo data is public and permanent. Even non-sensitive memos can contribute to behavioral fingerprinting.",
|
|
2135
|
+
mitigation: "Avoid using memos unless necessary. Never include personal information.",
|
|
2136
|
+
evidence: [{
|
|
2137
|
+
description: `${memoInstructions.length} transaction(s) with memos`,
|
|
2138
|
+
severity: "LOW",
|
|
2139
|
+
reference: void 0
|
|
2140
|
+
}]
|
|
2141
|
+
});
|
|
2142
|
+
} else {
|
|
2143
|
+
const highSeverity = suspiciousMemos.filter((m) => m.severity === "HIGH");
|
|
2144
|
+
const mediumSeverity = suspiciousMemos.filter((m) => m.severity === "MEDIUM");
|
|
2145
|
+
if (highSeverity.length > 0) {
|
|
2146
|
+
const evidence = highSeverity.map((memo) => ({
|
|
2147
|
+
description: `"${memo.content}"`,
|
|
2148
|
+
severity: "HIGH",
|
|
2149
|
+
reference: `https://solscan.io/tx/${memo.signature}`
|
|
2150
|
+
}));
|
|
2151
|
+
signals.push({
|
|
2152
|
+
id: "memo-pii-exposure",
|
|
2153
|
+
name: "Personal Information in Memo",
|
|
2154
|
+
severity: "HIGH",
|
|
2155
|
+
confidence: 0.9,
|
|
2156
|
+
category: "information-leak",
|
|
2157
|
+
reason: `${highSeverity.length} memo(s) contain personal identifying information (email, phone, name).`,
|
|
2158
|
+
impact: "CRITICAL: Personal information in memos is permanently public. This can directly link your wallet to your real-world identity.",
|
|
2159
|
+
mitigation: "Never put personal information in memos. Contact addresses involved to stop this practice if possible.",
|
|
2160
|
+
evidence
|
|
2161
|
+
});
|
|
2162
|
+
}
|
|
2163
|
+
if (mediumSeverity.length > 0) {
|
|
2164
|
+
const evidence = mediumSeverity.slice(0, 5).map((memo) => ({
|
|
2165
|
+
description: `"${memo.content}"`,
|
|
2166
|
+
severity: "MEDIUM",
|
|
2167
|
+
reference: `https://solscan.io/tx/${memo.signature}`
|
|
2168
|
+
}));
|
|
2169
|
+
signals.push({
|
|
2170
|
+
id: "memo-descriptive-content",
|
|
2171
|
+
name: "Descriptive Content in Memo",
|
|
2172
|
+
severity: "MEDIUM",
|
|
2173
|
+
confidence: 0.7,
|
|
2174
|
+
category: "information-leak",
|
|
2175
|
+
reason: `${mediumSeverity.length} memo(s) contain descriptive or identifying content.`,
|
|
2176
|
+
impact: "Descriptive memos create behavioral fingerprints and can indirectly reveal identity or transaction purpose.",
|
|
2177
|
+
mitigation: "Minimize memo usage. Use generic or coded references instead of descriptive text.",
|
|
2178
|
+
evidence
|
|
2179
|
+
});
|
|
2180
|
+
}
|
|
2181
|
+
}
|
|
2182
|
+
return signals;
|
|
2183
|
+
}
|
|
2184
|
+
function detectAddressReuse(context) {
|
|
2185
|
+
const signals = [];
|
|
2186
|
+
if (context.targetType !== "wallet" || context.transactionCount < 5) {
|
|
2187
|
+
return signals;
|
|
2188
|
+
}
|
|
2189
|
+
const activityTypes = /* @__PURE__ */ new Set();
|
|
2190
|
+
const activityDetails = /* @__PURE__ */ new Map();
|
|
2191
|
+
const DEFI_PROGRAMS = /* @__PURE__ */ new Set([
|
|
2192
|
+
"JUP",
|
|
2193
|
+
"Raydium",
|
|
2194
|
+
"Orca",
|
|
2195
|
+
"Marinade",
|
|
2196
|
+
"Lido",
|
|
2197
|
+
"Lifinity",
|
|
2198
|
+
"Serum"
|
|
2199
|
+
// Add more as needed
|
|
2200
|
+
]);
|
|
2201
|
+
const NFT_PROGRAMS = /* @__PURE__ */ new Set([
|
|
2202
|
+
"Magic Eden",
|
|
2203
|
+
"Tensor",
|
|
2204
|
+
"OpenSea",
|
|
2205
|
+
"Metaplex"
|
|
2206
|
+
]);
|
|
2207
|
+
const GAMING_PROGRAMS = /* @__PURE__ */ new Set([
|
|
2208
|
+
"Star Atlas",
|
|
2209
|
+
"Genopets",
|
|
2210
|
+
"Aurory"
|
|
2211
|
+
]);
|
|
2212
|
+
const DAO_PROGRAMS = /* @__PURE__ */ new Set([
|
|
2213
|
+
"Realms",
|
|
2214
|
+
"Squads",
|
|
2215
|
+
"Tribeca"
|
|
2216
|
+
]);
|
|
2217
|
+
for (const inst of context.instructions) {
|
|
2218
|
+
const label = context.labels.get(inst.programId);
|
|
2219
|
+
const programName = label?.name || "";
|
|
2220
|
+
if (DEFI_PROGRAMS.has(programName) || /swap|pool|stake|lend|borrow/i.test(programName)) {
|
|
2221
|
+
activityTypes.add("DeFi");
|
|
2222
|
+
if (!activityDetails.has("DeFi")) {
|
|
2223
|
+
activityDetails.set("DeFi", { count: 0, programs: /* @__PURE__ */ new Set() });
|
|
2224
|
+
}
|
|
2225
|
+
const details = activityDetails.get("DeFi");
|
|
2226
|
+
details.count++;
|
|
2227
|
+
details.programs.add(programName || inst.programId.slice(0, 8));
|
|
2228
|
+
}
|
|
2229
|
+
if (NFT_PROGRAMS.has(programName) || /nft|marketplace|mint/i.test(programName)) {
|
|
2230
|
+
activityTypes.add("NFT");
|
|
2231
|
+
if (!activityDetails.has("NFT")) {
|
|
2232
|
+
activityDetails.set("NFT", { count: 0, programs: /* @__PURE__ */ new Set() });
|
|
2233
|
+
}
|
|
2234
|
+
const details = activityDetails.get("NFT");
|
|
2235
|
+
details.count++;
|
|
2236
|
+
details.programs.add(programName || inst.programId.slice(0, 8));
|
|
2237
|
+
}
|
|
2238
|
+
if (GAMING_PROGRAMS.has(programName) || /game|play/i.test(programName)) {
|
|
2239
|
+
activityTypes.add("Gaming");
|
|
2240
|
+
if (!activityDetails.has("Gaming")) {
|
|
2241
|
+
activityDetails.set("Gaming", { count: 0, programs: /* @__PURE__ */ new Set() });
|
|
2242
|
+
}
|
|
2243
|
+
const details = activityDetails.get("Gaming");
|
|
2244
|
+
details.count++;
|
|
2245
|
+
details.programs.add(programName || inst.programId.slice(0, 8));
|
|
2246
|
+
}
|
|
2247
|
+
if (DAO_PROGRAMS.has(programName) || /dao|governance|vote/i.test(programName)) {
|
|
2248
|
+
activityTypes.add("DAO");
|
|
2249
|
+
if (!activityDetails.has("DAO")) {
|
|
2250
|
+
activityDetails.set("DAO", { count: 0, programs: /* @__PURE__ */ new Set() });
|
|
2251
|
+
}
|
|
2252
|
+
const details = activityDetails.get("DAO");
|
|
2253
|
+
details.count++;
|
|
2254
|
+
details.programs.add(programName || inst.programId.slice(0, 8));
|
|
2255
|
+
}
|
|
2256
|
+
}
|
|
2257
|
+
const hasExchangeInteraction = Array.from(context.labels.values()).some((label) => label.type === "exchange");
|
|
2258
|
+
if (hasExchangeInteraction) {
|
|
2259
|
+
activityTypes.add("Exchange");
|
|
2260
|
+
activityDetails.set("Exchange", {
|
|
2261
|
+
count: context.transfers.filter(
|
|
2262
|
+
(t) => context.labels.has(t.from) || context.labels.has(t.to)
|
|
2263
|
+
).length,
|
|
2264
|
+
programs: /* @__PURE__ */ new Set(["CEX"])
|
|
2265
|
+
});
|
|
2266
|
+
}
|
|
2267
|
+
const simpleTransfers = context.transfers.filter((t) => {
|
|
2268
|
+
const tx = context.transactions?.find((tx2) => tx2.signature === t.signature);
|
|
2269
|
+
return tx && tx.programs && tx.programs.length <= 2;
|
|
2270
|
+
});
|
|
2271
|
+
if (simpleTransfers.length >= 3) {
|
|
2272
|
+
activityTypes.add("P2P Transfers");
|
|
2273
|
+
activityDetails.set("P2P Transfers", {
|
|
2274
|
+
count: simpleTransfers.length,
|
|
2275
|
+
programs: /* @__PURE__ */ new Set(["Direct"])
|
|
2276
|
+
});
|
|
2277
|
+
}
|
|
2278
|
+
const diversityCount = activityTypes.size;
|
|
2279
|
+
if (diversityCount >= 4) {
|
|
2280
|
+
const evidence = Array.from(activityDetails.entries()).map(([type, details]) => ({
|
|
2281
|
+
description: `${type}: ${details.count} transaction(s) across ${details.programs.size} program(s)`,
|
|
2282
|
+
severity: "HIGH",
|
|
2283
|
+
reference: void 0
|
|
2284
|
+
}));
|
|
2285
|
+
signals.push({
|
|
2286
|
+
id: "address-high-diversity",
|
|
2287
|
+
name: "High Activity Diversity on Single Address",
|
|
2288
|
+
severity: "HIGH",
|
|
2289
|
+
confidence: 0.85,
|
|
2290
|
+
category: "linkability",
|
|
2291
|
+
reason: `This address is used for ${diversityCount} distinct activity types: ${Array.from(activityTypes).join(", ")}.`,
|
|
2292
|
+
impact: "Using one address for multiple unrelated activities links them all together. This creates a comprehensive behavioral profile.",
|
|
2293
|
+
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.",
|
|
2294
|
+
evidence
|
|
2295
|
+
});
|
|
2296
|
+
} else if (diversityCount === 3) {
|
|
2297
|
+
const evidence = Array.from(activityDetails.entries()).map(([type, details]) => ({
|
|
2298
|
+
description: `${type}: ${details.count} transaction(s)`,
|
|
2299
|
+
severity: "MEDIUM",
|
|
2300
|
+
reference: void 0
|
|
2301
|
+
}));
|
|
2302
|
+
signals.push({
|
|
2303
|
+
id: "address-moderate-diversity",
|
|
2304
|
+
name: "Moderate Activity Diversity on Single Address",
|
|
2305
|
+
severity: "MEDIUM",
|
|
2306
|
+
confidence: 0.7,
|
|
2307
|
+
category: "linkability",
|
|
2308
|
+
reason: `This address is used for ${diversityCount} activity types: ${Array.from(activityTypes).join(", ")}.`,
|
|
2309
|
+
impact: "Multiple activity types on one address create linkage between otherwise separate behaviors.",
|
|
2310
|
+
mitigation: "Consider using separate addresses for different activities to improve privacy compartmentalization.",
|
|
2311
|
+
evidence
|
|
2312
|
+
});
|
|
2313
|
+
}
|
|
2314
|
+
if (context.timeRange.earliest && context.timeRange.latest) {
|
|
2315
|
+
const timeSpanDays = (context.timeRange.latest - context.timeRange.earliest) / (60 * 60 * 24);
|
|
2316
|
+
if (timeSpanDays > 180 && context.transactionCount > 50) {
|
|
2317
|
+
signals.push({
|
|
2318
|
+
id: "address-long-term-usage",
|
|
2319
|
+
name: "Long-Term Single Address Usage",
|
|
2320
|
+
severity: "MEDIUM",
|
|
2321
|
+
confidence: 0.75,
|
|
2322
|
+
category: "behavioral",
|
|
2323
|
+
reason: `This address has been actively used for ${Math.round(timeSpanDays)} days with ${context.transactionCount} transactions.`,
|
|
2324
|
+
impact: "Long-term address usage accumulates a rich behavioral history. All activities over time are permanently linked.",
|
|
2325
|
+
mitigation: "Periodically rotate to new addresses to compartmentalize different time periods of activity.",
|
|
2326
|
+
evidence: [{
|
|
2327
|
+
description: `${context.transactionCount} transactions over ${Math.round(timeSpanDays)} days`,
|
|
2328
|
+
severity: "MEDIUM",
|
|
2329
|
+
reference: void 0
|
|
2330
|
+
}]
|
|
2331
|
+
});
|
|
2332
|
+
}
|
|
2333
|
+
}
|
|
2334
|
+
return signals;
|
|
2335
|
+
}
|
|
1860
2336
|
var REPORT_VERSION = "1.0.0";
|
|
1861
2337
|
var HEURISTICS = [
|
|
1862
2338
|
// Solana-specific (highest priority)
|
|
1863
2339
|
detectFeePayerReuse,
|
|
1864
2340
|
detectSignerOverlap,
|
|
2341
|
+
detectMemoExposure,
|
|
2342
|
+
detectAddressReuse,
|
|
1865
2343
|
detectKnownEntityInteraction,
|
|
1866
2344
|
detectCounterpartyReuse,
|
|
1867
2345
|
detectInstructionFingerprinting,
|
|
@@ -1905,15 +2383,24 @@ function generateMitigations(signals) {
|
|
|
1905
2383
|
if (signalIds.has("token-account-churn") || signalIds.has("rent-refund-clustering")) {
|
|
1906
2384
|
mitigations.add("Avoid closing token accounts if privacy is important - the rent refund creates linkage.");
|
|
1907
2385
|
}
|
|
1908
|
-
if (signalIds.has("
|
|
2386
|
+
if (signalIds.has("memo-pii-exposure") || signalIds.has("memo-descriptive-content") || signalIds.has("memo-usage")) {
|
|
2387
|
+
mitigations.add("Never include personal information in transaction memos - they are permanently public.");
|
|
2388
|
+
}
|
|
2389
|
+
if (signalIds.has("address-high-diversity") || signalIds.has("address-moderate-diversity") || signalIds.has("address-long-term-usage")) {
|
|
2390
|
+
mitigations.add("Use separate addresses for different activity types to compartmentalize your behavior.");
|
|
2391
|
+
}
|
|
2392
|
+
if (signalIds.has("known-entity-exchange") || signalIds.has("known-entity-bridge") || signalIds.has("known-entity-other") || signalIds.has("known-entity-interaction")) {
|
|
1909
2393
|
mitigations.add("Avoid direct interactions between privacy-sensitive wallets and KYC services.");
|
|
1910
2394
|
}
|
|
1911
2395
|
if (signalIds.has("counterparty-reuse") || signalIds.has("pda-reuse")) {
|
|
1912
2396
|
mitigations.add("Use different addresses for different counterparties or contexts.");
|
|
1913
2397
|
}
|
|
1914
|
-
if (signalIds.has("timing-
|
|
2398
|
+
if (signalIds.has("timing-burst") || signalIds.has("timing-regular-interval") || signalIds.has("timing-timezone-pattern") || signalIds.has("timing-correlation")) {
|
|
1915
2399
|
mitigations.add("Introduce timing delays and vary transaction patterns to reduce correlation.");
|
|
1916
2400
|
}
|
|
2401
|
+
if (signalIds.has("balance-matching-pairs") || signalIds.has("balance-sequential-similar") || signalIds.has("balance-full-movement") || signalIds.has("balance-traceability")) {
|
|
2402
|
+
mitigations.add("Vary transfer amounts and add delays to reduce balance traceability.");
|
|
2403
|
+
}
|
|
1917
2404
|
if (signalIds.has("amount-reuse")) {
|
|
1918
2405
|
mitigations.add("Vary transaction amounts to avoid creating fingerprints.");
|
|
1919
2406
|
}
|
|
@@ -1979,7 +2466,24 @@ var StaticLabelProvider = class {
|
|
|
1979
2466
|
*/
|
|
1980
2467
|
loadLabels(customPath) {
|
|
1981
2468
|
try {
|
|
1982
|
-
|
|
2469
|
+
let path;
|
|
2470
|
+
if (customPath) {
|
|
2471
|
+
path = customPath;
|
|
2472
|
+
} else {
|
|
2473
|
+
const locations = [
|
|
2474
|
+
join(__dirname, "known-addresses.json"),
|
|
2475
|
+
join(__dirname, "../../..", "known-addresses.json")
|
|
2476
|
+
// From src/labels to repo root
|
|
2477
|
+
];
|
|
2478
|
+
path = locations.find((loc) => {
|
|
2479
|
+
try {
|
|
2480
|
+
readFileSync(loc, "utf-8");
|
|
2481
|
+
return true;
|
|
2482
|
+
} catch {
|
|
2483
|
+
return false;
|
|
2484
|
+
}
|
|
2485
|
+
}) || locations[0];
|
|
2486
|
+
}
|
|
1983
2487
|
const data = readFileSync(path, "utf-8");
|
|
1984
2488
|
const parsed = JSON.parse(data);
|
|
1985
2489
|
if (!parsed.labels || !Array.isArray(parsed.labels)) {
|
|
@@ -2037,7 +2541,6 @@ var StaticLabelProvider = class {
|
|
|
2037
2541
|
function createDefaultLabelProvider() {
|
|
2038
2542
|
return new StaticLabelProvider();
|
|
2039
2543
|
}
|
|
2040
|
-
var VERSION = "0.1.0";
|
|
2041
2544
|
|
|
2042
2545
|
// src/formatter.js
|
|
2043
2546
|
import chalk from "chalk";
|
|
@@ -2181,21 +2684,21 @@ function formatReport(report, noColor = false) {
|
|
|
2181
2684
|
return lines.join("\n");
|
|
2182
2685
|
}
|
|
2183
2686
|
|
|
2184
|
-
// src/commands/wallet.
|
|
2687
|
+
// src/commands/wallet.ts
|
|
2185
2688
|
async function scanWallet(address, options) {
|
|
2186
2689
|
try {
|
|
2187
|
-
if (!options.rpc) {
|
|
2188
|
-
console.error("Error: RPC URL is required. Set SOLANA_RPC env var or use --rpc flag.");
|
|
2189
|
-
process.exit(1);
|
|
2190
|
-
}
|
|
2191
2690
|
console.error(`Scanning wallet: ${address}`);
|
|
2192
|
-
console.error(`Using RPC: ${options.rpc.substring(0, 30)}...`);
|
|
2193
2691
|
console.error("");
|
|
2194
|
-
const client = new RPCClient(
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2692
|
+
const client = new RPCClient(
|
|
2693
|
+
options.rpc ? {
|
|
2694
|
+
rpcUrl: options.rpc,
|
|
2695
|
+
maxConcurrency: 5,
|
|
2696
|
+
maxRetries: 3
|
|
2697
|
+
} : {
|
|
2698
|
+
maxConcurrency: 5,
|
|
2699
|
+
maxRetries: 3
|
|
2700
|
+
}
|
|
2701
|
+
);
|
|
2199
2702
|
console.error("Collecting transaction data...");
|
|
2200
2703
|
const maxSigs = parseInt(options.maxSignatures || "100", 10);
|
|
2201
2704
|
const rawData = await collectWalletData(client, address, {
|
|
@@ -2232,22 +2735,22 @@ async function scanWallet(address, options) {
|
|
|
2232
2735
|
}
|
|
2233
2736
|
}
|
|
2234
2737
|
|
|
2235
|
-
// src/commands/transaction.
|
|
2738
|
+
// src/commands/transaction.ts
|
|
2236
2739
|
import { writeFileSync as writeFileSync2 } from "fs";
|
|
2237
2740
|
async function scanTransaction(signature, options) {
|
|
2238
2741
|
try {
|
|
2239
|
-
if (!options.rpc) {
|
|
2240
|
-
console.error("Error: RPC URL is required. Set SOLANA_RPC env var or use --rpc flag.");
|
|
2241
|
-
process.exit(1);
|
|
2242
|
-
}
|
|
2243
2742
|
console.error(`Scanning transaction: ${signature}`);
|
|
2244
|
-
console.error(`Using RPC: ${options.rpc.substring(0, 30)}...`);
|
|
2245
2743
|
console.error("");
|
|
2246
|
-
const client = new RPCClient(
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2744
|
+
const client = new RPCClient(
|
|
2745
|
+
options.rpc ? {
|
|
2746
|
+
rpcUrl: options.rpc,
|
|
2747
|
+
maxConcurrency: 5,
|
|
2748
|
+
maxRetries: 3
|
|
2749
|
+
} : {
|
|
2750
|
+
maxConcurrency: 5,
|
|
2751
|
+
maxRetries: 3
|
|
2752
|
+
}
|
|
2753
|
+
);
|
|
2251
2754
|
console.error("Fetching transaction...");
|
|
2252
2755
|
const rawData = await collectTransactionData(client, signature);
|
|
2253
2756
|
if (!rawData.transaction) {
|
|
@@ -2283,22 +2786,22 @@ async function scanTransaction(signature, options) {
|
|
|
2283
2786
|
}
|
|
2284
2787
|
}
|
|
2285
2788
|
|
|
2286
|
-
// src/commands/program.
|
|
2789
|
+
// src/commands/program.ts
|
|
2287
2790
|
import { writeFileSync as writeFileSync3 } from "fs";
|
|
2288
2791
|
async function scanProgram(programId, options) {
|
|
2289
2792
|
try {
|
|
2290
|
-
if (!options.rpc) {
|
|
2291
|
-
console.error("Error: RPC URL is required. Set SOLANA_RPC env var or use --rpc flag.");
|
|
2292
|
-
process.exit(1);
|
|
2293
|
-
}
|
|
2294
2793
|
console.error(`Scanning program: ${programId}`);
|
|
2295
|
-
console.error(`Using RPC: ${options.rpc.substring(0, 30)}...`);
|
|
2296
2794
|
console.error("");
|
|
2297
|
-
const client = new RPCClient(
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
-
|
|
2301
|
-
|
|
2795
|
+
const client = new RPCClient(
|
|
2796
|
+
options.rpc ? {
|
|
2797
|
+
rpcUrl: options.rpc,
|
|
2798
|
+
maxConcurrency: 5,
|
|
2799
|
+
maxRetries: 3
|
|
2800
|
+
} : {
|
|
2801
|
+
maxConcurrency: 5,
|
|
2802
|
+
maxRetries: 3
|
|
2803
|
+
}
|
|
2804
|
+
);
|
|
2302
2805
|
console.error("Collecting program data...");
|
|
2303
2806
|
const maxAccounts = parseInt(options.maxAccounts || "100", 10);
|
|
2304
2807
|
const maxTxs = parseInt(options.maxTransactions || "50", 10);
|
|
@@ -2341,8 +2844,8 @@ dotenv.config({ path: ".env.local" });
|
|
|
2341
2844
|
dotenv.config();
|
|
2342
2845
|
var program = new Command();
|
|
2343
2846
|
program.name("solana-privacy-scanner").description("Privacy risk scanner for Solana blockchain").version(VERSION);
|
|
2344
|
-
program.command("scan-wallet").alias("wallet").description("Scan a Solana wallet address for privacy risks").argument("<address>", "Wallet address to scan").option("--rpc <url>", "RPC endpoint URL", process.env.SOLANA_RPC).option("--json", "Output as JSON", false).option("--max-signatures <number>", "Maximum number of signatures to fetch", "100").option("--output <file>", "Write output to file").action(scanWallet);
|
|
2345
|
-
program.command("scan-transaction").alias("tx").description("Scan a single Solana transaction for privacy risks").argument("<signature>", "Transaction signature to scan").option("--rpc <url>", "RPC endpoint URL", process.env.SOLANA_RPC).option("--json", "Output as JSON", false).option("--output <file>", "Write output to file").action(scanTransaction);
|
|
2346
|
-
program.command("scan-program").alias("program").description("Scan a Solana program for privacy risks").argument("<programId>", "Program ID to scan").option("--rpc <url>", "RPC endpoint URL", process.env.SOLANA_RPC).option("--json", "Output as JSON", false).option("--max-accounts <number>", "Maximum number of accounts to fetch", "100").option("--max-transactions <number>", "Maximum number of transactions to fetch", "50").option("--output <file>", "Write output to file").action(scanProgram);
|
|
2847
|
+
program.command("scan-wallet").alias("wallet").description("Scan a Solana wallet address for privacy risks").argument("<address>", "Wallet address to scan").option("--rpc <url>", "Custom RPC endpoint URL (optional)", process.env.SOLANA_RPC).option("--json", "Output as JSON", false).option("--max-signatures <number>", "Maximum number of signatures to fetch", "100").option("--output <file>", "Write output to file").action(scanWallet);
|
|
2848
|
+
program.command("scan-transaction").alias("tx").description("Scan a single Solana transaction for privacy risks").argument("<signature>", "Transaction signature to scan").option("--rpc <url>", "Custom RPC endpoint URL (optional)", process.env.SOLANA_RPC).option("--json", "Output as JSON", false).option("--output <file>", "Write output to file").action(scanTransaction);
|
|
2849
|
+
program.command("scan-program").alias("program").description("Scan a Solana program for privacy risks").argument("<programId>", "Program ID to scan").option("--rpc <url>", "Custom RPC endpoint URL (optional)", process.env.SOLANA_RPC).option("--json", "Output as JSON", false).option("--max-accounts <number>", "Maximum number of accounts to fetch", "100").option("--max-transactions <number>", "Maximum number of transactions to fetch", "50").option("--output <file>", "Write output to file").action(scanProgram);
|
|
2347
2850
|
program.parse();
|
|
2348
2851
|
//# sourceMappingURL=index.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "solana-privacy-scanner",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "CLI tool for analyzing Solana wallet privacy and on-chain exposure - scan wallets, transactions, and programs",
|
|
6
6
|
"bin": {
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
"url": "https://github.com/taylorferran/solana-privacy-scanner/issues"
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"solana-privacy-scanner-core": "^0.1
|
|
40
|
+
"solana-privacy-scanner-core": "^0.3.1",
|
|
41
41
|
"chalk": "^5.3.0",
|
|
42
42
|
"commander": "^12.1.0",
|
|
43
43
|
"dotenv": "^16.4.7"
|