solana-privacy-scanner-core 0.1.4 → 0.3.0
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 +1058 -89
- package/dist/index.cjs.map +3 -3
- package/dist/index.js +1053 -89
- package/dist/index.js.map +3 -3
- package/package.json +1 -1
package/dist/index.cjs
CHANGED
|
@@ -30,6 +30,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
30
30
|
// src/index.ts
|
|
31
31
|
var index_exports = {};
|
|
32
32
|
__export(index_exports, {
|
|
33
|
+
DEFAULT_RPC_URL: () => DEFAULT_RPC_URL,
|
|
33
34
|
RPCClient: () => RPCClient,
|
|
34
35
|
StaticLabelProvider: () => StaticLabelProvider,
|
|
35
36
|
VERSION: () => VERSION,
|
|
@@ -40,8 +41,12 @@ __export(index_exports, {
|
|
|
40
41
|
detectAmountReuse: () => detectAmountReuse,
|
|
41
42
|
detectBalanceTraceability: () => detectBalanceTraceability,
|
|
42
43
|
detectCounterpartyReuse: () => detectCounterpartyReuse,
|
|
44
|
+
detectFeePayerReuse: () => detectFeePayerReuse,
|
|
45
|
+
detectInstructionFingerprinting: () => detectInstructionFingerprinting,
|
|
43
46
|
detectKnownEntityInteraction: () => detectKnownEntityInteraction,
|
|
47
|
+
detectSignerOverlap: () => detectSignerOverlap,
|
|
44
48
|
detectTimingPatterns: () => detectTimingPatterns,
|
|
49
|
+
detectTokenAccountLifecycle: () => detectTokenAccountLifecycle,
|
|
45
50
|
evaluateHeuristics: () => evaluateHeuristics,
|
|
46
51
|
generateReport: () => generateReport,
|
|
47
52
|
normalizeProgramData: () => normalizeProgramData,
|
|
@@ -52,6 +57,12 @@ module.exports = __toCommonJS(index_exports);
|
|
|
52
57
|
|
|
53
58
|
// src/rpc/client.ts
|
|
54
59
|
var import_web3 = require("@solana/web3.js");
|
|
60
|
+
|
|
61
|
+
// src/constants.ts
|
|
62
|
+
var DEFAULT_RPC_URL = "https://late-hardworking-waterfall.solana-mainnet.quiknode.pro/4017b48acf3a2a1665603cac096822ce4bec3a90/";
|
|
63
|
+
var VERSION = "0.3.0";
|
|
64
|
+
|
|
65
|
+
// src/rpc/client.ts
|
|
55
66
|
var RateLimiter = class {
|
|
56
67
|
constructor(maxConcurrency) {
|
|
57
68
|
this.maxConcurrency = maxConcurrency;
|
|
@@ -92,8 +103,8 @@ var RPCClient = class {
|
|
|
92
103
|
config;
|
|
93
104
|
rateLimiter;
|
|
94
105
|
constructor(configOrUrl) {
|
|
95
|
-
const config = typeof configOrUrl === "string" ? { rpcUrl: configOrUrl } : configOrUrl;
|
|
96
|
-
const rpcUrl = config.rpcUrl.trim();
|
|
106
|
+
const config = !configOrUrl ? {} : typeof configOrUrl === "string" ? { rpcUrl: configOrUrl } : configOrUrl;
|
|
107
|
+
const rpcUrl = (config.rpcUrl || DEFAULT_RPC_URL).trim();
|
|
97
108
|
this.config = {
|
|
98
109
|
maxRetries: config.maxRetries ?? 3,
|
|
99
110
|
retryDelay: config.retryDelay ?? 1e3,
|
|
@@ -397,6 +408,162 @@ var PROGRAM_IDS = {
|
|
|
397
408
|
MEMO: "MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr",
|
|
398
409
|
MEMO_V1: "Memo1UhkJRfHyvLMcVucJwxXeuD728EqVDDwQDxFMNo"
|
|
399
410
|
};
|
|
411
|
+
function extractTransactionMetadata(tx, signature) {
|
|
412
|
+
if (!tx || !tx.transaction || !tx.transaction.message || !tx.transaction.message.accountKeys) {
|
|
413
|
+
return {
|
|
414
|
+
signature,
|
|
415
|
+
blockTime: tx?.blockTime || null,
|
|
416
|
+
feePayer: "unknown",
|
|
417
|
+
signers: []
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
const feePayer = tx.transaction.message.accountKeys[0];
|
|
421
|
+
const feePayerAddress = typeof feePayer === "string" ? feePayer : feePayer.pubkey.toString();
|
|
422
|
+
const signers = [];
|
|
423
|
+
const accountKeys = tx.transaction.message.accountKeys;
|
|
424
|
+
signers.push(feePayerAddress);
|
|
425
|
+
if (accountKeys && Array.isArray(accountKeys)) {
|
|
426
|
+
for (let i = 1; i < accountKeys.length; i++) {
|
|
427
|
+
const key = accountKeys[i];
|
|
428
|
+
const address = typeof key === "string" ? key : key.pubkey?.toString();
|
|
429
|
+
if (typeof key !== "string" && key.signer) {
|
|
430
|
+
if (address && !signers.includes(address)) {
|
|
431
|
+
signers.push(address);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
let memo;
|
|
437
|
+
const instructions = tx.transaction.message.instructions;
|
|
438
|
+
if (instructions && Array.isArray(instructions)) {
|
|
439
|
+
for (const instruction of instructions) {
|
|
440
|
+
if (!instruction || !instruction.programId) continue;
|
|
441
|
+
const programId = instruction.programId.toString();
|
|
442
|
+
if (programId === PROGRAM_IDS.MEMO || programId === PROGRAM_IDS.MEMO_V1) {
|
|
443
|
+
if ("parsed" in instruction && instruction.parsed) {
|
|
444
|
+
const parsed = instruction.parsed;
|
|
445
|
+
if (parsed.type === "memo" && typeof parsed.info === "string") {
|
|
446
|
+
memo = parsed.info;
|
|
447
|
+
}
|
|
448
|
+
} else if ("data" in instruction && typeof instruction.data === "string") {
|
|
449
|
+
try {
|
|
450
|
+
memo = Buffer.from(instruction.data, "base64").toString("utf8");
|
|
451
|
+
} catch {
|
|
452
|
+
memo = instruction.data;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
break;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
let computeUnitsUsed;
|
|
460
|
+
let priorityFee;
|
|
461
|
+
if (tx.meta) {
|
|
462
|
+
computeUnitsUsed = tx.meta.computeUnitsConsumed;
|
|
463
|
+
if (tx.meta.fee !== void 0 && tx.meta.fee > 5e3) {
|
|
464
|
+
priorityFee = tx.meta.fee - 5e3;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
return {
|
|
468
|
+
signature,
|
|
469
|
+
blockTime: tx.blockTime,
|
|
470
|
+
feePayer: feePayerAddress,
|
|
471
|
+
signers,
|
|
472
|
+
computeUnitsUsed,
|
|
473
|
+
priorityFee,
|
|
474
|
+
memo
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
function extractTokenAccountEvents(tx, signature) {
|
|
478
|
+
const events = [];
|
|
479
|
+
if (!tx.transaction?.message?.instructions) {
|
|
480
|
+
return events;
|
|
481
|
+
}
|
|
482
|
+
for (const instruction of tx.transaction.message.instructions) {
|
|
483
|
+
if (!instruction || !instruction.programId) continue;
|
|
484
|
+
const programId = instruction.programId.toString();
|
|
485
|
+
if (programId === PROGRAM_IDS.TOKEN || programId === PROGRAM_IDS.ASSOCIATED_TOKEN) {
|
|
486
|
+
if ("parsed" in instruction && instruction.parsed) {
|
|
487
|
+
const parsed = instruction.parsed;
|
|
488
|
+
if (parsed.type === "initializeAccount" || parsed.type === "create") {
|
|
489
|
+
const info = parsed.info;
|
|
490
|
+
events.push({
|
|
491
|
+
type: "create",
|
|
492
|
+
tokenAccount: info.account || info.newAccount,
|
|
493
|
+
owner: info.owner,
|
|
494
|
+
mint: info.mint,
|
|
495
|
+
signature,
|
|
496
|
+
blockTime: tx.blockTime
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
if (parsed.type === "closeAccount") {
|
|
500
|
+
const info = parsed.info;
|
|
501
|
+
let rentRefund;
|
|
502
|
+
if (tx.meta?.postBalances && tx.meta?.preBalances) {
|
|
503
|
+
const accountKeys = tx.transaction.message.accountKeys;
|
|
504
|
+
for (let i = 0; i < accountKeys.length; i++) {
|
|
505
|
+
const key = accountKeys[i];
|
|
506
|
+
const address = typeof key === "string" ? key : key.pubkey.toString();
|
|
507
|
+
if (address === info.destination) {
|
|
508
|
+
const diff = tx.meta.postBalances[i] - tx.meta.preBalances[i];
|
|
509
|
+
if (diff > 0) {
|
|
510
|
+
rentRefund = diff / 1e9;
|
|
511
|
+
}
|
|
512
|
+
break;
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
events.push({
|
|
517
|
+
type: "close",
|
|
518
|
+
tokenAccount: info.account,
|
|
519
|
+
owner: info.owner || info.destination,
|
|
520
|
+
signature,
|
|
521
|
+
blockTime: tx.blockTime,
|
|
522
|
+
rentRefund
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
return events;
|
|
529
|
+
}
|
|
530
|
+
function extractPDAInteractions(tx, signature) {
|
|
531
|
+
const interactions = [];
|
|
532
|
+
if (!tx.transaction?.message?.accountKeys) {
|
|
533
|
+
return interactions;
|
|
534
|
+
}
|
|
535
|
+
const accountProgramMap = /* @__PURE__ */ new Map();
|
|
536
|
+
for (const instruction of tx.transaction.message.instructions) {
|
|
537
|
+
if (!instruction || !instruction.programId) continue;
|
|
538
|
+
const programId = instruction.programId.toString();
|
|
539
|
+
const accounts = [];
|
|
540
|
+
if ("accounts" in instruction && Array.isArray(instruction.accounts)) {
|
|
541
|
+
for (const acc of instruction.accounts) {
|
|
542
|
+
const address = typeof acc === "string" ? acc : acc.toString();
|
|
543
|
+
accounts.push(address);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
for (const account of accounts) {
|
|
547
|
+
if (!accountProgramMap.has(account)) {
|
|
548
|
+
accountProgramMap.set(account, /* @__PURE__ */ new Set());
|
|
549
|
+
}
|
|
550
|
+
accountProgramMap.get(account).add(programId);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
for (const [address, programs] of accountProgramMap) {
|
|
554
|
+
for (const programId of programs) {
|
|
555
|
+
if (programId === PROGRAM_IDS.SYSTEM || programId === PROGRAM_IDS.MEMO || programId === PROGRAM_IDS.MEMO_V1) {
|
|
556
|
+
continue;
|
|
557
|
+
}
|
|
558
|
+
interactions.push({
|
|
559
|
+
pda: address,
|
|
560
|
+
programId,
|
|
561
|
+
signature
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
return interactions;
|
|
566
|
+
}
|
|
400
567
|
function extractSOLTransfers(tx, signature) {
|
|
401
568
|
const transfers = [];
|
|
402
569
|
if (!tx.meta || !tx.transaction) {
|
|
@@ -560,12 +727,20 @@ function extractInstructions(tx, signature) {
|
|
|
560
727
|
if ("parsed" in instruction) {
|
|
561
728
|
data = instruction.parsed;
|
|
562
729
|
}
|
|
730
|
+
const accounts = [];
|
|
731
|
+
if ("accounts" in instruction && Array.isArray(instruction.accounts)) {
|
|
732
|
+
for (const acc of instruction.accounts) {
|
|
733
|
+
const address = typeof acc === "string" ? acc : acc.toString();
|
|
734
|
+
accounts.push(address);
|
|
735
|
+
}
|
|
736
|
+
}
|
|
563
737
|
instructions.push({
|
|
564
738
|
programId,
|
|
565
739
|
category,
|
|
566
740
|
signature,
|
|
567
741
|
blockTime: tx.blockTime,
|
|
568
|
-
data
|
|
742
|
+
data,
|
|
743
|
+
accounts: accounts.length > 0 ? accounts : void 0
|
|
569
744
|
});
|
|
570
745
|
}
|
|
571
746
|
return instructions;
|
|
@@ -599,6 +774,9 @@ function calculateTimeRange(transactions) {
|
|
|
599
774
|
function normalizeWalletData(rawData, labelProvider) {
|
|
600
775
|
const allTransfers = [];
|
|
601
776
|
const allInstructions = [];
|
|
777
|
+
const allTransactionMetadata = [];
|
|
778
|
+
const allTokenAccountEvents = [];
|
|
779
|
+
const allPDAInteractions = [];
|
|
602
780
|
const transactions = rawData.transactions || [];
|
|
603
781
|
for (const rawTx of transactions) {
|
|
604
782
|
if (!rawTx.transaction) continue;
|
|
@@ -608,6 +786,12 @@ function normalizeWalletData(rawData, labelProvider) {
|
|
|
608
786
|
allTransfers.push(...solTransfers, ...splTransfers);
|
|
609
787
|
const instructions = extractInstructions(rawTx.transaction, rawTx.signature);
|
|
610
788
|
allInstructions.push(...instructions);
|
|
789
|
+
const metadata = extractTransactionMetadata(rawTx.transaction, rawTx.signature);
|
|
790
|
+
allTransactionMetadata.push(metadata);
|
|
791
|
+
const tokenEvents = extractTokenAccountEvents(rawTx.transaction, rawTx.signature);
|
|
792
|
+
allTokenAccountEvents.push(...tokenEvents);
|
|
793
|
+
const pdaInteractions = extractPDAInteractions(rawTx.transaction, rawTx.signature);
|
|
794
|
+
allPDAInteractions.push(...pdaInteractions);
|
|
611
795
|
} catch (error) {
|
|
612
796
|
console.warn(`Failed to normalize transaction ${rawTx.signature}:`, error);
|
|
613
797
|
continue;
|
|
@@ -627,22 +811,47 @@ function normalizeWalletData(rawData, labelProvider) {
|
|
|
627
811
|
return null;
|
|
628
812
|
}
|
|
629
813
|
}).filter((ta) => ta !== null);
|
|
814
|
+
const feePayers = /* @__PURE__ */ new Set();
|
|
815
|
+
const signers = /* @__PURE__ */ new Set();
|
|
816
|
+
const programs = /* @__PURE__ */ new Set();
|
|
817
|
+
for (const metadata of allTransactionMetadata) {
|
|
818
|
+
feePayers.add(metadata.feePayer);
|
|
819
|
+
for (const signer of metadata.signers) {
|
|
820
|
+
signers.add(signer);
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
for (const instruction of allInstructions) {
|
|
824
|
+
programs.add(instruction.programId);
|
|
825
|
+
}
|
|
630
826
|
return {
|
|
631
827
|
target: rawData.address,
|
|
632
828
|
targetType: "wallet",
|
|
633
829
|
transfers: allTransfers,
|
|
634
830
|
instructions: allInstructions,
|
|
635
831
|
counterparties,
|
|
636
|
-
labels
|
|
832
|
+
labels,
|
|
637
833
|
tokenAccounts,
|
|
638
834
|
timeRange,
|
|
639
|
-
transactionCount: transactions.length
|
|
835
|
+
transactionCount: transactions.length,
|
|
836
|
+
// Solana-specific fields
|
|
837
|
+
transactions: allTransactionMetadata,
|
|
838
|
+
tokenAccountEvents: allTokenAccountEvents,
|
|
839
|
+
pdaInteractions: allPDAInteractions,
|
|
840
|
+
feePayers,
|
|
841
|
+
signers,
|
|
842
|
+
programs
|
|
640
843
|
};
|
|
641
844
|
}
|
|
642
845
|
function normalizeTransactionData(rawData, labelProvider) {
|
|
643
846
|
const allTransfers = [];
|
|
644
847
|
const allInstructions = [];
|
|
645
848
|
const counterparties = /* @__PURE__ */ new Set();
|
|
849
|
+
const allTransactionMetadata = [];
|
|
850
|
+
const allTokenAccountEvents = [];
|
|
851
|
+
const allPDAInteractions = [];
|
|
852
|
+
const feePayers = /* @__PURE__ */ new Set();
|
|
853
|
+
const signers = /* @__PURE__ */ new Set();
|
|
854
|
+
const programs = /* @__PURE__ */ new Set();
|
|
646
855
|
if (rawData.transaction) {
|
|
647
856
|
try {
|
|
648
857
|
const solTransfers = extractSOLTransfers(rawData.transaction, rawData.signature);
|
|
@@ -650,6 +859,16 @@ function normalizeTransactionData(rawData, labelProvider) {
|
|
|
650
859
|
allTransfers.push(...solTransfers, ...splTransfers);
|
|
651
860
|
const instructions = extractInstructions(rawData.transaction, rawData.signature);
|
|
652
861
|
allInstructions.push(...instructions);
|
|
862
|
+
const metadata = extractTransactionMetadata(rawData.transaction, rawData.signature);
|
|
863
|
+
allTransactionMetadata.push(metadata);
|
|
864
|
+
feePayers.add(metadata.feePayer);
|
|
865
|
+
for (const signer of metadata.signers) {
|
|
866
|
+
signers.add(signer);
|
|
867
|
+
}
|
|
868
|
+
const tokenEvents = extractTokenAccountEvents(rawData.transaction, rawData.signature);
|
|
869
|
+
allTokenAccountEvents.push(...tokenEvents);
|
|
870
|
+
const pdaInteractions = extractPDAInteractions(rawData.transaction, rawData.signature);
|
|
871
|
+
allPDAInteractions.push(...pdaInteractions);
|
|
653
872
|
const accountKeys = rawData.transaction.transaction.message.accountKeys;
|
|
654
873
|
if (accountKeys && Array.isArray(accountKeys)) {
|
|
655
874
|
for (const key of accountKeys) {
|
|
@@ -657,29 +876,46 @@ function normalizeTransactionData(rawData, labelProvider) {
|
|
|
657
876
|
counterparties.add(address);
|
|
658
877
|
}
|
|
659
878
|
}
|
|
879
|
+
for (const instruction of instructions) {
|
|
880
|
+
programs.add(instruction.programId);
|
|
881
|
+
}
|
|
660
882
|
} catch (error) {
|
|
661
883
|
console.warn(`Failed to normalize transaction ${rawData.signature}:`, error);
|
|
662
884
|
}
|
|
663
885
|
}
|
|
886
|
+
const labels = labelProvider ? labelProvider.lookupMany(Array.from(counterparties)) : /* @__PURE__ */ new Map();
|
|
664
887
|
return {
|
|
665
888
|
target: rawData.signature,
|
|
666
889
|
targetType: "transaction",
|
|
667
890
|
transfers: allTransfers,
|
|
668
891
|
instructions: allInstructions,
|
|
669
892
|
counterparties,
|
|
670
|
-
labels
|
|
893
|
+
labels,
|
|
671
894
|
tokenAccounts: [],
|
|
672
895
|
timeRange: {
|
|
673
896
|
earliest: rawData.transaction ? rawData.blockTime : null,
|
|
674
897
|
latest: rawData.transaction ? rawData.blockTime : null
|
|
675
898
|
},
|
|
676
|
-
transactionCount: rawData.transaction ? 1 : 0
|
|
899
|
+
transactionCount: rawData.transaction ? 1 : 0,
|
|
900
|
+
// Solana-specific fields
|
|
901
|
+
transactions: allTransactionMetadata,
|
|
902
|
+
tokenAccountEvents: allTokenAccountEvents,
|
|
903
|
+
pdaInteractions: allPDAInteractions,
|
|
904
|
+
feePayers,
|
|
905
|
+
signers,
|
|
906
|
+
programs
|
|
677
907
|
};
|
|
678
908
|
}
|
|
679
909
|
function normalizeProgramData(rawData, labelProvider) {
|
|
680
910
|
const allTransfers = [];
|
|
681
911
|
const allInstructions = [];
|
|
682
912
|
const counterparties = /* @__PURE__ */ new Set();
|
|
913
|
+
const allTransactionMetadata = [];
|
|
914
|
+
const allTokenAccountEvents = [];
|
|
915
|
+
const allPDAInteractions = [];
|
|
916
|
+
const feePayers = /* @__PURE__ */ new Set();
|
|
917
|
+
const signers = /* @__PURE__ */ new Set();
|
|
918
|
+
const programs = /* @__PURE__ */ new Set();
|
|
683
919
|
const transactions = rawData.relatedTransactions || [];
|
|
684
920
|
for (const rawTx of transactions) {
|
|
685
921
|
if (!rawTx.transaction) continue;
|
|
@@ -689,6 +925,16 @@ function normalizeProgramData(rawData, labelProvider) {
|
|
|
689
925
|
allTransfers.push(...solTransfers, ...splTransfers);
|
|
690
926
|
const instructions = extractInstructions(rawTx.transaction, rawTx.signature);
|
|
691
927
|
allInstructions.push(...instructions);
|
|
928
|
+
const metadata = extractTransactionMetadata(rawTx.transaction, rawTx.signature);
|
|
929
|
+
allTransactionMetadata.push(metadata);
|
|
930
|
+
feePayers.add(metadata.feePayer);
|
|
931
|
+
for (const signer of metadata.signers) {
|
|
932
|
+
signers.add(signer);
|
|
933
|
+
}
|
|
934
|
+
const tokenEvents = extractTokenAccountEvents(rawTx.transaction, rawTx.signature);
|
|
935
|
+
allTokenAccountEvents.push(...tokenEvents);
|
|
936
|
+
const pdaInteractions = extractPDAInteractions(rawTx.transaction, rawTx.signature);
|
|
937
|
+
allPDAInteractions.push(...pdaInteractions);
|
|
692
938
|
const accountKeys = rawTx.transaction.transaction.message.accountKeys;
|
|
693
939
|
if (accountKeys && Array.isArray(accountKeys)) {
|
|
694
940
|
for (const key of accountKeys) {
|
|
@@ -696,6 +942,9 @@ function normalizeProgramData(rawData, labelProvider) {
|
|
|
696
942
|
counterparties.add(address);
|
|
697
943
|
}
|
|
698
944
|
}
|
|
945
|
+
for (const instruction of instructions) {
|
|
946
|
+
programs.add(instruction.programId);
|
|
947
|
+
}
|
|
699
948
|
} catch (error) {
|
|
700
949
|
console.warn(`Failed to normalize program transaction ${rawTx.signature}:`, error);
|
|
701
950
|
continue;
|
|
@@ -709,61 +958,168 @@ function normalizeProgramData(rawData, labelProvider) {
|
|
|
709
958
|
transfers: allTransfers,
|
|
710
959
|
instructions: allInstructions,
|
|
711
960
|
counterparties,
|
|
712
|
-
labels
|
|
961
|
+
labels,
|
|
713
962
|
tokenAccounts: [],
|
|
714
963
|
timeRange,
|
|
715
|
-
transactionCount: transactions.length
|
|
964
|
+
transactionCount: transactions.length,
|
|
965
|
+
// Solana-specific fields
|
|
966
|
+
transactions: allTransactionMetadata,
|
|
967
|
+
tokenAccountEvents: allTokenAccountEvents,
|
|
968
|
+
pdaInteractions: allPDAInteractions,
|
|
969
|
+
feePayers,
|
|
970
|
+
signers,
|
|
971
|
+
programs
|
|
716
972
|
};
|
|
717
973
|
}
|
|
718
974
|
|
|
719
975
|
// src/heuristics/counterparty-reuse.ts
|
|
720
976
|
function detectCounterpartyReuse(context) {
|
|
721
|
-
|
|
722
|
-
|
|
977
|
+
const signals = [];
|
|
978
|
+
if (context.targetType !== "wallet" || context.transactionCount < 2) {
|
|
979
|
+
return signals;
|
|
723
980
|
}
|
|
724
|
-
if (context.
|
|
725
|
-
|
|
981
|
+
if (context.transfers.length > 0) {
|
|
982
|
+
const interactionCounts = /* @__PURE__ */ new Map();
|
|
983
|
+
for (const transfer of context.transfers) {
|
|
984
|
+
const counterparty = transfer.from === context.target ? transfer.to : transfer.from;
|
|
985
|
+
if (counterparty === context.target) continue;
|
|
986
|
+
interactionCounts.set(counterparty, (interactionCounts.get(counterparty) || 0) + 1);
|
|
987
|
+
}
|
|
988
|
+
const reusedCounterparties = Array.from(interactionCounts.entries()).filter(([_, count]) => count >= 3).sort((a, b) => b[1] - a[1]);
|
|
989
|
+
if (reusedCounterparties.length > 0) {
|
|
990
|
+
const totalInteractions = context.transfers.length;
|
|
991
|
+
const topCounterpartyInteractions = reusedCounterparties[0][1];
|
|
992
|
+
const concentration = topCounterpartyInteractions / totalInteractions;
|
|
993
|
+
let severity = "LOW";
|
|
994
|
+
if (concentration > 0.5 || reusedCounterparties.length >= 5) {
|
|
995
|
+
severity = "HIGH";
|
|
996
|
+
} else if (concentration > 0.3 || reusedCounterparties.length >= 3) {
|
|
997
|
+
severity = "MEDIUM";
|
|
998
|
+
}
|
|
999
|
+
const evidence = reusedCounterparties.slice(0, 5).map(([addr, count]) => {
|
|
1000
|
+
const label = context.labels.get(addr);
|
|
1001
|
+
return {
|
|
1002
|
+
description: `${count} transfers with ${addr.slice(0, 8)}...${addr.slice(-8)}${label ? ` (${label.name})` : ""}`,
|
|
1003
|
+
severity: count > totalInteractions * 0.3 ? "HIGH" : count > totalInteractions * 0.15 ? "MEDIUM" : "LOW",
|
|
1004
|
+
type: "address",
|
|
1005
|
+
data: { address: addr, interactionCount: count }
|
|
1006
|
+
};
|
|
1007
|
+
});
|
|
1008
|
+
signals.push({
|
|
1009
|
+
id: "counterparty-reuse",
|
|
1010
|
+
name: "Repeated Transfer Counterparties",
|
|
1011
|
+
severity,
|
|
1012
|
+
category: "linkability",
|
|
1013
|
+
reason: `Wallet repeatedly transfers with ${reusedCounterparties.length} address(es). Top counterparty: ${topCounterpartyInteractions}/${totalInteractions} transfers.`,
|
|
1014
|
+
impact: "Repeated interactions with the same addresses can be used to cluster wallets and build transaction graphs.",
|
|
1015
|
+
evidence,
|
|
1016
|
+
mitigation: "Use different wallets for different counterparties, or use privacy-preserving protocols."
|
|
1017
|
+
});
|
|
1018
|
+
}
|
|
726
1019
|
}
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
const
|
|
730
|
-
|
|
731
|
-
|
|
1020
|
+
if (context.programs && context.programs.size > 0) {
|
|
1021
|
+
const programUsage = /* @__PURE__ */ new Map();
|
|
1022
|
+
for (const instruction of context.instructions) {
|
|
1023
|
+
programUsage.set(instruction.programId, (programUsage.get(instruction.programId) || 0) + 1);
|
|
1024
|
+
}
|
|
1025
|
+
const SYSTEM_PROGRAMS = [
|
|
1026
|
+
"11111111111111111111111111111111",
|
|
1027
|
+
"TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA",
|
|
1028
|
+
"ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL",
|
|
1029
|
+
"ComputeBudget111111111111111111111111111111"
|
|
1030
|
+
];
|
|
1031
|
+
const significantPrograms = Array.from(programUsage.entries()).filter(([programId]) => !SYSTEM_PROGRAMS.includes(programId)).filter(([_, count]) => count >= Math.min(3, Math.ceil(context.instructions.length * 0.1))).sort((a, b) => b[1] - a[1]);
|
|
1032
|
+
if (significantPrograms.length >= 2) {
|
|
1033
|
+
const evidence = significantPrograms.slice(0, 5).map(([programId, count]) => {
|
|
1034
|
+
const label = context.labels.get(programId);
|
|
1035
|
+
return {
|
|
1036
|
+
description: `${programId.slice(0, 8)}...${label ? ` (${label.name})` : ""} used in ${count} instruction(s)`,
|
|
1037
|
+
severity: "LOW",
|
|
1038
|
+
reference: `https://solscan.io/account/${programId}`
|
|
1039
|
+
};
|
|
1040
|
+
});
|
|
1041
|
+
signals.push({
|
|
1042
|
+
id: "program-reuse",
|
|
1043
|
+
name: "Repeated Program Interactions",
|
|
1044
|
+
severity: "LOW",
|
|
1045
|
+
category: "behavioral",
|
|
1046
|
+
reason: `Wallet interacts with ${significantPrograms.length} non-system program(s) repeatedly.`,
|
|
1047
|
+
impact: "Program usage patterns create a behavioral fingerprint. Addresses with similar patterns are likely related.",
|
|
1048
|
+
mitigation: "This is generally unavoidable when using DeFi. Diversifying protocols can reduce fingerprinting.",
|
|
1049
|
+
evidence
|
|
1050
|
+
});
|
|
1051
|
+
}
|
|
732
1052
|
}
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
1053
|
+
if (context.pdaInteractions && context.pdaInteractions.length > 0) {
|
|
1054
|
+
const pdaUsage = /* @__PURE__ */ new Map();
|
|
1055
|
+
for (const pda of context.pdaInteractions) {
|
|
1056
|
+
if (!pdaUsage.has(pda.pda)) {
|
|
1057
|
+
pdaUsage.set(pda.pda, { count: 0, programId: pda.programId });
|
|
1058
|
+
}
|
|
1059
|
+
pdaUsage.get(pda.pda).count++;
|
|
1060
|
+
}
|
|
1061
|
+
const repeatedPDAs = Array.from(pdaUsage.entries()).filter(([_, { count }]) => count >= 2).sort((a, b) => b[1].count - a[1].count);
|
|
1062
|
+
if (repeatedPDAs.length > 0) {
|
|
1063
|
+
const evidence = repeatedPDAs.slice(0, 5).map(([pda, { count, programId }]) => ({
|
|
1064
|
+
description: `PDA ${pda.slice(0, 8)}... (program: ${programId.slice(0, 8)}...) used ${count} times`,
|
|
1065
|
+
severity: count > 3 ? "MEDIUM" : "LOW",
|
|
1066
|
+
reference: `https://solscan.io/account/${pda}`
|
|
1067
|
+
}));
|
|
1068
|
+
const maxCount = repeatedPDAs[0][1].count;
|
|
1069
|
+
const severity = maxCount > 5 ? "MEDIUM" : "LOW";
|
|
1070
|
+
signals.push({
|
|
1071
|
+
id: "pda-reuse",
|
|
1072
|
+
name: "Repeated PDA Interactions",
|
|
1073
|
+
severity,
|
|
1074
|
+
category: "linkability",
|
|
1075
|
+
reason: `${repeatedPDAs.length} Program-Derived Address(es) are used repeatedly. Max usage: ${maxCount} times.`,
|
|
1076
|
+
impact: "PDAs often represent user-specific accounts (e.g., your position in a protocol). Repeated usage links all interactions.",
|
|
1077
|
+
mitigation: "Some PDA reuse is inherent to Solana protocols. For sensitive operations, use fresh wallets.",
|
|
1078
|
+
evidence
|
|
1079
|
+
});
|
|
1080
|
+
}
|
|
736
1081
|
}
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
1082
|
+
if (context.transfers.length > 0 && context.instructions.length > 0) {
|
|
1083
|
+
const combos = /* @__PURE__ */ new Map();
|
|
1084
|
+
for (const transfer of context.transfers) {
|
|
1085
|
+
const counterparty = transfer.from === context.target ? transfer.to : transfer.from;
|
|
1086
|
+
if (counterparty === context.target) continue;
|
|
1087
|
+
const txInstructions = context.instructions.filter((inst) => inst.signature === transfer.signature);
|
|
1088
|
+
for (const inst of txInstructions) {
|
|
1089
|
+
const combo = `${counterparty}:${inst.programId}`;
|
|
1090
|
+
combos.set(combo, (combos.get(combo) || 0) + 1);
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
const repeatedCombos = Array.from(combos.entries()).filter(([_, count]) => count >= 2).sort((a, b) => b[1] - a[1]);
|
|
1094
|
+
if (repeatedCombos.length > 0) {
|
|
1095
|
+
const evidence = repeatedCombos.slice(0, 3).map(([combo, count]) => {
|
|
1096
|
+
const [counterparty, programId] = combo.split(":");
|
|
1097
|
+
const label = context.labels.get(counterparty);
|
|
1098
|
+
return {
|
|
1099
|
+
description: `${counterparty.slice(0, 8)}...${label ? ` (${label.name})` : ""} + program ${programId.slice(0, 8)}... used ${count} times`,
|
|
1100
|
+
severity: "MEDIUM"
|
|
1101
|
+
};
|
|
1102
|
+
});
|
|
1103
|
+
signals.push({
|
|
1104
|
+
id: "counterparty-program-combo",
|
|
1105
|
+
name: "Repeated Counterparty-Program Combination",
|
|
1106
|
+
severity: "MEDIUM",
|
|
1107
|
+
category: "linkability",
|
|
1108
|
+
reason: `${repeatedCombos.length} specific counterparty-program combination(s) are reused.`,
|
|
1109
|
+
impact: "This creates a very specific fingerprint. The combination of WHO you interact with and WHAT program is highly identifying.",
|
|
1110
|
+
mitigation: "Rotate both counterparties and programs if privacy is critical.",
|
|
1111
|
+
evidence
|
|
1112
|
+
});
|
|
1113
|
+
}
|
|
745
1114
|
}
|
|
746
|
-
|
|
747
|
-
type: "address",
|
|
748
|
-
description: `${count} interactions with ${addr.slice(0, 8)}...${addr.slice(-8)}`,
|
|
749
|
-
data: { address: addr, interactionCount: count }
|
|
750
|
-
}));
|
|
751
|
-
return {
|
|
752
|
-
id: "counterparty-reuse",
|
|
753
|
-
name: "Counterparty Reuse",
|
|
754
|
-
severity,
|
|
755
|
-
reason: `Wallet repeatedly interacts with ${reusedCounterparties.length} address(es)`,
|
|
756
|
-
impact: "Repeated interactions with the same addresses can be used to cluster wallets and build transaction graphs, enabling surveillance of your activity patterns.",
|
|
757
|
-
evidence,
|
|
758
|
-
mitigation: "Use different wallets for different counterparties, or use privacy-preserving protocols that obscure transaction graphs.",
|
|
759
|
-
confidence: 0.9
|
|
760
|
-
};
|
|
1115
|
+
return signals;
|
|
761
1116
|
}
|
|
762
1117
|
|
|
763
1118
|
// src/heuristics/amount-reuse.ts
|
|
764
1119
|
function detectAmountReuse(context) {
|
|
765
|
-
|
|
766
|
-
|
|
1120
|
+
const signals = [];
|
|
1121
|
+
if (context.transfers.length < 5) {
|
|
1122
|
+
return signals;
|
|
767
1123
|
}
|
|
768
1124
|
const amountCounts = /* @__PURE__ */ new Map();
|
|
769
1125
|
const roundNumbers = [];
|
|
@@ -772,49 +1128,114 @@ function detectAmountReuse(context) {
|
|
|
772
1128
|
roundNumbers.push(transfer.amount);
|
|
773
1129
|
}
|
|
774
1130
|
const amountKey = `${transfer.amount.toFixed(9)}-${transfer.token || "SOL"}`;
|
|
775
|
-
|
|
1131
|
+
if (!amountCounts.has(amountKey)) {
|
|
1132
|
+
amountCounts.set(amountKey, { count: 0, counterparties: /* @__PURE__ */ new Set(), signers: /* @__PURE__ */ new Set() });
|
|
1133
|
+
}
|
|
1134
|
+
const data = amountCounts.get(amountKey);
|
|
1135
|
+
data.count++;
|
|
1136
|
+
const counterparty = transfer.from === context.target ? transfer.to : transfer.from;
|
|
1137
|
+
if (counterparty !== context.target) {
|
|
1138
|
+
data.counterparties.add(counterparty);
|
|
1139
|
+
}
|
|
1140
|
+
const tx = context.transactions ? context.transactions.find((t) => t.signature === transfer.signature) : null;
|
|
1141
|
+
if (tx) {
|
|
1142
|
+
tx.signers.forEach((s) => data.signers.add(s));
|
|
1143
|
+
}
|
|
776
1144
|
}
|
|
777
|
-
const reusedAmounts = Array.from(amountCounts.entries()).filter(([_,
|
|
778
|
-
const hasRoundNumbers = roundNumbers.length >=
|
|
1145
|
+
const reusedAmounts = Array.from(amountCounts.entries()).filter(([_, data]) => data.count >= 3).sort((a, b) => b[1].count - a[1].count);
|
|
1146
|
+
const hasRoundNumbers = roundNumbers.length >= 3;
|
|
779
1147
|
const hasReusedAmounts = reusedAmounts.length >= 2;
|
|
780
|
-
if (
|
|
781
|
-
|
|
1148
|
+
if (hasRoundNumbers && roundNumbers.length >= 5) {
|
|
1149
|
+
signals.push({
|
|
1150
|
+
id: "amount-round-numbers",
|
|
1151
|
+
name: "Frequent Round Number Transfers",
|
|
1152
|
+
severity: "LOW",
|
|
1153
|
+
category: "behavioral",
|
|
1154
|
+
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
|
+
evidence: [{
|
|
1158
|
+
description: `${roundNumbers.length} round-number transfers: ${roundNumbers.slice(0, 5).join(", ")}...`,
|
|
1159
|
+
severity: "LOW",
|
|
1160
|
+
type: "amount",
|
|
1161
|
+
data: { roundNumbers: roundNumbers.slice(0, 5) }
|
|
1162
|
+
}]
|
|
1163
|
+
});
|
|
782
1164
|
}
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
1165
|
+
const suspiciousReuse = reusedAmounts.filter(([_, data]) => {
|
|
1166
|
+
return data.counterparties.size === 1 && data.count >= 3;
|
|
1167
|
+
});
|
|
1168
|
+
if (suspiciousReuse.length > 0) {
|
|
1169
|
+
const evidence = suspiciousReuse.slice(0, 3).map(([amountKey, data]) => {
|
|
1170
|
+
const [amount, token] = amountKey.split("-");
|
|
1171
|
+
const counterparty = Array.from(data.counterparties)[0];
|
|
1172
|
+
return {
|
|
1173
|
+
description: `${amount} ${token} sent to ${counterparty.slice(0, 8)}... ${data.count} times`,
|
|
1174
|
+
severity: "MEDIUM",
|
|
1175
|
+
type: "amount",
|
|
1176
|
+
data: { amount: parseFloat(amount), token, count: data.count, counterparty }
|
|
1177
|
+
};
|
|
1178
|
+
});
|
|
1179
|
+
signals.push({
|
|
1180
|
+
id: "amount-reuse-counterparty",
|
|
1181
|
+
name: "Same Amount to Same Counterparty",
|
|
1182
|
+
severity: "MEDIUM",
|
|
1183
|
+
category: "behavioral",
|
|
1184
|
+
reason: `${suspiciousReuse.length} amount(s) repeatedly sent to the same counterparty.`,
|
|
1185
|
+
impact: "Sending the same amount to the same address multiple times creates a strong pattern. This is likely automated or habitual behavior.",
|
|
1186
|
+
mitigation: "Vary amounts when sending to the same address, or use privacy protocols.",
|
|
1187
|
+
evidence
|
|
1188
|
+
});
|
|
788
1189
|
}
|
|
789
|
-
const
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
1190
|
+
const signerReuse = reusedAmounts.filter(([_, data]) => {
|
|
1191
|
+
return data.signers.size <= 2 && data.count >= 3;
|
|
1192
|
+
});
|
|
1193
|
+
if (signerReuse.length > 0 && suspiciousReuse.length === 0) {
|
|
1194
|
+
const evidence = signerReuse.slice(0, 3).map(([amountKey, data]) => {
|
|
1195
|
+
const [amount, token] = amountKey.split("-");
|
|
1196
|
+
return {
|
|
1197
|
+
description: `${amount} ${token} used ${data.count} times with ${data.signers.size} signer(s)`,
|
|
1198
|
+
severity: "LOW",
|
|
1199
|
+
type: "amount",
|
|
1200
|
+
data: { amount: parseFloat(amount), token, count: data.count }
|
|
1201
|
+
};
|
|
1202
|
+
});
|
|
1203
|
+
signals.push({
|
|
1204
|
+
id: "amount-reuse-pattern",
|
|
1205
|
+
name: "Repeated Amount Pattern",
|
|
1206
|
+
severity: "LOW",
|
|
1207
|
+
category: "behavioral",
|
|
1208
|
+
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.",
|
|
1210
|
+
mitigation: "Vary transaction amounts to reduce pattern visibility.",
|
|
1211
|
+
evidence
|
|
795
1212
|
});
|
|
796
1213
|
}
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
1214
|
+
const veryReused = reusedAmounts.filter(([_, data]) => data.count >= 5);
|
|
1215
|
+
if (veryReused.length > 0 && suspiciousReuse.length === 0 && signerReuse.length === 0) {
|
|
1216
|
+
const evidence = veryReused.slice(0, 3).map(([amountKey, data]) => {
|
|
800
1217
|
const [amount, token] = amountKey.split("-");
|
|
801
|
-
|
|
1218
|
+
return {
|
|
1219
|
+
description: `${amount} ${token} used ${data.count} times across ${data.counterparties.size} counterparties`,
|
|
1220
|
+
severity: data.count > 10 ? "MEDIUM" : "LOW",
|
|
802
1221
|
type: "amount",
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
1222
|
+
data: { amount: parseFloat(amount), token, count: data.count }
|
|
1223
|
+
};
|
|
1224
|
+
});
|
|
1225
|
+
const maxCount = veryReused[0][1].count;
|
|
1226
|
+
const severity = maxCount > 10 ? "MEDIUM" : "LOW";
|
|
1227
|
+
signals.push({
|
|
1228
|
+
id: "amount-reuse-frequency",
|
|
1229
|
+
name: "High-Frequency Amount Reuse",
|
|
1230
|
+
severity,
|
|
1231
|
+
category: "behavioral",
|
|
1232
|
+
reason: `${veryReused.length} amount(s) are used very frequently (${maxCount} times for top amount).`,
|
|
1233
|
+
impact: "Extremely frequent reuse of specific amounts suggests automation or habitual behavior, creating a detectable pattern.",
|
|
1234
|
+
mitigation: "If running automated systems, add randomization to amounts.",
|
|
1235
|
+
evidence
|
|
1236
|
+
});
|
|
807
1237
|
}
|
|
808
|
-
return
|
|
809
|
-
id: "amount-reuse",
|
|
810
|
-
name: "Deterministic Amount Patterns",
|
|
811
|
-
severity,
|
|
812
|
-
reason: `Wallet uses ${hasRoundNumbers ? "round numbers" : "repeated amounts"} in transactions`,
|
|
813
|
-
impact: "Using the same amounts repeatedly or sending round numbers creates fingerprints that can be used to link transactions and identify patterns in your activity.",
|
|
814
|
-
evidence,
|
|
815
|
-
mitigation: "Vary transaction amounts slightly, avoid round numbers, and consider using privacy protocols that obscure amounts.",
|
|
816
|
-
confidence: hasRoundNumbers ? 0.85 : 0.75
|
|
817
|
-
};
|
|
1238
|
+
return signals;
|
|
818
1239
|
}
|
|
819
1240
|
|
|
820
1241
|
// src/heuristics/timing-patterns.ts
|
|
@@ -984,13 +1405,545 @@ function detectBalanceTraceability(context) {
|
|
|
984
1405
|
};
|
|
985
1406
|
}
|
|
986
1407
|
|
|
1408
|
+
// src/heuristics/fee-payer-reuse.ts
|
|
1409
|
+
function detectFeePayerReuse(context) {
|
|
1410
|
+
const signals = [];
|
|
1411
|
+
if (context.targetType === "transaction") {
|
|
1412
|
+
return signals;
|
|
1413
|
+
}
|
|
1414
|
+
if (!context.feePayers || !context.transactions || context.transactions.length === 0) {
|
|
1415
|
+
return signals;
|
|
1416
|
+
}
|
|
1417
|
+
const feePayers = context.feePayers;
|
|
1418
|
+
const target = context.target;
|
|
1419
|
+
const targetIsFeePayer = feePayers.has(target);
|
|
1420
|
+
const onlyTargetPays = feePayers.size === 1 && targetIsFeePayer;
|
|
1421
|
+
if (onlyTargetPays) {
|
|
1422
|
+
return signals;
|
|
1423
|
+
}
|
|
1424
|
+
if (feePayers.size > 1 && targetIsFeePayer) {
|
|
1425
|
+
const externalFeePayers = Array.from(feePayers).filter((fp) => fp !== target);
|
|
1426
|
+
const feePayerCounts = /* @__PURE__ */ new Map();
|
|
1427
|
+
for (const tx of context.transactions) {
|
|
1428
|
+
if (tx.feePayer !== target) {
|
|
1429
|
+
feePayerCounts.set(tx.feePayer, (feePayerCounts.get(tx.feePayer) || 0) + 1);
|
|
1430
|
+
}
|
|
1431
|
+
}
|
|
1432
|
+
const evidence = [];
|
|
1433
|
+
for (const [feePayer, count] of feePayerCounts) {
|
|
1434
|
+
evidence.push({
|
|
1435
|
+
description: `${feePayer} paid fees for ${count} transaction(s)`,
|
|
1436
|
+
severity: count > 1 ? "HIGH" : "MEDIUM",
|
|
1437
|
+
reference: void 0
|
|
1438
|
+
});
|
|
1439
|
+
}
|
|
1440
|
+
const knownFeePayerLabel = externalFeePayers.find((fp) => context.labels.has(fp));
|
|
1441
|
+
const knownLabel = knownFeePayerLabel ? context.labels.get(knownFeePayerLabel) : null;
|
|
1442
|
+
signals.push({
|
|
1443
|
+
id: "fee-payer-external",
|
|
1444
|
+
name: "External Fee Payer Detected",
|
|
1445
|
+
severity: knownLabel ? "HIGH" : "MEDIUM",
|
|
1446
|
+
category: "linkability",
|
|
1447
|
+
reason: `${externalFeePayers.length} external wallet(s) paid fees for transactions involving this address${knownLabel ? `, including known entity: ${knownLabel.name}` : ""}.`,
|
|
1448
|
+
impact: "This address is linked to the fee payer(s). Anyone observing the blockchain can see this relationship. If the fee payer is identified, this address is also compromised.",
|
|
1449
|
+
mitigation: "Always pay your own transaction fees. Never allow third parties to pay fees for your transactions unless absolutely necessary. If using a relayer, understand that this creates a permanent on-chain link.",
|
|
1450
|
+
evidence
|
|
1451
|
+
});
|
|
1452
|
+
}
|
|
1453
|
+
if (!targetIsFeePayer && feePayers.size > 0) {
|
|
1454
|
+
const allFeePayers = Array.from(feePayers);
|
|
1455
|
+
const feePayerCounts = /* @__PURE__ */ new Map();
|
|
1456
|
+
for (const tx of context.transactions) {
|
|
1457
|
+
feePayerCounts.set(tx.feePayer, (feePayerCounts.get(tx.feePayer) || 0) + 1);
|
|
1458
|
+
}
|
|
1459
|
+
const evidence = [];
|
|
1460
|
+
for (const [feePayer, count] of feePayerCounts) {
|
|
1461
|
+
const label = context.labels.get(feePayer);
|
|
1462
|
+
evidence.push({
|
|
1463
|
+
description: `${feePayer}${label ? ` (${label.name})` : ""} paid fees for ${count} transaction(s)`,
|
|
1464
|
+
severity: "HIGH",
|
|
1465
|
+
reference: void 0
|
|
1466
|
+
});
|
|
1467
|
+
}
|
|
1468
|
+
const maxCount = Math.max(...Array.from(feePayerCounts.values()));
|
|
1469
|
+
const repeatedFeePayer = maxCount > 1;
|
|
1470
|
+
signals.push({
|
|
1471
|
+
id: "fee-payer-never-self",
|
|
1472
|
+
name: "Never Self-Pays Transaction Fees",
|
|
1473
|
+
severity: repeatedFeePayer ? "HIGH" : "HIGH",
|
|
1474
|
+
// Always HIGH - this is critical
|
|
1475
|
+
category: "linkability",
|
|
1476
|
+
reason: `This address has NEVER paid its own transaction fees. All ${context.transactionCount} transaction(s) were paid by ${allFeePayers.length} external wallet(s).`,
|
|
1477
|
+
impact: "This is a CRITICAL privacy leak. This address is trivially linked to all fee payer(s). This pattern suggests a managed account, hot wallet, or program-controlled address. The controlling entity is fully exposed.",
|
|
1478
|
+
mitigation: "This account model fundamentally compromises privacy. To improve: (1) Fund this address with SOL and pay your own fees, or (2) Use a fresh address for each operation, or (3) Accept that this address is permanently linked to its fee payer(s).",
|
|
1479
|
+
evidence
|
|
1480
|
+
});
|
|
1481
|
+
}
|
|
1482
|
+
if (context.targetType === "program") {
|
|
1483
|
+
const feePayerCounts = /* @__PURE__ */ new Map();
|
|
1484
|
+
for (const tx of context.transactions) {
|
|
1485
|
+
if (!feePayerCounts.has(tx.feePayer)) {
|
|
1486
|
+
feePayerCounts.set(tx.feePayer, /* @__PURE__ */ new Set());
|
|
1487
|
+
}
|
|
1488
|
+
for (const signer of tx.signers) {
|
|
1489
|
+
feePayerCounts.get(tx.feePayer).add(signer);
|
|
1490
|
+
}
|
|
1491
|
+
}
|
|
1492
|
+
const multiFeePayerOperators = [];
|
|
1493
|
+
for (const [feePayer, signers] of feePayerCounts) {
|
|
1494
|
+
if (signers.size > 1) {
|
|
1495
|
+
const txCount = context.transactions.filter((tx) => tx.feePayer === feePayer).length;
|
|
1496
|
+
multiFeePayerOperators.push({
|
|
1497
|
+
feePayer,
|
|
1498
|
+
signerCount: signers.size,
|
|
1499
|
+
txCount
|
|
1500
|
+
});
|
|
1501
|
+
}
|
|
1502
|
+
}
|
|
1503
|
+
if (multiFeePayerOperators.length > 0) {
|
|
1504
|
+
const evidence = multiFeePayerOperators.map((op) => ({
|
|
1505
|
+
description: `${op.feePayer} paid fees for ${op.txCount} transaction(s) involving ${op.signerCount} different signer(s)`,
|
|
1506
|
+
severity: "HIGH",
|
|
1507
|
+
reference: void 0
|
|
1508
|
+
}));
|
|
1509
|
+
signals.push({
|
|
1510
|
+
id: "fee-payer-multi-signer",
|
|
1511
|
+
name: "Fee Payer Controls Multiple Signers",
|
|
1512
|
+
severity: "HIGH",
|
|
1513
|
+
category: "linkability",
|
|
1514
|
+
reason: `${multiFeePayerOperators.length} fee payer(s) are paying fees for multiple different signers, suggesting centralized control or bot operation.`,
|
|
1515
|
+
impact: "All addresses funded by the same fee payer are linkable. This pattern exposes operational infrastructure.",
|
|
1516
|
+
mitigation: "If running bots or managing multiple accounts, use a unique fee payer for each to avoid linking them on-chain.",
|
|
1517
|
+
evidence
|
|
1518
|
+
});
|
|
1519
|
+
}
|
|
1520
|
+
}
|
|
1521
|
+
return signals;
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
// src/heuristics/signer-overlap.ts
|
|
1525
|
+
function detectSignerOverlap(context) {
|
|
1526
|
+
const signals = [];
|
|
1527
|
+
if (context.transactionCount < 2) {
|
|
1528
|
+
return signals;
|
|
1529
|
+
}
|
|
1530
|
+
if (!context.transactions || context.transactions.length === 0) {
|
|
1531
|
+
return signals;
|
|
1532
|
+
}
|
|
1533
|
+
const signerFrequency = /* @__PURE__ */ new Map();
|
|
1534
|
+
const signerTransactions = /* @__PURE__ */ new Map();
|
|
1535
|
+
for (const tx of context.transactions) {
|
|
1536
|
+
for (const signer of tx.signers) {
|
|
1537
|
+
signerFrequency.set(signer, (signerFrequency.get(signer) || 0) + 1);
|
|
1538
|
+
if (!signerTransactions.has(signer)) {
|
|
1539
|
+
signerTransactions.set(signer, []);
|
|
1540
|
+
}
|
|
1541
|
+
signerTransactions.get(signer).push(tx.signature);
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
const target = context.target;
|
|
1545
|
+
const frequentSigners = Array.from(signerFrequency.entries()).filter(([signer]) => signer !== target).filter(([_, count]) => count >= Math.min(3, Math.ceil(context.transactionCount * 0.3))).sort((a, b) => b[1] - a[1]);
|
|
1546
|
+
if (frequentSigners.length > 0) {
|
|
1547
|
+
const evidence = frequentSigners.map(([signer, count]) => {
|
|
1548
|
+
const label = context.labels.get(signer);
|
|
1549
|
+
return {
|
|
1550
|
+
description: `${signer}${label ? ` (${label.name})` : ""} signed ${count}/${context.transactionCount} transactions`,
|
|
1551
|
+
severity: count > context.transactionCount * 0.7 ? "HIGH" : "MEDIUM",
|
|
1552
|
+
reference: void 0
|
|
1553
|
+
};
|
|
1554
|
+
});
|
|
1555
|
+
const topSignerCount = frequentSigners[0][1];
|
|
1556
|
+
const severity = topSignerCount > context.transactionCount * 0.7 ? "HIGH" : "MEDIUM";
|
|
1557
|
+
signals.push({
|
|
1558
|
+
id: "signer-repeated",
|
|
1559
|
+
name: "Repeated Signer Across Transactions",
|
|
1560
|
+
severity,
|
|
1561
|
+
category: "linkability",
|
|
1562
|
+
reason: `${frequentSigners.length} address(es) repeatedly sign transactions involving the target. The most frequent signer appears in ${topSignerCount}/${context.transactionCount} transactions.`,
|
|
1563
|
+
impact: "Repeated signers create hard links between transactions. All transactions signed by the same address are trivially linkable.",
|
|
1564
|
+
mitigation: "If you control multiple addresses that sign together, they are permanently linked. Use separate signing keys for unrelated activities.",
|
|
1565
|
+
evidence
|
|
1566
|
+
});
|
|
1567
|
+
}
|
|
1568
|
+
const signerSets = /* @__PURE__ */ new Map();
|
|
1569
|
+
const signerSetExamples = /* @__PURE__ */ new Map();
|
|
1570
|
+
for (const tx of context.transactions) {
|
|
1571
|
+
const sortedSigners = [...tx.signers].sort();
|
|
1572
|
+
const setKey = JSON.stringify(sortedSigners);
|
|
1573
|
+
signerSets.set(setKey, (signerSets.get(setKey) || 0) + 1);
|
|
1574
|
+
if (!signerSetExamples.has(setKey)) {
|
|
1575
|
+
signerSetExamples.set(setKey, tx.signature);
|
|
1576
|
+
}
|
|
1577
|
+
}
|
|
1578
|
+
const repeatedSets = Array.from(signerSets.entries()).filter(([_, count]) => count > 1).sort((a, b) => b[1] - a[1]);
|
|
1579
|
+
if (repeatedSets.length > 0) {
|
|
1580
|
+
const evidence = repeatedSets.map(([setKey, count]) => {
|
|
1581
|
+
const signers = JSON.parse(setKey);
|
|
1582
|
+
const exampleSig = signerSetExamples.get(setKey);
|
|
1583
|
+
return {
|
|
1584
|
+
description: `${count} transactions with identical signer set: [${signers.map((s) => s.slice(0, 8)).join(", ")}...]`,
|
|
1585
|
+
severity: count > 2 ? "MEDIUM" : "LOW",
|
|
1586
|
+
reference: `https://solscan.io/tx/${exampleSig}`
|
|
1587
|
+
};
|
|
1588
|
+
});
|
|
1589
|
+
signals.push({
|
|
1590
|
+
id: "signer-set-reuse",
|
|
1591
|
+
name: "Repeated Multi-Signature Pattern",
|
|
1592
|
+
severity: "MEDIUM",
|
|
1593
|
+
category: "linkability",
|
|
1594
|
+
reason: `${repeatedSets.length} distinct signer set(s) are reused multiple times. This creates a unique fingerprint.`,
|
|
1595
|
+
impact: "Reused multi-sig patterns are highly unique and easily linkable. Even if addresses differ, the signer set pattern can identify related activity.",
|
|
1596
|
+
mitigation: "If using multi-sig for multiple transactions, rotate signing keys or use threshold signatures to vary the signer set.",
|
|
1597
|
+
evidence
|
|
1598
|
+
});
|
|
1599
|
+
}
|
|
1600
|
+
if (context.targetType === "program" || context.transactionCount > 10) {
|
|
1601
|
+
const signerCoSigners = /* @__PURE__ */ new Map();
|
|
1602
|
+
for (const tx of context.transactions) {
|
|
1603
|
+
for (const signer of tx.signers) {
|
|
1604
|
+
if (!signerCoSigners.has(signer)) {
|
|
1605
|
+
signerCoSigners.set(signer, /* @__PURE__ */ new Set());
|
|
1606
|
+
}
|
|
1607
|
+
for (const otherSigner of tx.signers) {
|
|
1608
|
+
if (otherSigner !== signer) {
|
|
1609
|
+
signerCoSigners.get(signer).add(otherSigner);
|
|
1610
|
+
}
|
|
1611
|
+
}
|
|
1612
|
+
}
|
|
1613
|
+
}
|
|
1614
|
+
const authorityCandidates = Array.from(signerCoSigners.entries()).filter(([_, coSigners]) => coSigners.size >= 3).sort((a, b) => b[1].size - a[1].size);
|
|
1615
|
+
if (authorityCandidates.length > 0) {
|
|
1616
|
+
const evidence = authorityCandidates.slice(0, 3).map(([signer, coSigners]) => {
|
|
1617
|
+
const label = context.labels.get(signer);
|
|
1618
|
+
const txCount = signerFrequency.get(signer) || 0;
|
|
1619
|
+
return {
|
|
1620
|
+
description: `${signer}${label ? ` (${label.name})` : ""} co-signed with ${coSigners.size} different addresses across ${txCount} transactions`,
|
|
1621
|
+
severity: "HIGH",
|
|
1622
|
+
reference: void 0
|
|
1623
|
+
};
|
|
1624
|
+
});
|
|
1625
|
+
signals.push({
|
|
1626
|
+
id: "signer-authority-hub",
|
|
1627
|
+
name: "Authority Signer Detected",
|
|
1628
|
+
severity: "HIGH",
|
|
1629
|
+
category: "linkability",
|
|
1630
|
+
reason: `${authorityCandidates.length} address(es) act as an authority, co-signing with multiple different wallets. This exposes a control hub.`,
|
|
1631
|
+
impact: "An authority signer links all accounts it co-signs with. This reveals organizational structure or bot infrastructure.",
|
|
1632
|
+
mitigation: 'Use unique authority keys for each logical group of accounts. Avoid having a single "master" signer.',
|
|
1633
|
+
evidence
|
|
1634
|
+
});
|
|
1635
|
+
}
|
|
1636
|
+
}
|
|
1637
|
+
return signals;
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
// src/heuristics/instruction-fingerprinting.ts
|
|
1641
|
+
function detectInstructionFingerprinting(context) {
|
|
1642
|
+
const signals = [];
|
|
1643
|
+
if (context.transactionCount < 3) {
|
|
1644
|
+
return signals;
|
|
1645
|
+
}
|
|
1646
|
+
if (!context.transactions || context.transactions.length === 0) {
|
|
1647
|
+
return signals;
|
|
1648
|
+
}
|
|
1649
|
+
const sequenceFingerprints = /* @__PURE__ */ new Map();
|
|
1650
|
+
const sequenceExamples = /* @__PURE__ */ new Map();
|
|
1651
|
+
for (const tx of context.transactions) {
|
|
1652
|
+
const txInstructions = context.instructions.filter((inst) => inst.signature === tx.signature).map((inst) => inst.programId);
|
|
1653
|
+
if (txInstructions.length === 0) continue;
|
|
1654
|
+
const sequence = txInstructions.join("->");
|
|
1655
|
+
sequenceFingerprints.set(sequence, (sequenceFingerprints.get(sequence) || 0) + 1);
|
|
1656
|
+
if (!sequenceExamples.has(sequence)) {
|
|
1657
|
+
sequenceExamples.set(sequence, []);
|
|
1658
|
+
}
|
|
1659
|
+
sequenceExamples.get(sequence).push(tx.signature);
|
|
1660
|
+
}
|
|
1661
|
+
const repeatedSequences = Array.from(sequenceFingerprints.entries()).filter(([_, count]) => count >= Math.min(3, Math.ceil(context.transactionCount * 0.2))).sort((a, b) => b[1] - a[1]);
|
|
1662
|
+
if (repeatedSequences.length > 0) {
|
|
1663
|
+
const evidence = repeatedSequences.slice(0, 5).map(([sequence, count]) => {
|
|
1664
|
+
const exampleSigs = sequenceExamples.get(sequence).slice(0, 2);
|
|
1665
|
+
const programs = sequence.split("->").map((p) => p.slice(0, 8) + "...").join(" \u2192 ");
|
|
1666
|
+
return {
|
|
1667
|
+
description: `Instruction sequence repeated ${count} times: ${programs}`,
|
|
1668
|
+
severity: count > context.transactionCount * 0.5 ? "MEDIUM" : "LOW",
|
|
1669
|
+
reference: `https://solscan.io/tx/${exampleSigs[0]}`
|
|
1670
|
+
};
|
|
1671
|
+
});
|
|
1672
|
+
const topSequenceCount = repeatedSequences[0][1];
|
|
1673
|
+
const severity = topSequenceCount > context.transactionCount * 0.5 ? "MEDIUM" : "LOW";
|
|
1674
|
+
signals.push({
|
|
1675
|
+
id: "instruction-sequence-pattern",
|
|
1676
|
+
name: "Repeated Instruction Sequence Pattern",
|
|
1677
|
+
severity,
|
|
1678
|
+
category: "behavioral",
|
|
1679
|
+
reason: `${repeatedSequences.length} distinct instruction sequence(s) are repeated multiple times. The most common pattern appears in ${topSequenceCount}/${context.transactionCount} transactions.`,
|
|
1680
|
+
impact: "Repeated instruction patterns create a behavioral fingerprint. Even with different addresses, these patterns can link related activity.",
|
|
1681
|
+
mitigation: "Vary the order or combination of operations. Add dummy instructions or randomize transaction structure where possible.",
|
|
1682
|
+
evidence
|
|
1683
|
+
});
|
|
1684
|
+
}
|
|
1685
|
+
const programUsage = /* @__PURE__ */ new Map();
|
|
1686
|
+
for (const inst of context.instructions) {
|
|
1687
|
+
programUsage.set(inst.programId, (programUsage.get(inst.programId) || 0) + 1);
|
|
1688
|
+
}
|
|
1689
|
+
const COMMON_PROGRAMS = [
|
|
1690
|
+
"11111111111111111111111111111111",
|
|
1691
|
+
// System
|
|
1692
|
+
"TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA",
|
|
1693
|
+
// SPL Token
|
|
1694
|
+
"ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL",
|
|
1695
|
+
// Associated Token
|
|
1696
|
+
"MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr",
|
|
1697
|
+
// Memo
|
|
1698
|
+
"ComputeBudget111111111111111111111111111111"
|
|
1699
|
+
// Compute Budget
|
|
1700
|
+
];
|
|
1701
|
+
const uniquePrograms = Array.from(programUsage.entries()).filter(([programId]) => !COMMON_PROGRAMS.includes(programId)).filter(([_, count]) => count >= Math.min(2, Math.ceil(context.transactionCount * 0.15))).sort((a, b) => b[1] - a[1]);
|
|
1702
|
+
if (uniquePrograms.length >= 2) {
|
|
1703
|
+
const evidence = uniquePrograms.slice(0, 5).map(([programId, count]) => {
|
|
1704
|
+
const label = context.labels.get(programId);
|
|
1705
|
+
return {
|
|
1706
|
+
description: `${programId.slice(0, 8)}...${label ? ` (${label.name})` : ""} used in ${count} transactions`,
|
|
1707
|
+
severity: "LOW",
|
|
1708
|
+
reference: `https://solscan.io/account/${programId}`
|
|
1709
|
+
};
|
|
1710
|
+
});
|
|
1711
|
+
signals.push({
|
|
1712
|
+
id: "program-usage-profile",
|
|
1713
|
+
name: "Distinctive Program Usage Profile",
|
|
1714
|
+
severity: "LOW",
|
|
1715
|
+
category: "behavioral",
|
|
1716
|
+
reason: `This address uses ${uniquePrograms.length} less-common programs repeatedly. This creates a unique usage profile.`,
|
|
1717
|
+
impact: "Program usage patterns can fingerprint wallet behavior. Addresses with similar program usage profiles are likely related.",
|
|
1718
|
+
mitigation: "Using niche protocols creates a fingerprint. This is difficult to mitigate without changing your DeFi strategy.",
|
|
1719
|
+
evidence
|
|
1720
|
+
});
|
|
1721
|
+
}
|
|
1722
|
+
if (context.pdaInteractions.length > 0) {
|
|
1723
|
+
const pdaUsage = /* @__PURE__ */ new Map();
|
|
1724
|
+
for (const pda of context.pdaInteractions) {
|
|
1725
|
+
if (!pdaUsage.has(pda.pda)) {
|
|
1726
|
+
pdaUsage.set(pda.pda, { count: 0, programId: pda.programId });
|
|
1727
|
+
}
|
|
1728
|
+
pdaUsage.get(pda.pda).count++;
|
|
1729
|
+
}
|
|
1730
|
+
const repeatedPDAs = Array.from(pdaUsage.entries()).filter(([_, { count }]) => count > 1).sort((a, b) => b[1].count - a[1].count);
|
|
1731
|
+
if (repeatedPDAs.length > 0) {
|
|
1732
|
+
const evidence = repeatedPDAs.slice(0, 5).map(([pda, { count, programId }]) => ({
|
|
1733
|
+
description: `PDA ${pda.slice(0, 8)}... used ${count} times (program: ${programId.slice(0, 8)}...)`,
|
|
1734
|
+
severity: count > 3 ? "MEDIUM" : "LOW",
|
|
1735
|
+
reference: `https://solscan.io/account/${pda}`
|
|
1736
|
+
}));
|
|
1737
|
+
const maxPDAUsage = repeatedPDAs[0][1].count;
|
|
1738
|
+
const severity = maxPDAUsage > 3 ? "MEDIUM" : "LOW";
|
|
1739
|
+
signals.push({
|
|
1740
|
+
id: "pda-reuse-pattern",
|
|
1741
|
+
name: "Repeated PDA Interaction",
|
|
1742
|
+
severity,
|
|
1743
|
+
category: "behavioral",
|
|
1744
|
+
reason: `${repeatedPDAs.length} Program-Derived Address(es) are used repeatedly. The most common PDA appears in ${maxPDAUsage} transactions.`,
|
|
1745
|
+
impact: "Repeated PDA usage links transactions. If the PDA is specific to you (e.g., a user account), all interactions with it are linked.",
|
|
1746
|
+
mitigation: "Some PDA reuse is unavoidable (e.g., your DEX pool position). For sensitive operations, consider using fresh accounts or different protocols.",
|
|
1747
|
+
evidence
|
|
1748
|
+
});
|
|
1749
|
+
}
|
|
1750
|
+
}
|
|
1751
|
+
const programInstructions = /* @__PURE__ */ new Map();
|
|
1752
|
+
for (const inst of context.instructions) {
|
|
1753
|
+
if (!programInstructions.has(inst.programId)) {
|
|
1754
|
+
programInstructions.set(inst.programId, []);
|
|
1755
|
+
}
|
|
1756
|
+
if (inst.data) {
|
|
1757
|
+
programInstructions.get(inst.programId).push(inst.data);
|
|
1758
|
+
}
|
|
1759
|
+
}
|
|
1760
|
+
for (const [programId, dataList] of programInstructions) {
|
|
1761
|
+
if (dataList.length < 2) continue;
|
|
1762
|
+
const typeMap = /* @__PURE__ */ new Map();
|
|
1763
|
+
for (const data of dataList) {
|
|
1764
|
+
if (data && typeof data === "object" && "type" in data) {
|
|
1765
|
+
const type = String(data.type);
|
|
1766
|
+
typeMap.set(type, (typeMap.get(type) || 0) + 1);
|
|
1767
|
+
}
|
|
1768
|
+
}
|
|
1769
|
+
const repeatedTypes = Array.from(typeMap.entries()).filter(([_, count]) => count >= 2).sort((a, b) => b[1] - a[1]);
|
|
1770
|
+
if (repeatedTypes.length > 0 && repeatedTypes[0][1] >= 3) {
|
|
1771
|
+
const [instructionType, count] = repeatedTypes[0];
|
|
1772
|
+
const label = context.labels.get(programId);
|
|
1773
|
+
signals.push({
|
|
1774
|
+
id: `instruction-type-${programId.slice(0, 8)}`,
|
|
1775
|
+
name: "Repeated Instruction Type",
|
|
1776
|
+
severity: "LOW",
|
|
1777
|
+
category: "behavioral",
|
|
1778
|
+
reason: `The instruction type "${instructionType}" on program ${programId.slice(0, 8)}...${label ? ` (${label.name})` : ""} is used ${count} times.`,
|
|
1779
|
+
impact: "Repeated instruction types on the same program suggest automated behavior or specific strategy execution.",
|
|
1780
|
+
mitigation: "This is generally low-risk but contributes to behavioral fingerprinting. Diversify your transaction types if possible.",
|
|
1781
|
+
evidence: [{
|
|
1782
|
+
description: `"${instructionType}" instruction used ${count} times`,
|
|
1783
|
+
severity: "LOW",
|
|
1784
|
+
reference: void 0
|
|
1785
|
+
}]
|
|
1786
|
+
});
|
|
1787
|
+
}
|
|
1788
|
+
}
|
|
1789
|
+
return signals;
|
|
1790
|
+
}
|
|
1791
|
+
|
|
1792
|
+
// src/heuristics/token-account-lifecycle.ts
|
|
1793
|
+
function detectTokenAccountLifecycle(context) {
|
|
1794
|
+
const signals = [];
|
|
1795
|
+
if (!context.tokenAccountEvents || context.tokenAccountEvents.length === 0) {
|
|
1796
|
+
return signals;
|
|
1797
|
+
}
|
|
1798
|
+
const accountEvents = /* @__PURE__ */ new Map();
|
|
1799
|
+
for (const event of context.tokenAccountEvents) {
|
|
1800
|
+
if (!accountEvents.has(event.tokenAccount)) {
|
|
1801
|
+
accountEvents.set(event.tokenAccount, []);
|
|
1802
|
+
}
|
|
1803
|
+
accountEvents.get(event.tokenAccount).push(event);
|
|
1804
|
+
}
|
|
1805
|
+
const createEvents = context.tokenAccountEvents.filter((e) => e.type === "create");
|
|
1806
|
+
const closeEvents = context.tokenAccountEvents.filter((e) => e.type === "close");
|
|
1807
|
+
if (createEvents.length >= 2 && closeEvents.length >= 2) {
|
|
1808
|
+
const refundDestinations = /* @__PURE__ */ new Map();
|
|
1809
|
+
const totalRefunded = closeEvents.reduce((sum, event) => {
|
|
1810
|
+
if (event.rentRefund) {
|
|
1811
|
+
refundDestinations.set(event.owner, (refundDestinations.get(event.owner) || 0) + event.rentRefund);
|
|
1812
|
+
return sum + event.rentRefund;
|
|
1813
|
+
}
|
|
1814
|
+
return sum;
|
|
1815
|
+
}, 0);
|
|
1816
|
+
if (refundDestinations.size > 0) {
|
|
1817
|
+
const evidence = Array.from(refundDestinations.entries()).map(([owner, amount]) => ({
|
|
1818
|
+
description: `${amount.toFixed(4)} SOL refunded to ${owner.slice(0, 8)}... from ${closeEvents.filter((e) => e.owner === owner).length} closed account(s)`,
|
|
1819
|
+
severity: "MEDIUM",
|
|
1820
|
+
reference: void 0
|
|
1821
|
+
}));
|
|
1822
|
+
signals.push({
|
|
1823
|
+
id: "token-account-churn",
|
|
1824
|
+
name: "Frequent Token Account Creation/Closure",
|
|
1825
|
+
severity: "MEDIUM",
|
|
1826
|
+
category: "behavioral",
|
|
1827
|
+
reason: `${createEvents.length} token account(s) created and ${closeEvents.length} closed. Rent refunds totaling ${totalRefunded.toFixed(4)} SOL expose ownership.`,
|
|
1828
|
+
impact: 'Rent refunds link temporary token accounts back to the owner wallet. This pattern defeats the purpose of using "burner" accounts.',
|
|
1829
|
+
mitigation: "If using temporary token accounts for privacy, leave them open (accept the small rent cost) rather than closing and refunding to your main wallet.",
|
|
1830
|
+
evidence
|
|
1831
|
+
});
|
|
1832
|
+
}
|
|
1833
|
+
}
|
|
1834
|
+
const completeLifecycles = [];
|
|
1835
|
+
for (const [tokenAccount, events] of accountEvents) {
|
|
1836
|
+
const creates = events.filter((e) => e.type === "create");
|
|
1837
|
+
const closes = events.filter((e) => e.type === "close");
|
|
1838
|
+
if (creates.length > 0 && closes.length > 0) {
|
|
1839
|
+
const createTime = creates[0].blockTime;
|
|
1840
|
+
const closeTime = closes[closes.length - 1].blockTime;
|
|
1841
|
+
if (createTime && closeTime) {
|
|
1842
|
+
const duration = closeTime - createTime;
|
|
1843
|
+
completeLifecycles.push({ tokenAccount, events, duration });
|
|
1844
|
+
}
|
|
1845
|
+
}
|
|
1846
|
+
}
|
|
1847
|
+
const shortLived = completeLifecycles.filter((lc) => lc.duration < 3600);
|
|
1848
|
+
if (shortLived.length >= 2) {
|
|
1849
|
+
const evidence = shortLived.slice(0, 5).map((lc) => {
|
|
1850
|
+
const durationMin = Math.floor(lc.duration / 60);
|
|
1851
|
+
const closeEvent = lc.events.find((e) => e.type === "close");
|
|
1852
|
+
return {
|
|
1853
|
+
description: `${lc.tokenAccount.slice(0, 8)}... lived for ${durationMin} minute(s)${closeEvent?.rentRefund ? `, refunded ${closeEvent.rentRefund.toFixed(4)} SOL` : ""}`,
|
|
1854
|
+
severity: "LOW",
|
|
1855
|
+
reference: void 0
|
|
1856
|
+
};
|
|
1857
|
+
});
|
|
1858
|
+
signals.push({
|
|
1859
|
+
id: "token-account-short-lived",
|
|
1860
|
+
name: "Short-Lived Token Accounts",
|
|
1861
|
+
severity: "LOW",
|
|
1862
|
+
category: "behavioral",
|
|
1863
|
+
reason: `${shortLived.length} token account(s) were created and closed within an hour, suggesting burner account usage.`,
|
|
1864
|
+
impact: "Short-lived accounts suggest privacy-conscious behavior, but rent refunds still create linkage.",
|
|
1865
|
+
mitigation: "For true privacy, do not close accounts immediately. The rent refund links the burner back to you.",
|
|
1866
|
+
evidence
|
|
1867
|
+
});
|
|
1868
|
+
}
|
|
1869
|
+
const ownerAccounts = /* @__PURE__ */ new Map();
|
|
1870
|
+
for (const event of context.tokenAccountEvents) {
|
|
1871
|
+
if (event.type === "create") {
|
|
1872
|
+
if (!ownerAccounts.has(event.owner)) {
|
|
1873
|
+
ownerAccounts.set(event.owner, /* @__PURE__ */ new Set());
|
|
1874
|
+
}
|
|
1875
|
+
ownerAccounts.get(event.owner).add(event.tokenAccount);
|
|
1876
|
+
}
|
|
1877
|
+
}
|
|
1878
|
+
const multiAccountOwners = Array.from(ownerAccounts.entries()).filter(([_, accounts]) => accounts.size >= 2).sort((a, b) => b[1].size - a[1].size);
|
|
1879
|
+
if (multiAccountOwners.length > 0) {
|
|
1880
|
+
const [owner, accounts] = multiAccountOwners[0];
|
|
1881
|
+
const isTarget = owner === context.target;
|
|
1882
|
+
if (!isTarget || multiAccountOwners.length > 1) {
|
|
1883
|
+
const evidence = multiAccountOwners.slice(0, 3).map(([own, accs]) => {
|
|
1884
|
+
const label = context.labels.get(own);
|
|
1885
|
+
return {
|
|
1886
|
+
description: `${own.slice(0, 8)}...${label ? ` (${label.name})` : ""} owns ${accs.size} token account(s)`,
|
|
1887
|
+
severity: "LOW",
|
|
1888
|
+
reference: void 0
|
|
1889
|
+
};
|
|
1890
|
+
});
|
|
1891
|
+
signals.push({
|
|
1892
|
+
id: "token-account-common-owner",
|
|
1893
|
+
name: "Common Owner Across Token Accounts",
|
|
1894
|
+
severity: "LOW",
|
|
1895
|
+
category: "linkability",
|
|
1896
|
+
reason: `${multiAccountOwners.length} wallet(s) control multiple token accounts. The top owner controls ${accounts.size} accounts.`,
|
|
1897
|
+
impact: "All token accounts with the same owner are trivially linked.",
|
|
1898
|
+
mitigation: "This is inherent to Solana's token account model and cannot be avoided.",
|
|
1899
|
+
evidence
|
|
1900
|
+
});
|
|
1901
|
+
}
|
|
1902
|
+
}
|
|
1903
|
+
const rentRefundReceivers = /* @__PURE__ */ new Map();
|
|
1904
|
+
for (const event of context.tokenAccountEvents) {
|
|
1905
|
+
if (event.type === "close" && event.rentRefund) {
|
|
1906
|
+
const current = rentRefundReceivers.get(event.owner) || { count: 0, total: 0 };
|
|
1907
|
+
current.count++;
|
|
1908
|
+
current.total += event.rentRefund;
|
|
1909
|
+
rentRefundReceivers.set(event.owner, current);
|
|
1910
|
+
}
|
|
1911
|
+
}
|
|
1912
|
+
const significantRefunds = Array.from(rentRefundReceivers.entries()).filter(([_, { count }]) => count >= 3).sort((a, b) => b[1].count - a[1].count);
|
|
1913
|
+
if (significantRefunds.length > 0) {
|
|
1914
|
+
const evidence = significantRefunds.slice(0, 3).map(([owner, { count, total }]) => ({
|
|
1915
|
+
description: `${owner.slice(0, 8)}... received ${count} rent refunds totaling ${total.toFixed(4)} SOL`,
|
|
1916
|
+
severity: "MEDIUM",
|
|
1917
|
+
reference: void 0
|
|
1918
|
+
}));
|
|
1919
|
+
const [topOwner, topData] = significantRefunds[0];
|
|
1920
|
+
signals.push({
|
|
1921
|
+
id: "rent-refund-clustering",
|
|
1922
|
+
name: "Rent Refund Clustering",
|
|
1923
|
+
severity: "MEDIUM",
|
|
1924
|
+
category: "linkability",
|
|
1925
|
+
reason: `${significantRefunds.length} address(es) receive multiple rent refunds. ${topOwner.slice(0, 8)}... received ${topData.count} refunds.`,
|
|
1926
|
+
impact: "Rent refunds link closed token accounts back to a central wallet. This exposes the control structure.",
|
|
1927
|
+
mitigation: "Do not close token accounts if privacy is important. The small rent cost (~0.002 SOL) is cheaper than the privacy loss.",
|
|
1928
|
+
evidence
|
|
1929
|
+
});
|
|
1930
|
+
}
|
|
1931
|
+
return signals;
|
|
1932
|
+
}
|
|
1933
|
+
|
|
987
1934
|
// src/scanner/index.ts
|
|
988
1935
|
var REPORT_VERSION = "1.0.0";
|
|
989
1936
|
var HEURISTICS = [
|
|
1937
|
+
// Solana-specific (highest priority)
|
|
1938
|
+
detectFeePayerReuse,
|
|
1939
|
+
detectSignerOverlap,
|
|
1940
|
+
detectKnownEntityInteraction,
|
|
990
1941
|
detectCounterpartyReuse,
|
|
991
|
-
|
|
1942
|
+
detectInstructionFingerprinting,
|
|
1943
|
+
detectTokenAccountLifecycle,
|
|
1944
|
+
// Traditional heuristics
|
|
992
1945
|
detectTimingPatterns,
|
|
993
|
-
|
|
1946
|
+
detectAmountReuse,
|
|
994
1947
|
detectBalanceTraceability
|
|
995
1948
|
];
|
|
996
1949
|
function calculateOverallRisk(signals) {
|
|
@@ -1015,10 +1968,22 @@ function generateMitigations(signals) {
|
|
|
1015
1968
|
}
|
|
1016
1969
|
mitigations.add("Consider using multiple wallets to compartmentalize different activities.");
|
|
1017
1970
|
const signalIds = new Set(signals.map((s) => s.id));
|
|
1971
|
+
if (signalIds.has("fee-payer-never-self") || signalIds.has("fee-payer-external")) {
|
|
1972
|
+
mitigations.add("Always pay your own transaction fees to avoid linkage.");
|
|
1973
|
+
}
|
|
1974
|
+
if (signalIds.has("signer-repeated") || signalIds.has("signer-set-reuse")) {
|
|
1975
|
+
mitigations.add("Use separate signing keys for unrelated activities.");
|
|
1976
|
+
}
|
|
1977
|
+
if (signalIds.has("instruction-sequence-pattern") || signalIds.has("program-usage-profile")) {
|
|
1978
|
+
mitigations.add("Diversify transaction patterns and protocols to reduce behavioral fingerprinting.");
|
|
1979
|
+
}
|
|
1980
|
+
if (signalIds.has("token-account-churn") || signalIds.has("rent-refund-clustering")) {
|
|
1981
|
+
mitigations.add("Avoid closing token accounts if privacy is important - the rent refund creates linkage.");
|
|
1982
|
+
}
|
|
1018
1983
|
if (signalIds.has("known-entity-interaction")) {
|
|
1019
1984
|
mitigations.add("Avoid direct interactions between privacy-sensitive wallets and KYC services.");
|
|
1020
1985
|
}
|
|
1021
|
-
if (signalIds.has("counterparty-reuse")) {
|
|
1986
|
+
if (signalIds.has("counterparty-reuse") || signalIds.has("pda-reuse")) {
|
|
1022
1987
|
mitigations.add("Use different addresses for different counterparties or contexts.");
|
|
1023
1988
|
}
|
|
1024
1989
|
if (signalIds.has("timing-correlation") || signalIds.has("balance-traceability")) {
|
|
@@ -1034,9 +1999,11 @@ function evaluateHeuristics(context) {
|
|
|
1034
1999
|
const signals = [];
|
|
1035
2000
|
for (const heuristic of HEURISTICS) {
|
|
1036
2001
|
try {
|
|
1037
|
-
const
|
|
1038
|
-
if (
|
|
1039
|
-
signals.push(
|
|
2002
|
+
const result = heuristic(context);
|
|
2003
|
+
if (Array.isArray(result)) {
|
|
2004
|
+
signals.push(...result);
|
|
2005
|
+
} else if (result) {
|
|
2006
|
+
signals.push(result);
|
|
1040
2007
|
}
|
|
1041
2008
|
} catch (error) {
|
|
1042
2009
|
console.warn(`Heuristic evaluation failed:`, error);
|
|
@@ -1151,11 +2118,9 @@ var StaticLabelProvider = class {
|
|
|
1151
2118
|
function createDefaultLabelProvider() {
|
|
1152
2119
|
return new StaticLabelProvider();
|
|
1153
2120
|
}
|
|
1154
|
-
|
|
1155
|
-
// src/index.ts
|
|
1156
|
-
var VERSION = "0.1.0";
|
|
1157
2121
|
// Annotate the CommonJS export names for ESM import in node:
|
|
1158
2122
|
0 && (module.exports = {
|
|
2123
|
+
DEFAULT_RPC_URL,
|
|
1159
2124
|
RPCClient,
|
|
1160
2125
|
StaticLabelProvider,
|
|
1161
2126
|
VERSION,
|
|
@@ -1166,8 +2131,12 @@ var VERSION = "0.1.0";
|
|
|
1166
2131
|
detectAmountReuse,
|
|
1167
2132
|
detectBalanceTraceability,
|
|
1168
2133
|
detectCounterpartyReuse,
|
|
2134
|
+
detectFeePayerReuse,
|
|
2135
|
+
detectInstructionFingerprinting,
|
|
1169
2136
|
detectKnownEntityInteraction,
|
|
2137
|
+
detectSignerOverlap,
|
|
1170
2138
|
detectTimingPatterns,
|
|
2139
|
+
detectTokenAccountLifecycle,
|
|
1171
2140
|
evaluateHeuristics,
|
|
1172
2141
|
generateReport,
|
|
1173
2142
|
normalizeProgramData,
|