solana-privacy-scanner 0.3.1 → 0.3.2

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 CHANGED
@@ -6,2541 +6,13 @@ import * as dotenv from "dotenv";
6
6
 
7
7
  // src/commands/wallet.ts
8
8
  import { writeFileSync } from "fs";
9
-
10
- // ../core/dist/index.js
11
- import { Connection } from "@solana/web3.js";
12
- import { readFileSync } from "fs";
13
- import { fileURLToPath } from "url";
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";
17
- var RateLimiter = class {
18
- constructor(maxConcurrency) {
19
- this.maxConcurrency = maxConcurrency;
20
- }
21
- activeRequests = 0;
22
- queue = [];
23
- async acquire() {
24
- if (this.activeRequests < this.maxConcurrency) {
25
- this.activeRequests++;
26
- return;
27
- }
28
- return new Promise((resolve) => {
29
- this.queue.push(() => {
30
- this.activeRequests++;
31
- resolve();
32
- });
33
- });
34
- }
35
- release() {
36
- this.activeRequests--;
37
- const next = this.queue.shift();
38
- if (next) {
39
- next();
40
- }
41
- }
42
- getActiveCount() {
43
- return this.activeRequests;
44
- }
45
- getQueueLength() {
46
- return this.queue.length;
47
- }
48
- };
49
- function sleep(ms) {
50
- return new Promise((resolve) => setTimeout(resolve, ms));
51
- }
52
- var RPCClient = class {
53
- connection;
54
- config;
55
- rateLimiter;
56
- constructor(configOrUrl) {
57
- const config2 = !configOrUrl ? {} : typeof configOrUrl === "string" ? { rpcUrl: configOrUrl } : configOrUrl;
58
- const rpcUrl = (config2.rpcUrl || DEFAULT_RPC_URL).trim();
59
- this.config = {
60
- maxRetries: config2.maxRetries ?? 3,
61
- retryDelay: config2.retryDelay ?? 1e3,
62
- timeout: config2.timeout ?? 3e4,
63
- maxConcurrency: config2.maxConcurrency ?? 10,
64
- debug: config2.debug ?? false,
65
- rpcUrl
66
- };
67
- const connectionConfig = {
68
- commitment: "confirmed",
69
- confirmTransactionInitialTimeout: this.config.timeout
70
- };
71
- this.connection = new Connection(this.config.rpcUrl, connectionConfig);
72
- this.rateLimiter = new RateLimiter(this.config.maxConcurrency);
73
- if (this.config.debug) {
74
- console.log(`[RPCClient] Initialized with URL: ${this.config.rpcUrl}`);
75
- console.log(`[RPCClient] Max concurrency: ${this.config.maxConcurrency}`);
76
- console.log(`[RPCClient] Max retries: ${this.config.maxRetries}`);
77
- }
78
- }
79
- /**
80
- * Execute an RPC call with retry logic and rate limiting
81
- */
82
- async executeWithRetry(operation, operationName) {
83
- await this.rateLimiter.acquire();
84
- let lastError = null;
85
- for (let attempt = 0; attempt <= this.config.maxRetries; attempt++) {
86
- try {
87
- if (this.config.debug && attempt > 0) {
88
- console.log(`[RPCClient] Retry attempt ${attempt} for ${operationName}`);
89
- }
90
- const result = await operation();
91
- this.rateLimiter.release();
92
- return result;
93
- } catch (error) {
94
- lastError = error;
95
- if (this.config.debug) {
96
- console.error(
97
- `[RPCClient] Error in ${operationName} (attempt ${attempt + 1}/${this.config.maxRetries + 1}):`,
98
- error
99
- );
100
- }
101
- if (attempt < this.config.maxRetries) {
102
- const delay = this.config.retryDelay * Math.pow(2, attempt);
103
- await sleep(delay);
104
- }
105
- }
106
- }
107
- this.rateLimiter.release();
108
- throw new Error(
109
- `RPC operation ${operationName} failed after ${this.config.maxRetries + 1} attempts: ${lastError?.message}`
110
- );
111
- }
112
- /**
113
- * Get the underlying Solana Connection
114
- * Use this sparingly - prefer the wrapped methods for automatic retry/rate limiting
115
- */
116
- getConnection() {
117
- return this.connection;
118
- }
119
- /**
120
- * Get current rate limiter stats
121
- */
122
- getStats() {
123
- return {
124
- activeRequests: this.rateLimiter.getActiveCount(),
125
- queueLength: this.rateLimiter.getQueueLength()
126
- };
127
- }
128
- /**
129
- * Get signatures for an address with retry and rate limiting
130
- */
131
- async getSignaturesForAddress(address, options) {
132
- return this.executeWithRetry(
133
- async () => {
134
- const { PublicKey } = await import("@solana/web3.js");
135
- return this.connection.getSignaturesForAddress(
136
- new PublicKey(address),
137
- options
138
- );
139
- },
140
- `getSignaturesForAddress(${address})`
141
- );
142
- }
143
- /**
144
- * Get transaction details with retry and rate limiting
145
- */
146
- async getTransaction(signature, options) {
147
- return this.executeWithRetry(
148
- async () => {
149
- return this.connection.getTransaction(signature, {
150
- maxSupportedTransactionVersion: options?.maxSupportedTransactionVersion ?? 0
151
- });
152
- },
153
- `getTransaction(${signature})`
154
- );
155
- }
156
- /**
157
- * Get multiple transactions in parallel (respects rate limiting)
158
- */
159
- async getTransactions(signatures, options) {
160
- const promises = signatures.map((sig) => this.getTransaction(sig, options));
161
- return Promise.all(promises);
162
- }
163
- /**
164
- * Get token accounts by owner with retry and rate limiting
165
- */
166
- async getTokenAccountsByOwner(ownerAddress, mintAddress) {
167
- return this.executeWithRetry(
168
- async () => {
169
- const { PublicKey } = await import("@solana/web3.js");
170
- const owner = new PublicKey(ownerAddress);
171
- const TOKEN_PROGRAM_ID = new PublicKey("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA");
172
- if (mintAddress) {
173
- const mint = new PublicKey(mintAddress);
174
- return this.connection.getTokenAccountsByOwner(owner, { mint });
175
- } else {
176
- return this.connection.getTokenAccountsByOwner(owner, {
177
- programId: TOKEN_PROGRAM_ID
178
- });
179
- }
180
- },
181
- `getTokenAccountsByOwner(${ownerAddress})`
182
- );
183
- }
184
- /**
185
- * Get program accounts with retry and rate limiting
186
- */
187
- async getProgramAccounts(programId, config2) {
188
- return this.executeWithRetry(
189
- async () => {
190
- const { PublicKey } = await import("@solana/web3.js");
191
- return this.connection.getProgramAccounts(new PublicKey(programId), config2);
192
- },
193
- `getProgramAccounts(${programId})`
194
- );
195
- }
196
- /**
197
- * Get account info with retry and rate limiting
198
- */
199
- async getAccountInfo(address) {
200
- return this.executeWithRetry(
201
- async () => {
202
- const { PublicKey } = await import("@solana/web3.js");
203
- return this.connection.getAccountInfo(new PublicKey(address));
204
- },
205
- `getAccountInfo(${address})`
206
- );
207
- }
208
- /**
209
- * Get multiple account infos in parallel (respects rate limiting)
210
- */
211
- async getMultipleAccountsInfo(addresses) {
212
- return this.executeWithRetry(
213
- async () => {
214
- const { PublicKey } = await import("@solana/web3.js");
215
- const pubkeys = addresses.map((addr) => new PublicKey(addr));
216
- return this.connection.getMultipleAccountsInfo(pubkeys);
217
- },
218
- `getMultipleAccountsInfo(${addresses.length} addresses)`
219
- );
220
- }
221
- /**
222
- * Check if the RPC connection is healthy
223
- */
224
- async healthCheck() {
225
- try {
226
- const version = await this.executeWithRetry(
227
- () => this.connection.getVersion(),
228
- "healthCheck"
229
- );
230
- return !!version;
231
- } catch {
232
- return false;
233
- }
234
- }
235
- };
236
- async function collectWalletData(client, address, options = {}) {
237
- const maxSignatures = options.maxSignatures ?? 100;
238
- const includeTokenAccounts = options.includeTokenAccounts ?? true;
239
- let signatures = [];
240
- try {
241
- signatures = await client.getSignaturesForAddress(address, {
242
- limit: maxSignatures
243
- });
244
- } catch (error) {
245
- console.warn(`Failed to fetch signatures for ${address}:`, error);
246
- }
247
- const transactions = [];
248
- const BATCH_SIZE = 10;
249
- for (let i = 0; i < signatures.length; i += BATCH_SIZE) {
250
- const batch = signatures.slice(i, i + BATCH_SIZE);
251
- const batchSignatures = batch.map((sig) => sig.signature);
252
- try {
253
- const txs = await client.getTransactions(batchSignatures, {
254
- maxSupportedTransactionVersion: 0
255
- });
256
- for (let j = 0; j < batch.length; j++) {
257
- transactions.push({
258
- signature: batch[j].signature,
259
- transaction: txs[j],
260
- blockTime: batch[j].blockTime
261
- });
262
- }
263
- } catch (error) {
264
- console.warn(`Failed to fetch transaction batch for ${address}:`, error);
265
- }
266
- }
267
- let tokenAccounts = [];
268
- if (includeTokenAccounts) {
269
- try {
270
- const response = await client.getTokenAccountsByOwner(address);
271
- tokenAccounts = response.value;
272
- } catch (error) {
273
- console.warn(`Failed to fetch token accounts for ${address}:`, error);
274
- }
275
- }
276
- return {
277
- address,
278
- signatures,
279
- transactions,
280
- tokenAccounts
281
- };
282
- }
283
- async function collectTransactionData(client, signature) {
284
- let transaction = null;
285
- try {
286
- transaction = await client.getTransaction(signature, {
287
- maxSupportedTransactionVersion: 0
288
- });
289
- } catch (error) {
290
- console.warn(`Failed to fetch transaction ${signature}:`, error);
291
- }
292
- return {
293
- signature,
294
- transaction,
295
- blockTime: transaction?.blockTime ?? null
296
- };
297
- }
298
- async function collectProgramData(client, programId, options = {}) {
299
- const maxAccounts = options.maxAccounts ?? 100;
300
- const maxTransactions = options.maxTransactions ?? 50;
301
- let accounts = [];
302
- try {
303
- const response = await client.getProgramAccounts(programId, {
304
- encoding: "jsonParsed",
305
- dataSlice: { offset: 0, length: 0 }
306
- // Don't fetch full account data
307
- });
308
- accounts = response.slice(0, maxAccounts).map((acc) => ({
309
- pubkey: acc.pubkey.toString(),
310
- account: acc.account
311
- }));
312
- } catch (error) {
313
- console.warn(`Failed to fetch program accounts for ${programId}:`, error);
314
- }
315
- let signatures = [];
316
- try {
317
- signatures = await client.getSignaturesForAddress(programId, {
318
- limit: maxTransactions
319
- });
320
- } catch (error) {
321
- console.warn(`Failed to fetch signatures for program ${programId}:`, error);
322
- }
323
- const relatedTransactions = [];
324
- const BATCH_SIZE = 10;
325
- for (let i = 0; i < Math.min(signatures.length, maxTransactions); i += BATCH_SIZE) {
326
- const batch = signatures.slice(i, i + BATCH_SIZE);
327
- const batchSignatures = batch.map((sig) => sig.signature);
328
- try {
329
- const txs = await client.getTransactions(batchSignatures, {
330
- maxSupportedTransactionVersion: 0
331
- });
332
- for (let j = 0; j < batch.length; j++) {
333
- relatedTransactions.push({
334
- signature: batch[j].signature,
335
- transaction: txs[j],
336
- blockTime: batch[j].blockTime
337
- });
338
- }
339
- } catch (error) {
340
- console.warn(`Failed to fetch transaction batch for program ${programId}:`, error);
341
- }
342
- }
343
- return {
344
- programId,
345
- accounts,
346
- relatedTransactions
347
- };
348
- }
349
- var PROGRAM_IDS = {
350
- SYSTEM: "11111111111111111111111111111111",
351
- TOKEN: "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA",
352
- ASSOCIATED_TOKEN: "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL",
353
- STAKE: "Stake11111111111111111111111111111111111111",
354
- VOTE: "Vote111111111111111111111111111111111111111",
355
- MEMO: "MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr",
356
- MEMO_V1: "Memo1UhkJRfHyvLMcVucJwxXeuD728EqVDDwQDxFMNo"
357
- };
358
- function extractTransactionMetadata(tx, signature) {
359
- if (!tx || !tx.transaction || !tx.transaction.message || !tx.transaction.message.accountKeys) {
360
- return {
361
- signature,
362
- blockTime: tx?.blockTime || null,
363
- feePayer: "unknown",
364
- signers: []
365
- };
366
- }
367
- const feePayer = tx.transaction.message.accountKeys[0];
368
- const feePayerAddress = typeof feePayer === "string" ? feePayer : feePayer.pubkey.toString();
369
- const signers = [];
370
- const accountKeys = tx.transaction.message.accountKeys;
371
- signers.push(feePayerAddress);
372
- if (accountKeys && Array.isArray(accountKeys)) {
373
- for (let i = 1; i < accountKeys.length; i++) {
374
- const key = accountKeys[i];
375
- const address = typeof key === "string" ? key : key.pubkey?.toString();
376
- if (typeof key !== "string" && key.signer) {
377
- if (address && !signers.includes(address)) {
378
- signers.push(address);
379
- }
380
- }
381
- }
382
- }
383
- let memo;
384
- const instructions = tx.transaction.message.instructions;
385
- if (instructions && Array.isArray(instructions)) {
386
- for (const instruction of instructions) {
387
- if (!instruction || !instruction.programId) continue;
388
- const programId = instruction.programId.toString();
389
- if (programId === PROGRAM_IDS.MEMO || programId === PROGRAM_IDS.MEMO_V1) {
390
- if ("parsed" in instruction && instruction.parsed) {
391
- const parsed = instruction.parsed;
392
- if (parsed.type === "memo" && typeof parsed.info === "string") {
393
- memo = parsed.info;
394
- }
395
- } else if ("data" in instruction && typeof instruction.data === "string") {
396
- try {
397
- memo = Buffer.from(instruction.data, "base64").toString("utf8");
398
- } catch {
399
- memo = instruction.data;
400
- }
401
- }
402
- break;
403
- }
404
- }
405
- }
406
- let computeUnitsUsed;
407
- let priorityFee;
408
- if (tx.meta) {
409
- computeUnitsUsed = tx.meta.computeUnitsConsumed;
410
- if (tx.meta.fee !== void 0 && tx.meta.fee > 5e3) {
411
- priorityFee = tx.meta.fee - 5e3;
412
- }
413
- }
414
- return {
415
- signature,
416
- blockTime: tx.blockTime,
417
- feePayer: feePayerAddress,
418
- signers,
419
- computeUnitsUsed,
420
- priorityFee,
421
- memo
422
- };
423
- }
424
- function extractTokenAccountEvents(tx, signature) {
425
- const events = [];
426
- if (!tx.transaction?.message?.instructions) {
427
- return events;
428
- }
429
- for (const instruction of tx.transaction.message.instructions) {
430
- if (!instruction || !instruction.programId) continue;
431
- const programId = instruction.programId.toString();
432
- if (programId === PROGRAM_IDS.TOKEN || programId === PROGRAM_IDS.ASSOCIATED_TOKEN) {
433
- if ("parsed" in instruction && instruction.parsed) {
434
- const parsed = instruction.parsed;
435
- if (parsed.type === "initializeAccount" || parsed.type === "create") {
436
- const info = parsed.info;
437
- events.push({
438
- type: "create",
439
- tokenAccount: info.account || info.newAccount,
440
- owner: info.owner,
441
- mint: info.mint,
442
- signature,
443
- blockTime: tx.blockTime
444
- });
445
- }
446
- if (parsed.type === "closeAccount") {
447
- const info = parsed.info;
448
- let rentRefund;
449
- if (tx.meta?.postBalances && tx.meta?.preBalances) {
450
- const accountKeys = tx.transaction.message.accountKeys;
451
- for (let i = 0; i < accountKeys.length; i++) {
452
- const key = accountKeys[i];
453
- const address = typeof key === "string" ? key : key.pubkey.toString();
454
- if (address === info.destination) {
455
- const diff = tx.meta.postBalances[i] - tx.meta.preBalances[i];
456
- if (diff > 0) {
457
- rentRefund = diff / 1e9;
458
- }
459
- break;
460
- }
461
- }
462
- }
463
- events.push({
464
- type: "close",
465
- tokenAccount: info.account,
466
- owner: info.owner || info.destination,
467
- signature,
468
- blockTime: tx.blockTime,
469
- rentRefund
470
- });
471
- }
472
- }
473
- }
474
- }
475
- return events;
476
- }
477
- function extractPDAInteractions(tx, signature) {
478
- const interactions = [];
479
- if (!tx.transaction?.message?.accountKeys) {
480
- return interactions;
481
- }
482
- const accountProgramMap = /* @__PURE__ */ new Map();
483
- for (const instruction of tx.transaction.message.instructions) {
484
- if (!instruction || !instruction.programId) continue;
485
- const programId = instruction.programId.toString();
486
- const accounts = [];
487
- if ("accounts" in instruction && Array.isArray(instruction.accounts)) {
488
- for (const acc of instruction.accounts) {
489
- const address = typeof acc === "string" ? acc : acc.toString();
490
- accounts.push(address);
491
- }
492
- }
493
- for (const account of accounts) {
494
- if (!accountProgramMap.has(account)) {
495
- accountProgramMap.set(account, /* @__PURE__ */ new Set());
496
- }
497
- accountProgramMap.get(account).add(programId);
498
- }
499
- }
500
- for (const [address, programs] of accountProgramMap) {
501
- for (const programId of programs) {
502
- if (programId === PROGRAM_IDS.SYSTEM || programId === PROGRAM_IDS.MEMO || programId === PROGRAM_IDS.MEMO_V1) {
503
- continue;
504
- }
505
- interactions.push({
506
- pda: address,
507
- programId,
508
- signature
509
- });
510
- }
511
- }
512
- return interactions;
513
- }
514
- function extractSOLTransfers(tx, signature) {
515
- const transfers = [];
516
- if (!tx.meta || !tx.transaction) {
517
- return transfers;
518
- }
519
- const preBalances = tx.meta.preBalances;
520
- const postBalances = tx.meta.postBalances;
521
- const accountKeys = tx.transaction.message.accountKeys;
522
- if (!accountKeys || !Array.isArray(accountKeys) || accountKeys.length === 0) {
523
- return transfers;
524
- }
525
- if (!preBalances || !postBalances) {
526
- return transfers;
527
- }
528
- for (let i = 0; i < accountKeys.length; i++) {
529
- const pre = preBalances[i];
530
- const post = postBalances[i];
531
- if (pre === void 0 || post === void 0) {
532
- continue;
533
- }
534
- const diff = post - pre;
535
- if (diff === 0) continue;
536
- const account = accountKeys[i];
537
- if (!account) continue;
538
- const address = typeof account === "string" ? account : account.pubkey?.toString();
539
- if (!address) continue;
540
- if (diff > 0) {
541
- for (let j = 0; j < accountKeys.length; j++) {
542
- const preSender = preBalances[j];
543
- const postSender = postBalances[j];
544
- if (preSender === void 0 || postSender === void 0) {
545
- continue;
546
- }
547
- if (postSender < preSender) {
548
- const sender = accountKeys[j];
549
- if (!sender) continue;
550
- const senderAddress = typeof sender === "string" ? sender : sender.pubkey?.toString();
551
- if (!senderAddress) continue;
552
- transfers.push({
553
- from: senderAddress,
554
- to: address,
555
- amount: diff / 1e9,
556
- // Convert lamports to SOL
557
- token: void 0,
558
- signature,
559
- blockTime: tx.blockTime
560
- });
561
- break;
562
- }
563
- }
564
- }
565
- }
566
- return transfers;
567
- }
568
- function extractSPLTransfers(tx, signature) {
569
- const transfers = [];
570
- if (!tx.meta || !tx.meta.postTokenBalances || !tx.meta.preTokenBalances) {
571
- return transfers;
572
- }
573
- const preTokenBalances = tx.meta.preTokenBalances;
574
- const postTokenBalances = tx.meta.postTokenBalances;
575
- const balanceChanges = /* @__PURE__ */ new Map();
576
- for (const post of postTokenBalances) {
577
- const pre = preTokenBalances.find(
578
- (p) => p.accountIndex === post.accountIndex && p.mint === post.mint
579
- );
580
- const preAmount = pre?.uiTokenAmount.uiAmount ?? 0;
581
- const postAmount = post.uiTokenAmount.uiAmount ?? 0;
582
- const change = postAmount - preAmount;
583
- if (change !== 0) {
584
- balanceChanges.set(post.accountIndex, {
585
- mint: post.mint,
586
- change,
587
- decimals: post.uiTokenAmount.decimals
588
- });
589
- }
590
- }
591
- const accountKeys = tx.transaction.message.accountKeys;
592
- if (!accountKeys || !Array.isArray(accountKeys)) {
593
- return transfers;
594
- }
595
- balanceChanges.forEach((info, accountIndex) => {
596
- if (accountIndex >= accountKeys.length) {
597
- return;
598
- }
599
- const account = accountKeys[accountIndex];
600
- if (!account) return;
601
- const address = typeof account === "string" ? account : account.pubkey?.toString();
602
- if (!address) return;
603
- if (info.change > 0) {
604
- balanceChanges.forEach((senderInfo, senderIndex) => {
605
- if (senderInfo.mint === info.mint && senderInfo.change < 0 && senderIndex !== accountIndex && senderIndex < accountKeys.length) {
606
- const sender = accountKeys[senderIndex];
607
- if (!sender) return;
608
- const senderAddress = typeof sender === "string" ? sender : sender.pubkey?.toString();
609
- if (!senderAddress) return;
610
- transfers.push({
611
- from: senderAddress,
612
- to: address,
613
- amount: info.change,
614
- token: info.mint,
615
- signature,
616
- blockTime: tx.blockTime
617
- });
618
- }
619
- });
620
- }
621
- });
622
- return transfers;
623
- }
624
- function categorizeInstruction(instruction) {
625
- const programId = instruction.programId.toString();
626
- if (programId === PROGRAM_IDS.SYSTEM) {
627
- if ("parsed" in instruction && instruction.parsed.type) {
628
- const type = instruction.parsed.type;
629
- if (type === "transfer" || type === "transferWithSeed") {
630
- return "transfer";
631
- }
632
- }
633
- return "transfer";
634
- }
635
- if (programId === PROGRAM_IDS.TOKEN || programId === PROGRAM_IDS.ASSOCIATED_TOKEN) {
636
- if ("parsed" in instruction && instruction.parsed.type) {
637
- const type = instruction.parsed.type;
638
- if (type === "transfer" || type === "transferChecked") {
639
- return "transfer";
640
- }
641
- return "token_operation";
642
- }
643
- return "token_operation";
644
- }
645
- if (programId === PROGRAM_IDS.STAKE) {
646
- return "stake";
647
- }
648
- if (programId === PROGRAM_IDS.VOTE) {
649
- return "vote";
650
- }
651
- if (programId.includes("Swap") || programId.includes("swap") || programId === "JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4" || // Jupiter
652
- programId === "whirLbMiicVdio4qvUfM5KAg6Ct8VwpYzGff3uctyCc") {
653
- return "swap";
654
- }
655
- return "program_interaction";
656
- }
657
- function extractInstructions(tx, signature) {
658
- const instructions = [];
659
- if (!tx.transaction || !tx.transaction.message) {
660
- return instructions;
661
- }
662
- const message = tx.transaction.message;
663
- const allInstructions = message.instructions;
664
- if (!allInstructions || !Array.isArray(allInstructions)) {
665
- return instructions;
666
- }
667
- for (const instruction of allInstructions) {
668
- if (!instruction || !instruction.programId) {
669
- continue;
670
- }
671
- const programId = instruction.programId.toString();
672
- const category = categorizeInstruction(instruction);
673
- let data;
674
- if ("parsed" in instruction) {
675
- data = instruction.parsed;
676
- }
677
- const accounts = [];
678
- if ("accounts" in instruction && Array.isArray(instruction.accounts)) {
679
- for (const acc of instruction.accounts) {
680
- const address = typeof acc === "string" ? acc : acc.toString();
681
- accounts.push(address);
682
- }
683
- }
684
- instructions.push({
685
- programId,
686
- category,
687
- signature,
688
- blockTime: tx.blockTime,
689
- data,
690
- accounts: accounts.length > 0 ? accounts : void 0
691
- });
692
- }
693
- return instructions;
694
- }
695
- function extractCounterparties(transfers, targetAddress) {
696
- const counterparties = /* @__PURE__ */ new Set();
697
- for (const transfer of transfers) {
698
- if (transfer.from === targetAddress) {
699
- counterparties.add(transfer.to);
700
- } else if (transfer.to === targetAddress) {
701
- counterparties.add(transfer.from);
702
- }
703
- }
704
- return counterparties;
705
- }
706
- function calculateTimeRange(transactions) {
707
- let earliest = null;
708
- let latest = null;
709
- for (const tx of transactions) {
710
- if (tx.blockTime) {
711
- if (earliest === null || tx.blockTime < earliest) {
712
- earliest = tx.blockTime;
713
- }
714
- if (latest === null || tx.blockTime > latest) {
715
- latest = tx.blockTime;
716
- }
717
- }
718
- }
719
- return { earliest, latest };
720
- }
721
- function normalizeWalletData(rawData, labelProvider) {
722
- const allTransfers = [];
723
- const allInstructions = [];
724
- const allTransactionMetadata = [];
725
- const allTokenAccountEvents = [];
726
- const allPDAInteractions = [];
727
- const transactions = rawData.transactions || [];
728
- for (const rawTx of transactions) {
729
- if (!rawTx.transaction) continue;
730
- try {
731
- const solTransfers = extractSOLTransfers(rawTx.transaction, rawTx.signature);
732
- const splTransfers = extractSPLTransfers(rawTx.transaction, rawTx.signature);
733
- allTransfers.push(...solTransfers, ...splTransfers);
734
- const instructions = extractInstructions(rawTx.transaction, rawTx.signature);
735
- allInstructions.push(...instructions);
736
- const metadata = extractTransactionMetadata(rawTx.transaction, rawTx.signature);
737
- allTransactionMetadata.push(metadata);
738
- const tokenEvents = extractTokenAccountEvents(rawTx.transaction, rawTx.signature);
739
- allTokenAccountEvents.push(...tokenEvents);
740
- const pdaInteractions = extractPDAInteractions(rawTx.transaction, rawTx.signature);
741
- allPDAInteractions.push(...pdaInteractions);
742
- } catch (error) {
743
- console.warn(`Failed to normalize transaction ${rawTx.signature}:`, error);
744
- continue;
745
- }
746
- }
747
- const counterparties = extractCounterparties(allTransfers, rawData.address);
748
- const labels = labelProvider ? labelProvider.lookupMany(Array.from(counterparties)) : /* @__PURE__ */ new Map();
749
- const timeRange = calculateTimeRange(transactions);
750
- const tokenAccounts = rawData.tokenAccounts.map((ta) => {
751
- try {
752
- return {
753
- mint: ta.account.data.parsed.info.mint,
754
- address: ta.pubkey.toString(),
755
- balance: ta.account.data.parsed.info.tokenAmount.uiAmount ?? 0
756
- };
757
- } catch (error) {
758
- return null;
759
- }
760
- }).filter((ta) => ta !== null);
761
- const feePayers = /* @__PURE__ */ new Set();
762
- const signers = /* @__PURE__ */ new Set();
763
- const programs = /* @__PURE__ */ new Set();
764
- for (const metadata of allTransactionMetadata) {
765
- feePayers.add(metadata.feePayer);
766
- for (const signer of metadata.signers) {
767
- signers.add(signer);
768
- }
769
- }
770
- for (const instruction of allInstructions) {
771
- programs.add(instruction.programId);
772
- }
773
- return {
774
- target: rawData.address,
775
- targetType: "wallet",
776
- transfers: allTransfers,
777
- instructions: allInstructions,
778
- counterparties,
779
- labels,
780
- tokenAccounts,
781
- timeRange,
782
- transactionCount: transactions.length,
783
- // Solana-specific fields
784
- transactions: allTransactionMetadata,
785
- tokenAccountEvents: allTokenAccountEvents,
786
- pdaInteractions: allPDAInteractions,
787
- feePayers,
788
- signers,
789
- programs
790
- };
791
- }
792
- function normalizeTransactionData(rawData, labelProvider) {
793
- const allTransfers = [];
794
- const allInstructions = [];
795
- const counterparties = /* @__PURE__ */ new Set();
796
- const allTransactionMetadata = [];
797
- const allTokenAccountEvents = [];
798
- const allPDAInteractions = [];
799
- const feePayers = /* @__PURE__ */ new Set();
800
- const signers = /* @__PURE__ */ new Set();
801
- const programs = /* @__PURE__ */ new Set();
802
- if (rawData.transaction) {
803
- try {
804
- const solTransfers = extractSOLTransfers(rawData.transaction, rawData.signature);
805
- const splTransfers = extractSPLTransfers(rawData.transaction, rawData.signature);
806
- allTransfers.push(...solTransfers, ...splTransfers);
807
- const instructions = extractInstructions(rawData.transaction, rawData.signature);
808
- allInstructions.push(...instructions);
809
- const metadata = extractTransactionMetadata(rawData.transaction, rawData.signature);
810
- allTransactionMetadata.push(metadata);
811
- feePayers.add(metadata.feePayer);
812
- for (const signer of metadata.signers) {
813
- signers.add(signer);
814
- }
815
- const tokenEvents = extractTokenAccountEvents(rawData.transaction, rawData.signature);
816
- allTokenAccountEvents.push(...tokenEvents);
817
- const pdaInteractions = extractPDAInteractions(rawData.transaction, rawData.signature);
818
- allPDAInteractions.push(...pdaInteractions);
819
- const accountKeys = rawData.transaction.transaction.message.accountKeys;
820
- if (accountKeys && Array.isArray(accountKeys)) {
821
- for (const key of accountKeys) {
822
- const address = typeof key === "string" ? key : key.pubkey.toString();
823
- counterparties.add(address);
824
- }
825
- }
826
- for (const instruction of instructions) {
827
- programs.add(instruction.programId);
828
- }
829
- } catch (error) {
830
- console.warn(`Failed to normalize transaction ${rawData.signature}:`, error);
831
- }
832
- }
833
- const labels = labelProvider ? labelProvider.lookupMany(Array.from(counterparties)) : /* @__PURE__ */ new Map();
834
- return {
835
- target: rawData.signature,
836
- targetType: "transaction",
837
- transfers: allTransfers,
838
- instructions: allInstructions,
839
- counterparties,
840
- labels,
841
- tokenAccounts: [],
842
- timeRange: {
843
- earliest: rawData.transaction ? rawData.blockTime : null,
844
- latest: rawData.transaction ? rawData.blockTime : null
845
- },
846
- transactionCount: rawData.transaction ? 1 : 0,
847
- // Solana-specific fields
848
- transactions: allTransactionMetadata,
849
- tokenAccountEvents: allTokenAccountEvents,
850
- pdaInteractions: allPDAInteractions,
851
- feePayers,
852
- signers,
853
- programs
854
- };
855
- }
856
- function normalizeProgramData(rawData, labelProvider) {
857
- const allTransfers = [];
858
- const allInstructions = [];
859
- const counterparties = /* @__PURE__ */ new Set();
860
- const allTransactionMetadata = [];
861
- const allTokenAccountEvents = [];
862
- const allPDAInteractions = [];
863
- const feePayers = /* @__PURE__ */ new Set();
864
- const signers = /* @__PURE__ */ new Set();
865
- const programs = /* @__PURE__ */ new Set();
866
- const transactions = rawData.relatedTransactions || [];
867
- for (const rawTx of transactions) {
868
- if (!rawTx.transaction) continue;
869
- try {
870
- const solTransfers = extractSOLTransfers(rawTx.transaction, rawTx.signature);
871
- const splTransfers = extractSPLTransfers(rawTx.transaction, rawTx.signature);
872
- allTransfers.push(...solTransfers, ...splTransfers);
873
- const instructions = extractInstructions(rawTx.transaction, rawTx.signature);
874
- allInstructions.push(...instructions);
875
- const metadata = extractTransactionMetadata(rawTx.transaction, rawTx.signature);
876
- allTransactionMetadata.push(metadata);
877
- feePayers.add(metadata.feePayer);
878
- for (const signer of metadata.signers) {
879
- signers.add(signer);
880
- }
881
- const tokenEvents = extractTokenAccountEvents(rawTx.transaction, rawTx.signature);
882
- allTokenAccountEvents.push(...tokenEvents);
883
- const pdaInteractions = extractPDAInteractions(rawTx.transaction, rawTx.signature);
884
- allPDAInteractions.push(...pdaInteractions);
885
- const accountKeys = rawTx.transaction.transaction.message.accountKeys;
886
- if (accountKeys && Array.isArray(accountKeys)) {
887
- for (const key of accountKeys) {
888
- const address = typeof key === "string" ? key : key.pubkey.toString();
889
- counterparties.add(address);
890
- }
891
- }
892
- for (const instruction of instructions) {
893
- programs.add(instruction.programId);
894
- }
895
- } catch (error) {
896
- console.warn(`Failed to normalize program transaction ${rawTx.signature}:`, error);
897
- continue;
898
- }
899
- }
900
- const timeRange = calculateTimeRange(transactions);
901
- const labels = labelProvider ? labelProvider.lookupMany(Array.from(counterparties)) : /* @__PURE__ */ new Map();
902
- return {
903
- target: rawData.programId,
904
- targetType: "program",
905
- transfers: allTransfers,
906
- instructions: allInstructions,
907
- counterparties,
908
- labels,
909
- tokenAccounts: [],
910
- timeRange,
911
- transactionCount: transactions.length,
912
- // Solana-specific fields
913
- transactions: allTransactionMetadata,
914
- tokenAccountEvents: allTokenAccountEvents,
915
- pdaInteractions: allPDAInteractions,
916
- feePayers,
917
- signers,
918
- programs
919
- };
920
- }
921
- function detectCounterpartyReuse(context) {
922
- const signals = [];
923
- if (context.targetType !== "wallet" || context.transactionCount < 2) {
924
- return signals;
925
- }
926
- if (context.transfers.length > 0) {
927
- const interactionCounts = /* @__PURE__ */ new Map();
928
- for (const transfer of context.transfers) {
929
- const counterparty = transfer.from === context.target ? transfer.to : transfer.from;
930
- if (counterparty === context.target) continue;
931
- interactionCounts.set(counterparty, (interactionCounts.get(counterparty) || 0) + 1);
932
- }
933
- const reusedCounterparties = Array.from(interactionCounts.entries()).filter(([_, count]) => count >= 3).sort((a, b) => b[1] - a[1]);
934
- if (reusedCounterparties.length > 0) {
935
- const totalInteractions = context.transfers.length;
936
- const topCounterpartyInteractions = reusedCounterparties[0][1];
937
- const concentration = topCounterpartyInteractions / totalInteractions;
938
- let severity = "LOW";
939
- if (concentration > 0.5 || reusedCounterparties.length >= 5) {
940
- severity = "HIGH";
941
- } else if (concentration > 0.3 || reusedCounterparties.length >= 3) {
942
- severity = "MEDIUM";
943
- }
944
- const evidence = reusedCounterparties.slice(0, 5).map(([addr, count]) => {
945
- const label = context.labels.get(addr);
946
- return {
947
- description: `${count} transfers with ${addr.slice(0, 8)}...${addr.slice(-8)}${label ? ` (${label.name})` : ""}`,
948
- severity: count > totalInteractions * 0.3 ? "HIGH" : count > totalInteractions * 0.15 ? "MEDIUM" : "LOW",
949
- type: "address",
950
- data: { address: addr, interactionCount: count }
951
- };
952
- });
953
- signals.push({
954
- id: "counterparty-reuse",
955
- name: "Repeated Transfer Counterparties",
956
- severity,
957
- category: "linkability",
958
- reason: `Wallet repeatedly transfers with ${reusedCounterparties.length} address(es). Top counterparty: ${topCounterpartyInteractions}/${totalInteractions} transfers.`,
959
- impact: "Repeated interactions with the same addresses can be used to cluster wallets and build transaction graphs.",
960
- evidence,
961
- mitigation: "Use different wallets for different counterparties, or use privacy-preserving protocols."
962
- });
963
- }
964
- }
965
- if (context.programs && context.programs.size > 0) {
966
- const programUsage = /* @__PURE__ */ new Map();
967
- for (const instruction of context.instructions) {
968
- programUsage.set(instruction.programId, (programUsage.get(instruction.programId) || 0) + 1);
969
- }
970
- const SYSTEM_PROGRAMS = [
971
- "11111111111111111111111111111111",
972
- "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA",
973
- "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL",
974
- "ComputeBudget111111111111111111111111111111"
975
- ];
976
- 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]);
977
- if (significantPrograms.length >= 2) {
978
- const evidence = significantPrograms.slice(0, 5).map(([programId, count]) => {
979
- const label = context.labels.get(programId);
980
- return {
981
- description: `${programId.slice(0, 8)}...${label ? ` (${label.name})` : ""} used in ${count} instruction(s)`,
982
- severity: "LOW",
983
- reference: `https://solscan.io/account/${programId}`
984
- };
985
- });
986
- signals.push({
987
- id: "program-reuse",
988
- name: "Repeated Program Interactions",
989
- severity: "LOW",
990
- category: "behavioral",
991
- reason: `Wallet interacts with ${significantPrograms.length} non-system program(s) repeatedly.`,
992
- impact: "Program usage patterns create a behavioral fingerprint. Addresses with similar patterns are likely related.",
993
- mitigation: "This is generally unavoidable when using DeFi. Diversifying protocols can reduce fingerprinting.",
994
- evidence
995
- });
996
- }
997
- }
998
- if (context.pdaInteractions && context.pdaInteractions.length > 0) {
999
- const pdaUsage = /* @__PURE__ */ new Map();
1000
- for (const pda of context.pdaInteractions) {
1001
- if (!pdaUsage.has(pda.pda)) {
1002
- pdaUsage.set(pda.pda, { count: 0, programId: pda.programId });
1003
- }
1004
- pdaUsage.get(pda.pda).count++;
1005
- }
1006
- const repeatedPDAs = Array.from(pdaUsage.entries()).filter(([_, { count }]) => count >= 2).sort((a, b) => b[1].count - a[1].count);
1007
- if (repeatedPDAs.length > 0) {
1008
- const evidence = repeatedPDAs.slice(0, 5).map(([pda, { count, programId }]) => ({
1009
- description: `PDA ${pda.slice(0, 8)}... (program: ${programId.slice(0, 8)}...) used ${count} times`,
1010
- severity: count > 3 ? "MEDIUM" : "LOW",
1011
- reference: `https://solscan.io/account/${pda}`
1012
- }));
1013
- const maxCount = repeatedPDAs[0][1].count;
1014
- const severity = maxCount > 5 ? "MEDIUM" : "LOW";
1015
- signals.push({
1016
- id: "pda-reuse",
1017
- name: "Repeated PDA Interactions",
1018
- severity,
1019
- category: "linkability",
1020
- reason: `${repeatedPDAs.length} Program-Derived Address(es) are used repeatedly. Max usage: ${maxCount} times.`,
1021
- impact: "PDAs often represent user-specific accounts (e.g., your position in a protocol). Repeated usage links all interactions.",
1022
- mitigation: "Some PDA reuse is inherent to Solana protocols. For sensitive operations, use fresh wallets.",
1023
- evidence
1024
- });
1025
- }
1026
- }
1027
- if (context.transfers.length > 0 && context.instructions.length > 0) {
1028
- const combos = /* @__PURE__ */ new Map();
1029
- for (const transfer of context.transfers) {
1030
- const counterparty = transfer.from === context.target ? transfer.to : transfer.from;
1031
- if (counterparty === context.target) continue;
1032
- const txInstructions = context.instructions.filter((inst) => inst.signature === transfer.signature);
1033
- for (const inst of txInstructions) {
1034
- const combo = `${counterparty}:${inst.programId}`;
1035
- combos.set(combo, (combos.get(combo) || 0) + 1);
1036
- }
1037
- }
1038
- const repeatedCombos = Array.from(combos.entries()).filter(([_, count]) => count >= 2).sort((a, b) => b[1] - a[1]);
1039
- if (repeatedCombos.length > 0) {
1040
- const evidence = repeatedCombos.slice(0, 3).map(([combo, count]) => {
1041
- const [counterparty, programId] = combo.split(":");
1042
- const label = context.labels.get(counterparty);
1043
- return {
1044
- description: `${counterparty.slice(0, 8)}...${label ? ` (${label.name})` : ""} + program ${programId.slice(0, 8)}... used ${count} times`,
1045
- severity: "MEDIUM"
1046
- };
1047
- });
1048
- signals.push({
1049
- id: "counterparty-program-combo",
1050
- name: "Repeated Counterparty-Program Combination",
1051
- severity: "MEDIUM",
1052
- category: "linkability",
1053
- reason: `${repeatedCombos.length} specific counterparty-program combination(s) are reused.`,
1054
- impact: "This creates a very specific fingerprint. The combination of WHO you interact with and WHAT program is highly identifying.",
1055
- mitigation: "Rotate both counterparties and programs if privacy is critical.",
1056
- evidence
1057
- });
1058
- }
1059
- }
1060
- return signals;
1061
- }
1062
- function detectAmountReuse(context) {
1063
- const signals = [];
1064
- if (context.transfers.length < 5) {
1065
- return signals;
1066
- }
1067
- const amountCounts = /* @__PURE__ */ new Map();
1068
- const roundNumbers = [];
1069
- for (const transfer of context.transfers) {
1070
- if (transfer.amount > 0 && Number.isInteger(transfer.amount) && transfer.amount >= 1) {
1071
- roundNumbers.push(transfer.amount);
1072
- }
1073
- const amountKey = `${transfer.amount.toFixed(9)}-${transfer.token || "SOL"}`;
1074
- if (!amountCounts.has(amountKey)) {
1075
- amountCounts.set(amountKey, { count: 0, counterparties: /* @__PURE__ */ new Set(), signers: /* @__PURE__ */ new Set() });
1076
- }
1077
- const data = amountCounts.get(amountKey);
1078
- data.count++;
1079
- const counterparty = transfer.from === context.target ? transfer.to : transfer.from;
1080
- if (counterparty !== context.target) {
1081
- data.counterparties.add(counterparty);
1082
- }
1083
- const tx = context.transactions ? context.transactions.find((t) => t.signature === transfer.signature) : null;
1084
- if (tx) {
1085
- tx.signers.forEach((s) => data.signers.add(s));
1086
- }
1087
- }
1088
- const reusedAmounts = Array.from(amountCounts.entries()).filter(([_, data]) => data.count >= 3).sort((a, b) => b[1].count - a[1].count);
1089
- const hasRoundNumbers = roundNumbers.length >= 3;
1090
- const hasReusedAmounts = reusedAmounts.length >= 2;
1091
- if (hasRoundNumbers && roundNumbers.length >= 5) {
1092
- signals.push({
1093
- id: "amount-round-numbers",
1094
- name: "Frequent Round Number Transfers",
1095
- severity: "LOW",
1096
- category: "behavioral",
1097
- reason: `${roundNumbers.length} round-number transfers detected (e.g., 1 SOL, 10 SOL).`,
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.",
1100
- evidence: [{
1101
- description: `${roundNumbers.length} round-number transfers: ${roundNumbers.slice(0, 5).join(", ")}...`,
1102
- severity: "LOW",
1103
- type: "amount",
1104
- data: { roundNumbers: roundNumbers.slice(0, 5) }
1105
- }]
1106
- });
1107
- }
1108
- const suspiciousReuse = reusedAmounts.filter(([_, data]) => {
1109
- return data.counterparties.size === 1 && data.count >= 3;
1110
- });
1111
- if (suspiciousReuse.length > 0) {
1112
- const evidence = suspiciousReuse.slice(0, 3).map(([amountKey, data]) => {
1113
- const [amount, token] = amountKey.split("-");
1114
- const counterparty = Array.from(data.counterparties)[0];
1115
- return {
1116
- description: `${amount} ${token} sent to ${counterparty.slice(0, 8)}... ${data.count} times`,
1117
- severity: "MEDIUM",
1118
- type: "amount",
1119
- data: { amount: parseFloat(amount), token, count: data.count, counterparty }
1120
- };
1121
- });
1122
- signals.push({
1123
- id: "amount-reuse-counterparty",
1124
- name: "Same Amount to Same Counterparty",
1125
- severity: "MEDIUM",
1126
- category: "behavioral",
1127
- reason: `${suspiciousReuse.length} amount(s) repeatedly sent to the same counterparty.`,
1128
- impact: "Sending the same amount to the same address multiple times creates a strong pattern. This is likely automated or habitual behavior.",
1129
- mitigation: "Vary amounts when sending to the same address, or use privacy protocols.",
1130
- evidence
1131
- });
1132
- }
1133
- const signerReuse = reusedAmounts.filter(([_, data]) => {
1134
- return data.signers.size <= 2 && data.count >= 3;
1135
- });
1136
- if (signerReuse.length > 0 && suspiciousReuse.length === 0) {
1137
- const evidence = signerReuse.slice(0, 3).map(([amountKey, data]) => {
1138
- const [amount, token] = amountKey.split("-");
1139
- return {
1140
- description: `${amount} ${token} used ${data.count} times with ${data.signers.size} signer(s)`,
1141
- severity: "LOW",
1142
- type: "amount",
1143
- data: { amount: parseFloat(amount), token, count: data.count }
1144
- };
1145
- });
1146
- signals.push({
1147
- id: "amount-reuse-pattern",
1148
- name: "Repeated Amount Pattern",
1149
- severity: "LOW",
1150
- category: "behavioral",
1151
- reason: `${signerReuse.length} amount(s) are reused multiple times with consistent signers.`,
1152
- impact: "Amount reuse alone is relatively weak, but combined with other signals it contributes to behavioral fingerprinting.",
1153
- mitigation: "Vary transaction amounts to reduce pattern visibility.",
1154
- evidence
1155
- });
1156
- }
1157
- const veryReused = reusedAmounts.filter(([_, data]) => data.count >= 5);
1158
- if (veryReused.length > 0 && suspiciousReuse.length === 0 && signerReuse.length === 0) {
1159
- const evidence = veryReused.slice(0, 3).map(([amountKey, data]) => {
1160
- const [amount, token] = amountKey.split("-");
1161
- return {
1162
- description: `${amount} ${token} used ${data.count} times across ${data.counterparties.size} counterparties`,
1163
- severity: data.count > 10 ? "MEDIUM" : "LOW",
1164
- type: "amount",
1165
- data: { amount: parseFloat(amount), token, count: data.count }
1166
- };
1167
- });
1168
- const maxCount = veryReused[0][1].count;
1169
- const severity = maxCount > 10 ? "MEDIUM" : "LOW";
1170
- signals.push({
1171
- id: "amount-reuse-frequency",
1172
- name: "High-Frequency Amount Reuse",
1173
- severity,
1174
- category: "behavioral",
1175
- reason: `${veryReused.length} amount(s) are used very frequently (${maxCount} times for top amount).`,
1176
- impact: "Extremely frequent reuse of specific amounts suggests automation or habitual behavior, creating a detectable pattern.",
1177
- mitigation: "If running automated systems, add randomization to amounts.",
1178
- evidence
1179
- });
1180
- }
1181
- return signals;
1182
- }
1183
- function detectTimingPatterns(context) {
1184
- const signals = [];
1185
- if (!context.timeRange.earliest || !context.timeRange.latest) {
1186
- return signals;
1187
- }
1188
- if (context.transactionCount < 3) {
1189
- return signals;
1190
- }
1191
- const timeSpanSeconds = context.timeRange.latest - context.timeRange.earliest;
1192
- const timeSpanHours = timeSpanSeconds / 3600;
1193
- if (timeSpanHours === 0) {
1194
- return signals;
1195
- }
1196
- const txRate = context.transactionCount / timeSpanHours;
1197
- let isBurst = false;
1198
- let burstSeverity = "LOW";
1199
- if (txRate > 10) {
1200
- burstSeverity = "HIGH";
1201
- isBurst = true;
1202
- } else if (txRate > 5) {
1203
- burstSeverity = "MEDIUM";
1204
- isBurst = true;
1205
- } else if (timeSpanHours < 1 && context.transactionCount >= 3) {
1206
- burstSeverity = "MEDIUM";
1207
- isBurst = true;
1208
- }
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
- });
1225
- }
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
- });
1263
- }
1264
- }
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;
1297
- }
1298
- function detectKnownEntityInteraction(context) {
1299
- const signals = [];
1300
- if (context.labels.size === 0) {
1301
- return signals;
1302
- }
1303
- const entityTypeGroups = /* @__PURE__ */ new Map();
1304
- for (const [address, label] of context.labels.entries()) {
1305
- let interactionCount = 0;
1306
- const relatedTxs = [];
1307
- for (const transfer of context.transfers) {
1308
- if (transfer.from === address || transfer.to === address) {
1309
- interactionCount++;
1310
- if (relatedTxs.length < 3) {
1311
- relatedTxs.push(transfer.signature);
1312
- }
1313
- }
1314
- }
1315
- if (interactionCount > 0) {
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
1324
- });
1325
- }
1326
- }
1327
- if (entityTypeGroups.size === 0) {
1328
- return signals;
1329
- }
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
- });
1349
- }
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;
1417
- }
1418
- function detectBalanceTraceability(context) {
1419
- const signals = [];
1420
- if (context.targetType !== "wallet" || context.transfers.length < 2) {
1421
- return signals;
1422
- }
1423
- const amountPairs = /* @__PURE__ */ new Map();
1424
- for (const transfer of context.transfers) {
1425
- const amountKey = transfer.amount.toFixed(6);
1426
- amountPairs.set(amountKey, (amountPairs.get(amountKey) || 0) + 1);
1427
- }
1428
- const matchingPairs = Array.from(amountPairs.entries()).filter(([_, count]) => count >= 2).sort((a, b) => b[1] - a[1]);
1429
- if (matchingPairs.length >= 2) {
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
- });
1447
- }
1448
- const sequentialPairs = [];
1449
- for (let i = 0; i < context.transfers.length - 1; i++) {
1450
- const current = context.transfers[i];
1451
- const next = context.transfers[i + 1];
1452
- if (current.blockTime && next.blockTime) {
1453
- const timeDiff = Math.abs(next.blockTime - current.blockTime);
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
- });
1463
- }
1464
- }
1465
- }
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
1482
- });
1483
- }
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
- }]
1509
- });
1510
- }
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
- }
1549
- }
1550
- return signals;
1551
- }
1552
- function detectFeePayerReuse(context) {
1553
- const signals = [];
1554
- if (context.targetType === "transaction") {
1555
- return signals;
1556
- }
1557
- if (!context.feePayers || !context.transactions || context.transactions.length === 0) {
1558
- return signals;
1559
- }
1560
- const feePayers = context.feePayers;
1561
- const target = context.target;
1562
- const targetIsFeePayer = feePayers.has(target);
1563
- const onlyTargetPays = feePayers.size === 1 && targetIsFeePayer;
1564
- if (onlyTargetPays) {
1565
- return signals;
1566
- }
1567
- if (feePayers.size > 1 && targetIsFeePayer) {
1568
- const externalFeePayers = Array.from(feePayers).filter((fp) => fp !== target);
1569
- const feePayerCounts = /* @__PURE__ */ new Map();
1570
- for (const tx of context.transactions) {
1571
- if (tx.feePayer !== target) {
1572
- feePayerCounts.set(tx.feePayer, (feePayerCounts.get(tx.feePayer) || 0) + 1);
1573
- }
1574
- }
1575
- const evidence = [];
1576
- for (const [feePayer, count] of feePayerCounts) {
1577
- evidence.push({
1578
- description: `${feePayer} paid fees for ${count} transaction(s)`,
1579
- severity: count > 1 ? "HIGH" : "MEDIUM",
1580
- reference: void 0
1581
- });
1582
- }
1583
- const knownFeePayerLabel = externalFeePayers.find((fp) => context.labels.has(fp));
1584
- const knownLabel = knownFeePayerLabel ? context.labels.get(knownFeePayerLabel) : null;
1585
- signals.push({
1586
- id: "fee-payer-external",
1587
- name: "External Fee Payer Detected",
1588
- severity: knownLabel ? "HIGH" : "MEDIUM",
1589
- category: "linkability",
1590
- reason: `${externalFeePayers.length} external wallet(s) paid fees for transactions involving this address${knownLabel ? `, including known entity: ${knownLabel.name}` : ""}.`,
1591
- 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.",
1592
- 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.",
1593
- evidence
1594
- });
1595
- }
1596
- if (!targetIsFeePayer && feePayers.size > 0) {
1597
- const allFeePayers = Array.from(feePayers);
1598
- const feePayerCounts = /* @__PURE__ */ new Map();
1599
- for (const tx of context.transactions) {
1600
- feePayerCounts.set(tx.feePayer, (feePayerCounts.get(tx.feePayer) || 0) + 1);
1601
- }
1602
- const evidence = [];
1603
- for (const [feePayer, count] of feePayerCounts) {
1604
- const label = context.labels.get(feePayer);
1605
- evidence.push({
1606
- description: `${feePayer}${label ? ` (${label.name})` : ""} paid fees for ${count} transaction(s)`,
1607
- severity: "HIGH",
1608
- reference: void 0
1609
- });
1610
- }
1611
- const maxCount = Math.max(...Array.from(feePayerCounts.values()));
1612
- const repeatedFeePayer = maxCount > 1;
1613
- signals.push({
1614
- id: "fee-payer-never-self",
1615
- name: "Never Self-Pays Transaction Fees",
1616
- severity: repeatedFeePayer ? "HIGH" : "HIGH",
1617
- // Always HIGH - this is critical
1618
- category: "linkability",
1619
- reason: `This address has NEVER paid its own transaction fees. All ${context.transactionCount} transaction(s) were paid by ${allFeePayers.length} external wallet(s).`,
1620
- 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.",
1621
- 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).",
1622
- evidence
1623
- });
1624
- }
1625
- if (context.targetType === "program") {
1626
- const feePayerCounts = /* @__PURE__ */ new Map();
1627
- for (const tx of context.transactions) {
1628
- if (!feePayerCounts.has(tx.feePayer)) {
1629
- feePayerCounts.set(tx.feePayer, /* @__PURE__ */ new Set());
1630
- }
1631
- for (const signer of tx.signers) {
1632
- feePayerCounts.get(tx.feePayer).add(signer);
1633
- }
1634
- }
1635
- const multiFeePayerOperators = [];
1636
- for (const [feePayer, signers] of feePayerCounts) {
1637
- if (signers.size > 1) {
1638
- const txCount = context.transactions.filter((tx) => tx.feePayer === feePayer).length;
1639
- multiFeePayerOperators.push({
1640
- feePayer,
1641
- signerCount: signers.size,
1642
- txCount
1643
- });
1644
- }
1645
- }
1646
- if (multiFeePayerOperators.length > 0) {
1647
- const evidence = multiFeePayerOperators.map((op) => ({
1648
- description: `${op.feePayer} paid fees for ${op.txCount} transaction(s) involving ${op.signerCount} different signer(s)`,
1649
- severity: "HIGH",
1650
- reference: void 0
1651
- }));
1652
- signals.push({
1653
- id: "fee-payer-multi-signer",
1654
- name: "Fee Payer Controls Multiple Signers",
1655
- severity: "HIGH",
1656
- category: "linkability",
1657
- reason: `${multiFeePayerOperators.length} fee payer(s) are paying fees for multiple different signers, suggesting centralized control or bot operation.`,
1658
- impact: "All addresses funded by the same fee payer are linkable. This pattern exposes operational infrastructure.",
1659
- mitigation: "If running bots or managing multiple accounts, use a unique fee payer for each to avoid linking them on-chain.",
1660
- evidence
1661
- });
1662
- }
1663
- }
1664
- return signals;
1665
- }
1666
- function detectSignerOverlap(context) {
1667
- const signals = [];
1668
- if (context.transactionCount < 2) {
1669
- return signals;
1670
- }
1671
- if (!context.transactions || context.transactions.length === 0) {
1672
- return signals;
1673
- }
1674
- const signerFrequency = /* @__PURE__ */ new Map();
1675
- const signerTransactions = /* @__PURE__ */ new Map();
1676
- for (const tx of context.transactions) {
1677
- for (const signer of tx.signers) {
1678
- signerFrequency.set(signer, (signerFrequency.get(signer) || 0) + 1);
1679
- if (!signerTransactions.has(signer)) {
1680
- signerTransactions.set(signer, []);
1681
- }
1682
- signerTransactions.get(signer).push(tx.signature);
1683
- }
1684
- }
1685
- const target = context.target;
1686
- 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]);
1687
- if (frequentSigners.length > 0) {
1688
- const evidence = frequentSigners.map(([signer, count]) => {
1689
- const label = context.labels.get(signer);
1690
- return {
1691
- description: `${signer}${label ? ` (${label.name})` : ""} signed ${count}/${context.transactionCount} transactions`,
1692
- severity: count > context.transactionCount * 0.7 ? "HIGH" : "MEDIUM",
1693
- reference: void 0
1694
- };
1695
- });
1696
- const topSignerCount = frequentSigners[0][1];
1697
- const severity = topSignerCount > context.transactionCount * 0.7 ? "HIGH" : "MEDIUM";
1698
- signals.push({
1699
- id: "signer-repeated",
1700
- name: "Repeated Signer Across Transactions",
1701
- severity,
1702
- category: "linkability",
1703
- reason: `${frequentSigners.length} address(es) repeatedly sign transactions involving the target. The most frequent signer appears in ${topSignerCount}/${context.transactionCount} transactions.`,
1704
- impact: "Repeated signers create hard links between transactions. All transactions signed by the same address are trivially linkable.",
1705
- mitigation: "If you control multiple addresses that sign together, they are permanently linked. Use separate signing keys for unrelated activities.",
1706
- evidence
1707
- });
1708
- }
1709
- const signerSets = /* @__PURE__ */ new Map();
1710
- const signerSetExamples = /* @__PURE__ */ new Map();
1711
- for (const tx of context.transactions) {
1712
- const sortedSigners = [...tx.signers].sort();
1713
- const setKey = JSON.stringify(sortedSigners);
1714
- signerSets.set(setKey, (signerSets.get(setKey) || 0) + 1);
1715
- if (!signerSetExamples.has(setKey)) {
1716
- signerSetExamples.set(setKey, tx.signature);
1717
- }
1718
- }
1719
- const repeatedSets = Array.from(signerSets.entries()).filter(([_, count]) => count > 1).sort((a, b) => b[1] - a[1]);
1720
- if (repeatedSets.length > 0) {
1721
- const evidence = repeatedSets.map(([setKey, count]) => {
1722
- const signers = JSON.parse(setKey);
1723
- const exampleSig = signerSetExamples.get(setKey);
1724
- return {
1725
- description: `${count} transactions with identical signer set: [${signers.map((s) => s.slice(0, 8)).join(", ")}...]`,
1726
- severity: count > 2 ? "MEDIUM" : "LOW",
1727
- reference: `https://solscan.io/tx/${exampleSig}`
1728
- };
1729
- });
1730
- signals.push({
1731
- id: "signer-set-reuse",
1732
- name: "Repeated Multi-Signature Pattern",
1733
- severity: "MEDIUM",
1734
- category: "linkability",
1735
- reason: `${repeatedSets.length} distinct signer set(s) are reused multiple times. This creates a unique fingerprint.`,
1736
- impact: "Reused multi-sig patterns are highly unique and easily linkable. Even if addresses differ, the signer set pattern can identify related activity.",
1737
- mitigation: "If using multi-sig for multiple transactions, rotate signing keys or use threshold signatures to vary the signer set.",
1738
- evidence
1739
- });
1740
- }
1741
- if (context.targetType === "program" || context.transactionCount > 10) {
1742
- const signerCoSigners = /* @__PURE__ */ new Map();
1743
- for (const tx of context.transactions) {
1744
- for (const signer of tx.signers) {
1745
- if (!signerCoSigners.has(signer)) {
1746
- signerCoSigners.set(signer, /* @__PURE__ */ new Set());
1747
- }
1748
- for (const otherSigner of tx.signers) {
1749
- if (otherSigner !== signer) {
1750
- signerCoSigners.get(signer).add(otherSigner);
1751
- }
1752
- }
1753
- }
1754
- }
1755
- const authorityCandidates = Array.from(signerCoSigners.entries()).filter(([_, coSigners]) => coSigners.size >= 3).sort((a, b) => b[1].size - a[1].size);
1756
- if (authorityCandidates.length > 0) {
1757
- const evidence = authorityCandidates.slice(0, 3).map(([signer, coSigners]) => {
1758
- const label = context.labels.get(signer);
1759
- const txCount = signerFrequency.get(signer) || 0;
1760
- return {
1761
- description: `${signer}${label ? ` (${label.name})` : ""} co-signed with ${coSigners.size} different addresses across ${txCount} transactions`,
1762
- severity: "HIGH",
1763
- reference: void 0
1764
- };
1765
- });
1766
- signals.push({
1767
- id: "signer-authority-hub",
1768
- name: "Authority Signer Detected",
1769
- severity: "HIGH",
1770
- category: "linkability",
1771
- reason: `${authorityCandidates.length} address(es) act as an authority, co-signing with multiple different wallets. This exposes a control hub.`,
1772
- impact: "An authority signer links all accounts it co-signs with. This reveals organizational structure or bot infrastructure.",
1773
- mitigation: 'Use unique authority keys for each logical group of accounts. Avoid having a single "master" signer.',
1774
- evidence
1775
- });
1776
- }
1777
- }
1778
- return signals;
1779
- }
1780
- function detectInstructionFingerprinting(context) {
1781
- const signals = [];
1782
- if (context.transactionCount < 3) {
1783
- return signals;
1784
- }
1785
- if (!context.transactions || context.transactions.length === 0) {
1786
- return signals;
1787
- }
1788
- const sequenceFingerprints = /* @__PURE__ */ new Map();
1789
- const sequenceExamples = /* @__PURE__ */ new Map();
1790
- for (const tx of context.transactions) {
1791
- const txInstructions = context.instructions.filter((inst) => inst.signature === tx.signature).map((inst) => inst.programId);
1792
- if (txInstructions.length === 0) continue;
1793
- const sequence = txInstructions.join("->");
1794
- sequenceFingerprints.set(sequence, (sequenceFingerprints.get(sequence) || 0) + 1);
1795
- if (!sequenceExamples.has(sequence)) {
1796
- sequenceExamples.set(sequence, []);
1797
- }
1798
- sequenceExamples.get(sequence).push(tx.signature);
1799
- }
1800
- 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]);
1801
- if (repeatedSequences.length > 0) {
1802
- const evidence = repeatedSequences.slice(0, 5).map(([sequence, count]) => {
1803
- const exampleSigs = sequenceExamples.get(sequence).slice(0, 2);
1804
- const programs = sequence.split("->").map((p) => p.slice(0, 8) + "...").join(" \u2192 ");
1805
- return {
1806
- description: `Instruction sequence repeated ${count} times: ${programs}`,
1807
- severity: count > context.transactionCount * 0.5 ? "MEDIUM" : "LOW",
1808
- reference: `https://solscan.io/tx/${exampleSigs[0]}`
1809
- };
1810
- });
1811
- const topSequenceCount = repeatedSequences[0][1];
1812
- const severity = topSequenceCount > context.transactionCount * 0.5 ? "MEDIUM" : "LOW";
1813
- signals.push({
1814
- id: "instruction-sequence-pattern",
1815
- name: "Repeated Instruction Sequence Pattern",
1816
- severity,
1817
- category: "behavioral",
1818
- reason: `${repeatedSequences.length} distinct instruction sequence(s) are repeated multiple times. The most common pattern appears in ${topSequenceCount}/${context.transactionCount} transactions.`,
1819
- impact: "Repeated instruction patterns create a behavioral fingerprint. Even with different addresses, these patterns can link related activity.",
1820
- mitigation: "Vary the order or combination of operations. Add dummy instructions or randomize transaction structure where possible.",
1821
- evidence
1822
- });
1823
- }
1824
- const programUsage = /* @__PURE__ */ new Map();
1825
- for (const inst of context.instructions) {
1826
- programUsage.set(inst.programId, (programUsage.get(inst.programId) || 0) + 1);
1827
- }
1828
- const COMMON_PROGRAMS = [
1829
- "11111111111111111111111111111111",
1830
- // System
1831
- "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA",
1832
- // SPL Token
1833
- "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL",
1834
- // Associated Token
1835
- "MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr",
1836
- // Memo
1837
- "ComputeBudget111111111111111111111111111111"
1838
- // Compute Budget
1839
- ];
1840
- 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]);
1841
- if (uniquePrograms.length >= 2) {
1842
- const evidence = uniquePrograms.slice(0, 5).map(([programId, count]) => {
1843
- const label = context.labels.get(programId);
1844
- return {
1845
- description: `${programId.slice(0, 8)}...${label ? ` (${label.name})` : ""} used in ${count} transactions`,
1846
- severity: "LOW",
1847
- reference: `https://solscan.io/account/${programId}`
1848
- };
1849
- });
1850
- signals.push({
1851
- id: "program-usage-profile",
1852
- name: "Distinctive Program Usage Profile",
1853
- severity: "LOW",
1854
- category: "behavioral",
1855
- reason: `This address uses ${uniquePrograms.length} less-common programs repeatedly. This creates a unique usage profile.`,
1856
- impact: "Program usage patterns can fingerprint wallet behavior. Addresses with similar program usage profiles are likely related.",
1857
- mitigation: "Using niche protocols creates a fingerprint. This is difficult to mitigate without changing your DeFi strategy.",
1858
- evidence
1859
- });
1860
- }
1861
- if (context.pdaInteractions.length > 0) {
1862
- const pdaUsage = /* @__PURE__ */ new Map();
1863
- for (const pda of context.pdaInteractions) {
1864
- if (!pdaUsage.has(pda.pda)) {
1865
- pdaUsage.set(pda.pda, { count: 0, programId: pda.programId });
1866
- }
1867
- pdaUsage.get(pda.pda).count++;
1868
- }
1869
- const repeatedPDAs = Array.from(pdaUsage.entries()).filter(([_, { count }]) => count > 1).sort((a, b) => b[1].count - a[1].count);
1870
- if (repeatedPDAs.length > 0) {
1871
- const evidence = repeatedPDAs.slice(0, 5).map(([pda, { count, programId }]) => ({
1872
- description: `PDA ${pda.slice(0, 8)}... used ${count} times (program: ${programId.slice(0, 8)}...)`,
1873
- severity: count > 3 ? "MEDIUM" : "LOW",
1874
- reference: `https://solscan.io/account/${pda}`
1875
- }));
1876
- const maxPDAUsage = repeatedPDAs[0][1].count;
1877
- const severity = maxPDAUsage > 3 ? "MEDIUM" : "LOW";
1878
- signals.push({
1879
- id: "pda-reuse-pattern",
1880
- name: "Repeated PDA Interaction",
1881
- severity,
1882
- category: "behavioral",
1883
- reason: `${repeatedPDAs.length} Program-Derived Address(es) are used repeatedly. The most common PDA appears in ${maxPDAUsage} transactions.`,
1884
- impact: "Repeated PDA usage links transactions. If the PDA is specific to you (e.g., a user account), all interactions with it are linked.",
1885
- mitigation: "Some PDA reuse is unavoidable (e.g., your DEX pool position). For sensitive operations, consider using fresh accounts or different protocols.",
1886
- evidence
1887
- });
1888
- }
1889
- }
1890
- const programInstructions = /* @__PURE__ */ new Map();
1891
- for (const inst of context.instructions) {
1892
- if (!programInstructions.has(inst.programId)) {
1893
- programInstructions.set(inst.programId, []);
1894
- }
1895
- if (inst.data) {
1896
- programInstructions.get(inst.programId).push(inst.data);
1897
- }
1898
- }
1899
- for (const [programId, dataList] of programInstructions) {
1900
- if (dataList.length < 2) continue;
1901
- const typeMap = /* @__PURE__ */ new Map();
1902
- for (const data of dataList) {
1903
- if (data && typeof data === "object" && "type" in data) {
1904
- const type = String(data.type);
1905
- typeMap.set(type, (typeMap.get(type) || 0) + 1);
1906
- }
1907
- }
1908
- const repeatedTypes = Array.from(typeMap.entries()).filter(([_, count]) => count >= 2).sort((a, b) => b[1] - a[1]);
1909
- if (repeatedTypes.length > 0 && repeatedTypes[0][1] >= 3) {
1910
- const [instructionType, count] = repeatedTypes[0];
1911
- const label = context.labels.get(programId);
1912
- signals.push({
1913
- id: `instruction-type-${programId.slice(0, 8)}`,
1914
- name: "Repeated Instruction Type",
1915
- severity: "LOW",
1916
- category: "behavioral",
1917
- reason: `The instruction type "${instructionType}" on program ${programId.slice(0, 8)}...${label ? ` (${label.name})` : ""} is used ${count} times.`,
1918
- impact: "Repeated instruction types on the same program suggest automated behavior or specific strategy execution.",
1919
- mitigation: "This is generally low-risk but contributes to behavioral fingerprinting. Diversify your transaction types if possible.",
1920
- evidence: [{
1921
- description: `"${instructionType}" instruction used ${count} times`,
1922
- severity: "LOW",
1923
- reference: void 0
1924
- }]
1925
- });
1926
- }
1927
- }
1928
- return signals;
1929
- }
1930
- function detectTokenAccountLifecycle(context) {
1931
- const signals = [];
1932
- if (!context.tokenAccountEvents || context.tokenAccountEvents.length === 0) {
1933
- return signals;
1934
- }
1935
- const accountEvents = /* @__PURE__ */ new Map();
1936
- for (const event of context.tokenAccountEvents) {
1937
- if (!accountEvents.has(event.tokenAccount)) {
1938
- accountEvents.set(event.tokenAccount, []);
1939
- }
1940
- accountEvents.get(event.tokenAccount).push(event);
1941
- }
1942
- const createEvents = context.tokenAccountEvents.filter((e) => e.type === "create");
1943
- const closeEvents = context.tokenAccountEvents.filter((e) => e.type === "close");
1944
- if (createEvents.length >= 2 && closeEvents.length >= 2) {
1945
- const refundDestinations = /* @__PURE__ */ new Map();
1946
- const totalRefunded = closeEvents.reduce((sum, event) => {
1947
- if (event.rentRefund) {
1948
- refundDestinations.set(event.owner, (refundDestinations.get(event.owner) || 0) + event.rentRefund);
1949
- return sum + event.rentRefund;
1950
- }
1951
- return sum;
1952
- }, 0);
1953
- if (refundDestinations.size > 0) {
1954
- const evidence = Array.from(refundDestinations.entries()).map(([owner, amount]) => ({
1955
- description: `${amount.toFixed(4)} SOL refunded to ${owner.slice(0, 8)}... from ${closeEvents.filter((e) => e.owner === owner).length} closed account(s)`,
1956
- severity: "MEDIUM",
1957
- reference: void 0
1958
- }));
1959
- signals.push({
1960
- id: "token-account-churn",
1961
- name: "Frequent Token Account Creation/Closure",
1962
- severity: "MEDIUM",
1963
- category: "behavioral",
1964
- reason: `${createEvents.length} token account(s) created and ${closeEvents.length} closed. Rent refunds totaling ${totalRefunded.toFixed(4)} SOL expose ownership.`,
1965
- impact: 'Rent refunds link temporary token accounts back to the owner wallet. This pattern defeats the purpose of using "burner" accounts.',
1966
- 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.",
1967
- evidence
1968
- });
1969
- }
1970
- }
1971
- const completeLifecycles = [];
1972
- for (const [tokenAccount, events] of accountEvents) {
1973
- const creates = events.filter((e) => e.type === "create");
1974
- const closes = events.filter((e) => e.type === "close");
1975
- if (creates.length > 0 && closes.length > 0) {
1976
- const createTime = creates[0].blockTime;
1977
- const closeTime = closes[closes.length - 1].blockTime;
1978
- if (createTime && closeTime) {
1979
- const duration = closeTime - createTime;
1980
- completeLifecycles.push({ tokenAccount, events, duration });
1981
- }
1982
- }
1983
- }
1984
- const shortLived = completeLifecycles.filter((lc) => lc.duration < 3600);
1985
- if (shortLived.length >= 2) {
1986
- const evidence = shortLived.slice(0, 5).map((lc) => {
1987
- const durationMin = Math.floor(lc.duration / 60);
1988
- const closeEvent = lc.events.find((e) => e.type === "close");
1989
- return {
1990
- description: `${lc.tokenAccount.slice(0, 8)}... lived for ${durationMin} minute(s)${closeEvent?.rentRefund ? `, refunded ${closeEvent.rentRefund.toFixed(4)} SOL` : ""}`,
1991
- severity: "LOW",
1992
- reference: void 0
1993
- };
1994
- });
1995
- signals.push({
1996
- id: "token-account-short-lived",
1997
- name: "Short-Lived Token Accounts",
1998
- severity: "LOW",
1999
- category: "behavioral",
2000
- reason: `${shortLived.length} token account(s) were created and closed within an hour, suggesting burner account usage.`,
2001
- impact: "Short-lived accounts suggest privacy-conscious behavior, but rent refunds still create linkage.",
2002
- mitigation: "For true privacy, do not close accounts immediately. The rent refund links the burner back to you.",
2003
- evidence
2004
- });
2005
- }
2006
- const ownerAccounts = /* @__PURE__ */ new Map();
2007
- for (const event of context.tokenAccountEvents) {
2008
- if (event.type === "create") {
2009
- if (!ownerAccounts.has(event.owner)) {
2010
- ownerAccounts.set(event.owner, /* @__PURE__ */ new Set());
2011
- }
2012
- ownerAccounts.get(event.owner).add(event.tokenAccount);
2013
- }
2014
- }
2015
- const multiAccountOwners = Array.from(ownerAccounts.entries()).filter(([_, accounts]) => accounts.size >= 2).sort((a, b) => b[1].size - a[1].size);
2016
- if (multiAccountOwners.length > 0) {
2017
- const [owner, accounts] = multiAccountOwners[0];
2018
- const isTarget = owner === context.target;
2019
- if (!isTarget || multiAccountOwners.length > 1) {
2020
- const evidence = multiAccountOwners.slice(0, 3).map(([own, accs]) => {
2021
- const label = context.labels.get(own);
2022
- return {
2023
- description: `${own.slice(0, 8)}...${label ? ` (${label.name})` : ""} owns ${accs.size} token account(s)`,
2024
- severity: "LOW",
2025
- reference: void 0
2026
- };
2027
- });
2028
- signals.push({
2029
- id: "token-account-common-owner",
2030
- name: "Common Owner Across Token Accounts",
2031
- severity: "LOW",
2032
- category: "linkability",
2033
- reason: `${multiAccountOwners.length} wallet(s) control multiple token accounts. The top owner controls ${accounts.size} accounts.`,
2034
- impact: "All token accounts with the same owner are trivially linked.",
2035
- mitigation: "This is inherent to Solana's token account model and cannot be avoided.",
2036
- evidence
2037
- });
2038
- }
2039
- }
2040
- const rentRefundReceivers = /* @__PURE__ */ new Map();
2041
- for (const event of context.tokenAccountEvents) {
2042
- if (event.type === "close" && event.rentRefund) {
2043
- const current = rentRefundReceivers.get(event.owner) || { count: 0, total: 0 };
2044
- current.count++;
2045
- current.total += event.rentRefund;
2046
- rentRefundReceivers.set(event.owner, current);
2047
- }
2048
- }
2049
- const significantRefunds = Array.from(rentRefundReceivers.entries()).filter(([_, { count }]) => count >= 3).sort((a, b) => b[1].count - a[1].count);
2050
- if (significantRefunds.length > 0) {
2051
- const evidence = significantRefunds.slice(0, 3).map(([owner, { count, total }]) => ({
2052
- description: `${owner.slice(0, 8)}... received ${count} rent refunds totaling ${total.toFixed(4)} SOL`,
2053
- severity: "MEDIUM",
2054
- reference: void 0
2055
- }));
2056
- const [topOwner, topData] = significantRefunds[0];
2057
- signals.push({
2058
- id: "rent-refund-clustering",
2059
- name: "Rent Refund Clustering",
2060
- severity: "MEDIUM",
2061
- category: "linkability",
2062
- reason: `${significantRefunds.length} address(es) receive multiple rent refunds. ${topOwner.slice(0, 8)}... received ${topData.count} refunds.`,
2063
- impact: "Rent refunds link closed token accounts back to a central wallet. This exposes the control structure.",
2064
- mitigation: "Do not close token accounts if privacy is important. The small rent cost (~0.002 SOL) is cheaper than the privacy loss.",
2065
- evidence
2066
- });
2067
- }
2068
- return signals;
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
- }
2336
- var REPORT_VERSION = "1.0.0";
2337
- var HEURISTICS = [
2338
- // Solana-specific (highest priority)
2339
- detectFeePayerReuse,
2340
- detectSignerOverlap,
2341
- detectMemoExposure,
2342
- detectAddressReuse,
2343
- detectKnownEntityInteraction,
2344
- detectCounterpartyReuse,
2345
- detectInstructionFingerprinting,
2346
- detectTokenAccountLifecycle,
2347
- // Traditional heuristics
2348
- detectTimingPatterns,
2349
- detectAmountReuse,
2350
- detectBalanceTraceability
2351
- ];
2352
- function calculateOverallRisk(signals) {
2353
- if (signals.length === 0) {
2354
- return "LOW";
2355
- }
2356
- const highCount = signals.filter((s) => s.severity === "HIGH").length;
2357
- const mediumCount = signals.filter((s) => s.severity === "MEDIUM").length;
2358
- const lowCount = signals.filter((s) => s.severity === "LOW").length;
2359
- if (highCount >= 2 || highCount >= 1 && mediumCount >= 2) {
2360
- return "HIGH";
2361
- }
2362
- if (highCount >= 1 || mediumCount >= 2 || mediumCount >= 1 && lowCount >= 2) {
2363
- return "MEDIUM";
2364
- }
2365
- return "LOW";
2366
- }
2367
- function generateMitigations(signals) {
2368
- const mitigations = /* @__PURE__ */ new Set();
2369
- if (signals.length === 0) {
2370
- return ["Continue practicing good privacy hygiene to maintain low exposure."];
2371
- }
2372
- mitigations.add("Consider using multiple wallets to compartmentalize different activities.");
2373
- const signalIds = new Set(signals.map((s) => s.id));
2374
- if (signalIds.has("fee-payer-never-self") || signalIds.has("fee-payer-external")) {
2375
- mitigations.add("Always pay your own transaction fees to avoid linkage.");
2376
- }
2377
- if (signalIds.has("signer-repeated") || signalIds.has("signer-set-reuse")) {
2378
- mitigations.add("Use separate signing keys for unrelated activities.");
2379
- }
2380
- if (signalIds.has("instruction-sequence-pattern") || signalIds.has("program-usage-profile")) {
2381
- mitigations.add("Diversify transaction patterns and protocols to reduce behavioral fingerprinting.");
2382
- }
2383
- if (signalIds.has("token-account-churn") || signalIds.has("rent-refund-clustering")) {
2384
- mitigations.add("Avoid closing token accounts if privacy is important - the rent refund creates linkage.");
2385
- }
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")) {
2393
- mitigations.add("Avoid direct interactions between privacy-sensitive wallets and KYC services.");
2394
- }
2395
- if (signalIds.has("counterparty-reuse") || signalIds.has("pda-reuse")) {
2396
- mitigations.add("Use different addresses for different counterparties or contexts.");
2397
- }
2398
- if (signalIds.has("timing-burst") || signalIds.has("timing-regular-interval") || signalIds.has("timing-timezone-pattern") || signalIds.has("timing-correlation")) {
2399
- mitigations.add("Introduce timing delays and vary transaction patterns to reduce correlation.");
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
- }
2404
- if (signalIds.has("amount-reuse")) {
2405
- mitigations.add("Vary transaction amounts to avoid creating fingerprints.");
2406
- }
2407
- mitigations.add("Research and consider privacy-preserving protocols when available.");
2408
- return Array.from(mitigations);
2409
- }
2410
- function evaluateHeuristics(context) {
2411
- const signals = [];
2412
- for (const heuristic of HEURISTICS) {
2413
- try {
2414
- const result = heuristic(context);
2415
- if (Array.isArray(result)) {
2416
- signals.push(...result);
2417
- } else if (result) {
2418
- signals.push(result);
2419
- }
2420
- } catch (error) {
2421
- console.warn(`Heuristic evaluation failed:`, error);
2422
- }
2423
- }
2424
- signals.sort((a, b) => {
2425
- const severityOrder = { HIGH: 0, MEDIUM: 1, LOW: 2 };
2426
- return severityOrder[a.severity] - severityOrder[b.severity];
2427
- });
2428
- return signals;
2429
- }
2430
- function generateReport(context) {
2431
- const signals = evaluateHeuristics(context);
2432
- const overallRisk = calculateOverallRisk(signals);
2433
- const highRiskSignals = signals.filter((s) => s.severity === "HIGH").length;
2434
- const mediumRiskSignals = signals.filter((s) => s.severity === "MEDIUM").length;
2435
- const lowRiskSignals = signals.filter((s) => s.severity === "LOW").length;
2436
- const mitigations = generateMitigations(signals);
2437
- const knownEntities = Array.from(context.labels.values());
2438
- return {
2439
- version: REPORT_VERSION,
2440
- timestamp: Date.now(),
2441
- targetType: context.targetType,
2442
- target: context.target,
2443
- overallRisk,
2444
- signals,
2445
- summary: {
2446
- totalSignals: signals.length,
2447
- highRiskSignals,
2448
- mediumRiskSignals,
2449
- lowRiskSignals,
2450
- transactionsAnalyzed: context.transactionCount
2451
- },
2452
- mitigations,
2453
- knownEntities
2454
- };
2455
- }
2456
- var __filename = fileURLToPath(import.meta.url);
2457
- var __dirname = dirname(__filename);
2458
- var StaticLabelProvider = class {
2459
- labels;
2460
- constructor(labelsPath) {
2461
- this.labels = /* @__PURE__ */ new Map();
2462
- this.loadLabels(labelsPath);
2463
- }
2464
- /**
2465
- * Load labels from JSON file
2466
- */
2467
- loadLabels(customPath) {
2468
- try {
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
- }
2487
- const data = readFileSync(path, "utf-8");
2488
- const parsed = JSON.parse(data);
2489
- if (!parsed.labels || !Array.isArray(parsed.labels)) {
2490
- console.warn("Invalid labels file format");
2491
- return;
2492
- }
2493
- for (const label of parsed.labels) {
2494
- if (label.address && label.name && label.type) {
2495
- this.labels.set(label.address, {
2496
- address: label.address,
2497
- name: label.name,
2498
- type: label.type,
2499
- description: label.description,
2500
- relatedAddresses: label.relatedAddresses
2501
- });
2502
- }
2503
- }
2504
- console.debug(`Loaded ${this.labels.size} address labels`);
2505
- } catch (error) {
2506
- console.warn("Failed to load labels file:", error);
2507
- }
2508
- }
2509
- /**
2510
- * Look up a label for an address
2511
- */
2512
- lookup(address) {
2513
- return this.labels.get(address) || null;
2514
- }
2515
- /**
2516
- * Look up multiple addresses at once
2517
- */
2518
- lookupMany(addresses) {
2519
- const results = /* @__PURE__ */ new Map();
2520
- for (const address of addresses) {
2521
- const label = this.lookup(address);
2522
- if (label) {
2523
- results.set(address, label);
2524
- }
2525
- }
2526
- return results;
2527
- }
2528
- /**
2529
- * Get all loaded labels
2530
- */
2531
- getAllLabels() {
2532
- return Array.from(this.labels.values());
2533
- }
2534
- /**
2535
- * Get count of loaded labels
2536
- */
2537
- getCount() {
2538
- return this.labels.size;
2539
- }
2540
- };
2541
- function createDefaultLabelProvider() {
2542
- return new StaticLabelProvider();
2543
- }
9
+ import {
10
+ RPCClient,
11
+ collectWalletData,
12
+ normalizeWalletData,
13
+ generateReport,
14
+ createDefaultLabelProvider
15
+ } from "solana-privacy-scanner-core";
2544
16
 
2545
17
  // src/formatter.js
2546
18
  import chalk from "chalk";
@@ -2737,11 +209,18 @@ async function scanWallet(address, options) {
2737
209
 
2738
210
  // src/commands/transaction.ts
2739
211
  import { writeFileSync as writeFileSync2 } from "fs";
212
+ import {
213
+ RPCClient as RPCClient2,
214
+ collectTransactionData,
215
+ normalizeTransactionData,
216
+ generateReport as generateReport2,
217
+ createDefaultLabelProvider as createDefaultLabelProvider2
218
+ } from "solana-privacy-scanner-core";
2740
219
  async function scanTransaction(signature, options) {
2741
220
  try {
2742
221
  console.error(`Scanning transaction: ${signature}`);
2743
222
  console.error("");
2744
- const client = new RPCClient(
223
+ const client = new RPCClient2(
2745
224
  options.rpc ? {
2746
225
  rpcUrl: options.rpc,
2747
226
  maxConcurrency: 5,
@@ -2759,9 +238,9 @@ async function scanTransaction(signature, options) {
2759
238
  }
2760
239
  console.error("Analyzing transaction...");
2761
240
  console.error("");
2762
- const labelProvider = createDefaultLabelProvider();
241
+ const labelProvider = createDefaultLabelProvider2();
2763
242
  const context = normalizeTransactionData(rawData, labelProvider);
2764
- const report = generateReport(context);
243
+ const report = generateReport2(context);
2765
244
  if (options.json) {
2766
245
  const output = JSON.stringify(report, null, 2);
2767
246
  if (options.output) {
@@ -2788,11 +267,18 @@ async function scanTransaction(signature, options) {
2788
267
 
2789
268
  // src/commands/program.ts
2790
269
  import { writeFileSync as writeFileSync3 } from "fs";
270
+ import {
271
+ RPCClient as RPCClient3,
272
+ collectProgramData,
273
+ normalizeProgramData,
274
+ generateReport as generateReport3,
275
+ createDefaultLabelProvider as createDefaultLabelProvider3
276
+ } from "solana-privacy-scanner-core";
2791
277
  async function scanProgram(programId, options) {
2792
278
  try {
2793
279
  console.error(`Scanning program: ${programId}`);
2794
280
  console.error("");
2795
- const client = new RPCClient(
281
+ const client = new RPCClient3(
2796
282
  options.rpc ? {
2797
283
  rpcUrl: options.rpc,
2798
284
  maxConcurrency: 5,
@@ -2812,9 +298,9 @@ async function scanProgram(programId, options) {
2812
298
  console.error(`Found ${rawData.accounts.length} accounts, ${rawData.relatedTransactions.length} transactions`);
2813
299
  console.error("");
2814
300
  console.error("Analyzing program activity...");
2815
- const labelProvider = createDefaultLabelProvider();
301
+ const labelProvider = createDefaultLabelProvider3();
2816
302
  const context = normalizeProgramData(rawData, labelProvider);
2817
- const report = generateReport(context);
303
+ const report = generateReport3(context);
2818
304
  if (options.json) {
2819
305
  const output = JSON.stringify(report, null, 2);
2820
306
  if (options.output) {
@@ -2840,6 +326,7 @@ async function scanProgram(programId, options) {
2840
326
  }
2841
327
 
2842
328
  // src/index.ts
329
+ import { VERSION } from "solana-privacy-scanner-core";
2843
330
  dotenv.config({ path: ".env.local" });
2844
331
  dotenv.config();
2845
332
  var program = new Command();