solana-privacy-scanner 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/index.js +1155 -171
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -13,12 +13,11 @@ import { readFileSync } from "fs";
13
13
  import { fileURLToPath } from "url";
14
14
  import { dirname, join } from "path";
15
15
  var RateLimiter = class {
16
- maxConcurrency;
17
- activeRequests = 0;
18
- queue = [];
19
16
  constructor(maxConcurrency) {
20
17
  this.maxConcurrency = maxConcurrency;
21
18
  }
19
+ activeRequests = 0;
20
+ queue = [];
22
21
  async acquire() {
23
22
  if (this.activeRequests < this.maxConcurrency) {
24
23
  this.activeRequests++;
@@ -52,14 +51,16 @@ var RPCClient = class {
52
51
  connection;
53
52
  config;
54
53
  rateLimiter;
55
- constructor(config2) {
54
+ constructor(configOrUrl) {
55
+ const config2 = typeof configOrUrl === "string" ? { rpcUrl: configOrUrl } : configOrUrl;
56
+ const rpcUrl = config2.rpcUrl.trim();
56
57
  this.config = {
57
58
  maxRetries: config2.maxRetries ?? 3,
58
59
  retryDelay: config2.retryDelay ?? 1e3,
59
60
  timeout: config2.timeout ?? 3e4,
60
61
  maxConcurrency: config2.maxConcurrency ?? 10,
61
62
  debug: config2.debug ?? false,
62
- rpcUrl: config2.rpcUrl
63
+ rpcUrl
63
64
  };
64
65
  const connectionConfig = {
65
66
  commitment: "confirmed",
@@ -90,7 +91,10 @@ var RPCClient = class {
90
91
  } catch (error) {
91
92
  lastError = error;
92
93
  if (this.config.debug) {
93
- console.error(`[RPCClient] Error in ${operationName} (attempt ${attempt + 1}/${this.config.maxRetries + 1}):`, error);
94
+ console.error(
95
+ `[RPCClient] Error in ${operationName} (attempt ${attempt + 1}/${this.config.maxRetries + 1}):`,
96
+ error
97
+ );
94
98
  }
95
99
  if (attempt < this.config.maxRetries) {
96
100
  const delay = this.config.retryDelay * Math.pow(2, attempt);
@@ -99,7 +103,9 @@ var RPCClient = class {
99
103
  }
100
104
  }
101
105
  this.rateLimiter.release();
102
- throw new Error(`RPC operation ${operationName} failed after ${this.config.maxRetries + 1} attempts: ${lastError?.message}`);
106
+ throw new Error(
107
+ `RPC operation ${operationName} failed after ${this.config.maxRetries + 1} attempts: ${lastError?.message}`
108
+ );
103
109
  }
104
110
  /**
105
111
  * Get the underlying Solana Connection
@@ -121,20 +127,29 @@ var RPCClient = class {
121
127
  * Get signatures for an address with retry and rate limiting
122
128
  */
123
129
  async getSignaturesForAddress(address, options) {
124
- return this.executeWithRetry(async () => {
125
- const { PublicKey } = await import("@solana/web3.js");
126
- return this.connection.getSignaturesForAddress(new PublicKey(address), options);
127
- }, `getSignaturesForAddress(${address})`);
130
+ return this.executeWithRetry(
131
+ async () => {
132
+ const { PublicKey } = await import("@solana/web3.js");
133
+ return this.connection.getSignaturesForAddress(
134
+ new PublicKey(address),
135
+ options
136
+ );
137
+ },
138
+ `getSignaturesForAddress(${address})`
139
+ );
128
140
  }
129
141
  /**
130
142
  * Get transaction details with retry and rate limiting
131
143
  */
132
144
  async getTransaction(signature, options) {
133
- return this.executeWithRetry(async () => {
134
- return this.connection.getTransaction(signature, {
135
- maxSupportedTransactionVersion: options?.maxSupportedTransactionVersion ?? 0
136
- });
137
- }, `getTransaction(${signature})`);
145
+ return this.executeWithRetry(
146
+ async () => {
147
+ return this.connection.getTransaction(signature, {
148
+ maxSupportedTransactionVersion: options?.maxSupportedTransactionVersion ?? 0
149
+ });
150
+ },
151
+ `getTransaction(${signature})`
152
+ );
138
153
  }
139
154
  /**
140
155
  * Get multiple transactions in parallel (respects rate limiting)
@@ -147,54 +162,69 @@ var RPCClient = class {
147
162
  * Get token accounts by owner with retry and rate limiting
148
163
  */
149
164
  async getTokenAccountsByOwner(ownerAddress, mintAddress) {
150
- return this.executeWithRetry(async () => {
151
- const { PublicKey } = await import("@solana/web3.js");
152
- const owner = new PublicKey(ownerAddress);
153
- const TOKEN_PROGRAM_ID = new PublicKey("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA");
154
- if (mintAddress) {
155
- const mint = new PublicKey(mintAddress);
156
- return this.connection.getTokenAccountsByOwner(owner, { mint });
157
- } else {
158
- return this.connection.getTokenAccountsByOwner(owner, {
159
- programId: TOKEN_PROGRAM_ID
160
- });
161
- }
162
- }, `getTokenAccountsByOwner(${ownerAddress})`);
165
+ return this.executeWithRetry(
166
+ async () => {
167
+ const { PublicKey } = await import("@solana/web3.js");
168
+ const owner = new PublicKey(ownerAddress);
169
+ const TOKEN_PROGRAM_ID = new PublicKey("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA");
170
+ if (mintAddress) {
171
+ const mint = new PublicKey(mintAddress);
172
+ return this.connection.getTokenAccountsByOwner(owner, { mint });
173
+ } else {
174
+ return this.connection.getTokenAccountsByOwner(owner, {
175
+ programId: TOKEN_PROGRAM_ID
176
+ });
177
+ }
178
+ },
179
+ `getTokenAccountsByOwner(${ownerAddress})`
180
+ );
163
181
  }
164
182
  /**
165
183
  * Get program accounts with retry and rate limiting
166
184
  */
167
185
  async getProgramAccounts(programId, config2) {
168
- return this.executeWithRetry(async () => {
169
- const { PublicKey } = await import("@solana/web3.js");
170
- return this.connection.getProgramAccounts(new PublicKey(programId), config2);
171
- }, `getProgramAccounts(${programId})`);
186
+ return this.executeWithRetry(
187
+ async () => {
188
+ const { PublicKey } = await import("@solana/web3.js");
189
+ return this.connection.getProgramAccounts(new PublicKey(programId), config2);
190
+ },
191
+ `getProgramAccounts(${programId})`
192
+ );
172
193
  }
173
194
  /**
174
195
  * Get account info with retry and rate limiting
175
196
  */
176
197
  async getAccountInfo(address) {
177
- return this.executeWithRetry(async () => {
178
- const { PublicKey } = await import("@solana/web3.js");
179
- return this.connection.getAccountInfo(new PublicKey(address));
180
- }, `getAccountInfo(${address})`);
198
+ return this.executeWithRetry(
199
+ async () => {
200
+ const { PublicKey } = await import("@solana/web3.js");
201
+ return this.connection.getAccountInfo(new PublicKey(address));
202
+ },
203
+ `getAccountInfo(${address})`
204
+ );
181
205
  }
182
206
  /**
183
207
  * Get multiple account infos in parallel (respects rate limiting)
184
208
  */
185
209
  async getMultipleAccountsInfo(addresses) {
186
- return this.executeWithRetry(async () => {
187
- const { PublicKey } = await import("@solana/web3.js");
188
- const pubkeys = addresses.map((addr) => new PublicKey(addr));
189
- return this.connection.getMultipleAccountsInfo(pubkeys);
190
- }, `getMultipleAccountsInfo(${addresses.length} addresses)`);
210
+ return this.executeWithRetry(
211
+ async () => {
212
+ const { PublicKey } = await import("@solana/web3.js");
213
+ const pubkeys = addresses.map((addr) => new PublicKey(addr));
214
+ return this.connection.getMultipleAccountsInfo(pubkeys);
215
+ },
216
+ `getMultipleAccountsInfo(${addresses.length} addresses)`
217
+ );
191
218
  }
192
219
  /**
193
220
  * Check if the RPC connection is healthy
194
221
  */
195
222
  async healthCheck() {
196
223
  try {
197
- const version = await this.executeWithRetry(() => this.connection.getVersion(), "healthCheck");
224
+ const version = await this.executeWithRetry(
225
+ () => this.connection.getVersion(),
226
+ "healthCheck"
227
+ );
198
228
  return !!version;
199
229
  } catch {
200
230
  return false;
@@ -204,23 +234,32 @@ var RPCClient = class {
204
234
  async function collectWalletData(client, address, options = {}) {
205
235
  const maxSignatures = options.maxSignatures ?? 100;
206
236
  const includeTokenAccounts = options.includeTokenAccounts ?? true;
207
- const signatures = await client.getSignaturesForAddress(address, {
208
- limit: maxSignatures
209
- });
237
+ let signatures = [];
238
+ try {
239
+ signatures = await client.getSignaturesForAddress(address, {
240
+ limit: maxSignatures
241
+ });
242
+ } catch (error) {
243
+ console.warn(`Failed to fetch signatures for ${address}:`, error);
244
+ }
210
245
  const transactions = [];
211
246
  const BATCH_SIZE = 10;
212
247
  for (let i = 0; i < signatures.length; i += BATCH_SIZE) {
213
248
  const batch = signatures.slice(i, i + BATCH_SIZE);
214
249
  const batchSignatures = batch.map((sig) => sig.signature);
215
- const txs = await client.getTransactions(batchSignatures, {
216
- maxSupportedTransactionVersion: 0
217
- });
218
- for (let j = 0; j < batch.length; j++) {
219
- transactions.push({
220
- signature: batch[j].signature,
221
- transaction: txs[j],
222
- blockTime: batch[j].blockTime
250
+ try {
251
+ const txs = await client.getTransactions(batchSignatures, {
252
+ maxSupportedTransactionVersion: 0
223
253
  });
254
+ for (let j = 0; j < batch.length; j++) {
255
+ transactions.push({
256
+ signature: batch[j].signature,
257
+ transaction: txs[j],
258
+ blockTime: batch[j].blockTime
259
+ });
260
+ }
261
+ } catch (error) {
262
+ console.warn(`Failed to fetch transaction batch for ${address}:`, error);
224
263
  }
225
264
  }
226
265
  let tokenAccounts = [];
@@ -240,9 +279,14 @@ async function collectWalletData(client, address, options = {}) {
240
279
  };
241
280
  }
242
281
  async function collectTransactionData(client, signature) {
243
- const transaction = await client.getTransaction(signature, {
244
- maxSupportedTransactionVersion: 0
245
- });
282
+ let transaction = null;
283
+ try {
284
+ transaction = await client.getTransaction(signature, {
285
+ maxSupportedTransactionVersion: 0
286
+ });
287
+ } catch (error) {
288
+ console.warn(`Failed to fetch transaction ${signature}:`, error);
289
+ }
246
290
  return {
247
291
  signature,
248
292
  transaction,
@@ -309,6 +353,162 @@ var PROGRAM_IDS = {
309
353
  MEMO: "MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr",
310
354
  MEMO_V1: "Memo1UhkJRfHyvLMcVucJwxXeuD728EqVDDwQDxFMNo"
311
355
  };
356
+ function extractTransactionMetadata(tx, signature) {
357
+ if (!tx || !tx.transaction || !tx.transaction.message || !tx.transaction.message.accountKeys) {
358
+ return {
359
+ signature,
360
+ blockTime: tx?.blockTime || null,
361
+ feePayer: "unknown",
362
+ signers: []
363
+ };
364
+ }
365
+ const feePayer = tx.transaction.message.accountKeys[0];
366
+ const feePayerAddress = typeof feePayer === "string" ? feePayer : feePayer.pubkey.toString();
367
+ const signers = [];
368
+ const accountKeys = tx.transaction.message.accountKeys;
369
+ signers.push(feePayerAddress);
370
+ if (accountKeys && Array.isArray(accountKeys)) {
371
+ for (let i = 1; i < accountKeys.length; i++) {
372
+ const key = accountKeys[i];
373
+ const address = typeof key === "string" ? key : key.pubkey?.toString();
374
+ if (typeof key !== "string" && key.signer) {
375
+ if (address && !signers.includes(address)) {
376
+ signers.push(address);
377
+ }
378
+ }
379
+ }
380
+ }
381
+ let memo;
382
+ const instructions = tx.transaction.message.instructions;
383
+ if (instructions && Array.isArray(instructions)) {
384
+ for (const instruction of instructions) {
385
+ if (!instruction || !instruction.programId) continue;
386
+ const programId = instruction.programId.toString();
387
+ if (programId === PROGRAM_IDS.MEMO || programId === PROGRAM_IDS.MEMO_V1) {
388
+ if ("parsed" in instruction && instruction.parsed) {
389
+ const parsed = instruction.parsed;
390
+ if (parsed.type === "memo" && typeof parsed.info === "string") {
391
+ memo = parsed.info;
392
+ }
393
+ } else if ("data" in instruction && typeof instruction.data === "string") {
394
+ try {
395
+ memo = Buffer.from(instruction.data, "base64").toString("utf8");
396
+ } catch {
397
+ memo = instruction.data;
398
+ }
399
+ }
400
+ break;
401
+ }
402
+ }
403
+ }
404
+ let computeUnitsUsed;
405
+ let priorityFee;
406
+ if (tx.meta) {
407
+ computeUnitsUsed = tx.meta.computeUnitsConsumed;
408
+ if (tx.meta.fee !== void 0 && tx.meta.fee > 5e3) {
409
+ priorityFee = tx.meta.fee - 5e3;
410
+ }
411
+ }
412
+ return {
413
+ signature,
414
+ blockTime: tx.blockTime,
415
+ feePayer: feePayerAddress,
416
+ signers,
417
+ computeUnitsUsed,
418
+ priorityFee,
419
+ memo
420
+ };
421
+ }
422
+ function extractTokenAccountEvents(tx, signature) {
423
+ const events = [];
424
+ if (!tx.transaction?.message?.instructions) {
425
+ return events;
426
+ }
427
+ for (const instruction of tx.transaction.message.instructions) {
428
+ if (!instruction || !instruction.programId) continue;
429
+ const programId = instruction.programId.toString();
430
+ if (programId === PROGRAM_IDS.TOKEN || programId === PROGRAM_IDS.ASSOCIATED_TOKEN) {
431
+ if ("parsed" in instruction && instruction.parsed) {
432
+ const parsed = instruction.parsed;
433
+ if (parsed.type === "initializeAccount" || parsed.type === "create") {
434
+ const info = parsed.info;
435
+ events.push({
436
+ type: "create",
437
+ tokenAccount: info.account || info.newAccount,
438
+ owner: info.owner,
439
+ mint: info.mint,
440
+ signature,
441
+ blockTime: tx.blockTime
442
+ });
443
+ }
444
+ if (parsed.type === "closeAccount") {
445
+ const info = parsed.info;
446
+ let rentRefund;
447
+ if (tx.meta?.postBalances && tx.meta?.preBalances) {
448
+ const accountKeys = tx.transaction.message.accountKeys;
449
+ for (let i = 0; i < accountKeys.length; i++) {
450
+ const key = accountKeys[i];
451
+ const address = typeof key === "string" ? key : key.pubkey.toString();
452
+ if (address === info.destination) {
453
+ const diff = tx.meta.postBalances[i] - tx.meta.preBalances[i];
454
+ if (diff > 0) {
455
+ rentRefund = diff / 1e9;
456
+ }
457
+ break;
458
+ }
459
+ }
460
+ }
461
+ events.push({
462
+ type: "close",
463
+ tokenAccount: info.account,
464
+ owner: info.owner || info.destination,
465
+ signature,
466
+ blockTime: tx.blockTime,
467
+ rentRefund
468
+ });
469
+ }
470
+ }
471
+ }
472
+ }
473
+ return events;
474
+ }
475
+ function extractPDAInteractions(tx, signature) {
476
+ const interactions = [];
477
+ if (!tx.transaction?.message?.accountKeys) {
478
+ return interactions;
479
+ }
480
+ const accountProgramMap = /* @__PURE__ */ new Map();
481
+ for (const instruction of tx.transaction.message.instructions) {
482
+ if (!instruction || !instruction.programId) continue;
483
+ const programId = instruction.programId.toString();
484
+ const accounts = [];
485
+ if ("accounts" in instruction && Array.isArray(instruction.accounts)) {
486
+ for (const acc of instruction.accounts) {
487
+ const address = typeof acc === "string" ? acc : acc.toString();
488
+ accounts.push(address);
489
+ }
490
+ }
491
+ for (const account of accounts) {
492
+ if (!accountProgramMap.has(account)) {
493
+ accountProgramMap.set(account, /* @__PURE__ */ new Set());
494
+ }
495
+ accountProgramMap.get(account).add(programId);
496
+ }
497
+ }
498
+ for (const [address, programs] of accountProgramMap) {
499
+ for (const programId of programs) {
500
+ if (programId === PROGRAM_IDS.SYSTEM || programId === PROGRAM_IDS.MEMO || programId === PROGRAM_IDS.MEMO_V1) {
501
+ continue;
502
+ }
503
+ interactions.push({
504
+ pda: address,
505
+ programId,
506
+ signature
507
+ });
508
+ }
509
+ }
510
+ return interactions;
511
+ }
312
512
  function extractSOLTransfers(tx, signature) {
313
513
  const transfers = [];
314
514
  if (!tx.meta || !tx.transaction) {
@@ -330,14 +530,11 @@ function extractSOLTransfers(tx, signature) {
330
530
  continue;
331
531
  }
332
532
  const diff = post - pre;
333
- if (diff === 0)
334
- continue;
533
+ if (diff === 0) continue;
335
534
  const account = accountKeys[i];
336
- if (!account)
337
- continue;
535
+ if (!account) continue;
338
536
  const address = typeof account === "string" ? account : account.pubkey?.toString();
339
- if (!address)
340
- continue;
537
+ if (!address) continue;
341
538
  if (diff > 0) {
342
539
  for (let j = 0; j < accountKeys.length; j++) {
343
540
  const preSender = preBalances[j];
@@ -347,11 +544,9 @@ function extractSOLTransfers(tx, signature) {
347
544
  }
348
545
  if (postSender < preSender) {
349
546
  const sender = accountKeys[j];
350
- if (!sender)
351
- continue;
547
+ if (!sender) continue;
352
548
  const senderAddress = typeof sender === "string" ? sender : sender.pubkey?.toString();
353
- if (!senderAddress)
354
- continue;
549
+ if (!senderAddress) continue;
355
550
  transfers.push({
356
551
  from: senderAddress,
357
552
  to: address,
@@ -377,7 +572,9 @@ function extractSPLTransfers(tx, signature) {
377
572
  const postTokenBalances = tx.meta.postTokenBalances;
378
573
  const balanceChanges = /* @__PURE__ */ new Map();
379
574
  for (const post of postTokenBalances) {
380
- const pre = preTokenBalances.find((p) => p.accountIndex === post.accountIndex && p.mint === post.mint);
575
+ const pre = preTokenBalances.find(
576
+ (p) => p.accountIndex === post.accountIndex && p.mint === post.mint
577
+ );
381
578
  const preAmount = pre?.uiTokenAmount.uiAmount ?? 0;
382
579
  const postAmount = post.uiTokenAmount.uiAmount ?? 0;
383
580
  const change = postAmount - preAmount;
@@ -398,20 +595,16 @@ function extractSPLTransfers(tx, signature) {
398
595
  return;
399
596
  }
400
597
  const account = accountKeys[accountIndex];
401
- if (!account)
402
- return;
598
+ if (!account) return;
403
599
  const address = typeof account === "string" ? account : account.pubkey?.toString();
404
- if (!address)
405
- return;
600
+ if (!address) return;
406
601
  if (info.change > 0) {
407
602
  balanceChanges.forEach((senderInfo, senderIndex) => {
408
603
  if (senderInfo.mint === info.mint && senderInfo.change < 0 && senderIndex !== accountIndex && senderIndex < accountKeys.length) {
409
604
  const sender = accountKeys[senderIndex];
410
- if (!sender)
411
- return;
605
+ if (!sender) return;
412
606
  const senderAddress = typeof sender === "string" ? sender : sender.pubkey?.toString();
413
- if (!senderAddress)
414
- return;
607
+ if (!senderAddress) return;
415
608
  transfers.push({
416
609
  from: senderAddress,
417
610
  to: address,
@@ -479,12 +672,20 @@ function extractInstructions(tx, signature) {
479
672
  if ("parsed" in instruction) {
480
673
  data = instruction.parsed;
481
674
  }
675
+ const accounts = [];
676
+ if ("accounts" in instruction && Array.isArray(instruction.accounts)) {
677
+ for (const acc of instruction.accounts) {
678
+ const address = typeof acc === "string" ? acc : acc.toString();
679
+ accounts.push(address);
680
+ }
681
+ }
482
682
  instructions.push({
483
683
  programId,
484
684
  category,
485
685
  signature,
486
686
  blockTime: tx.blockTime,
487
- data
687
+ data,
688
+ accounts: accounts.length > 0 ? accounts : void 0
488
689
  });
489
690
  }
490
691
  return instructions;
@@ -518,15 +719,24 @@ function calculateTimeRange(transactions) {
518
719
  function normalizeWalletData(rawData, labelProvider) {
519
720
  const allTransfers = [];
520
721
  const allInstructions = [];
521
- for (const rawTx of rawData.transactions) {
522
- if (!rawTx.transaction)
523
- continue;
722
+ const allTransactionMetadata = [];
723
+ const allTokenAccountEvents = [];
724
+ const allPDAInteractions = [];
725
+ const transactions = rawData.transactions || [];
726
+ for (const rawTx of transactions) {
727
+ if (!rawTx.transaction) continue;
524
728
  try {
525
729
  const solTransfers = extractSOLTransfers(rawTx.transaction, rawTx.signature);
526
730
  const splTransfers = extractSPLTransfers(rawTx.transaction, rawTx.signature);
527
731
  allTransfers.push(...solTransfers, ...splTransfers);
528
732
  const instructions = extractInstructions(rawTx.transaction, rawTx.signature);
529
733
  allInstructions.push(...instructions);
734
+ const metadata = extractTransactionMetadata(rawTx.transaction, rawTx.signature);
735
+ allTransactionMetadata.push(metadata);
736
+ const tokenEvents = extractTokenAccountEvents(rawTx.transaction, rawTx.signature);
737
+ allTokenAccountEvents.push(...tokenEvents);
738
+ const pdaInteractions = extractPDAInteractions(rawTx.transaction, rawTx.signature);
739
+ allPDAInteractions.push(...pdaInteractions);
530
740
  } catch (error) {
531
741
  console.warn(`Failed to normalize transaction ${rawTx.signature}:`, error);
532
742
  continue;
@@ -534,7 +744,7 @@ function normalizeWalletData(rawData, labelProvider) {
534
744
  }
535
745
  const counterparties = extractCounterparties(allTransfers, rawData.address);
536
746
  const labels = labelProvider ? labelProvider.lookupMany(Array.from(counterparties)) : /* @__PURE__ */ new Map();
537
- const timeRange = calculateTimeRange(rawData.transactions);
747
+ const timeRange = calculateTimeRange(transactions);
538
748
  const tokenAccounts = rawData.tokenAccounts.map((ta) => {
539
749
  try {
540
750
  return {
@@ -546,22 +756,47 @@ function normalizeWalletData(rawData, labelProvider) {
546
756
  return null;
547
757
  }
548
758
  }).filter((ta) => ta !== null);
759
+ const feePayers = /* @__PURE__ */ new Set();
760
+ const signers = /* @__PURE__ */ new Set();
761
+ const programs = /* @__PURE__ */ new Set();
762
+ for (const metadata of allTransactionMetadata) {
763
+ feePayers.add(metadata.feePayer);
764
+ for (const signer of metadata.signers) {
765
+ signers.add(signer);
766
+ }
767
+ }
768
+ for (const instruction of allInstructions) {
769
+ programs.add(instruction.programId);
770
+ }
549
771
  return {
550
772
  target: rawData.address,
551
773
  targetType: "wallet",
552
774
  transfers: allTransfers,
553
775
  instructions: allInstructions,
554
776
  counterparties,
555
- labels: /* @__PURE__ */ new Map(),
777
+ labels,
556
778
  tokenAccounts,
557
779
  timeRange,
558
- transactionCount: rawData.transactions.length
780
+ transactionCount: transactions.length,
781
+ // Solana-specific fields
782
+ transactions: allTransactionMetadata,
783
+ tokenAccountEvents: allTokenAccountEvents,
784
+ pdaInteractions: allPDAInteractions,
785
+ feePayers,
786
+ signers,
787
+ programs
559
788
  };
560
789
  }
561
790
  function normalizeTransactionData(rawData, labelProvider) {
562
791
  const allTransfers = [];
563
792
  const allInstructions = [];
564
793
  const counterparties = /* @__PURE__ */ new Set();
794
+ const allTransactionMetadata = [];
795
+ const allTokenAccountEvents = [];
796
+ const allPDAInteractions = [];
797
+ const feePayers = /* @__PURE__ */ new Set();
798
+ const signers = /* @__PURE__ */ new Set();
799
+ const programs = /* @__PURE__ */ new Set();
565
800
  if (rawData.transaction) {
566
801
  try {
567
802
  const solTransfers = extractSOLTransfers(rawData.transaction, rawData.signature);
@@ -569,6 +804,16 @@ function normalizeTransactionData(rawData, labelProvider) {
569
804
  allTransfers.push(...solTransfers, ...splTransfers);
570
805
  const instructions = extractInstructions(rawData.transaction, rawData.signature);
571
806
  allInstructions.push(...instructions);
807
+ const metadata = extractTransactionMetadata(rawData.transaction, rawData.signature);
808
+ allTransactionMetadata.push(metadata);
809
+ feePayers.add(metadata.feePayer);
810
+ for (const signer of metadata.signers) {
811
+ signers.add(signer);
812
+ }
813
+ const tokenEvents = extractTokenAccountEvents(rawData.transaction, rawData.signature);
814
+ allTokenAccountEvents.push(...tokenEvents);
815
+ const pdaInteractions = extractPDAInteractions(rawData.transaction, rawData.signature);
816
+ allPDAInteractions.push(...pdaInteractions);
572
817
  const accountKeys = rawData.transaction.transaction.message.accountKeys;
573
818
  if (accountKeys && Array.isArray(accountKeys)) {
574
819
  for (const key of accountKeys) {
@@ -576,38 +821,65 @@ function normalizeTransactionData(rawData, labelProvider) {
576
821
  counterparties.add(address);
577
822
  }
578
823
  }
824
+ for (const instruction of instructions) {
825
+ programs.add(instruction.programId);
826
+ }
579
827
  } catch (error) {
580
828
  console.warn(`Failed to normalize transaction ${rawData.signature}:`, error);
581
829
  }
582
830
  }
831
+ const labels = labelProvider ? labelProvider.lookupMany(Array.from(counterparties)) : /* @__PURE__ */ new Map();
583
832
  return {
584
833
  target: rawData.signature,
585
834
  targetType: "transaction",
586
835
  transfers: allTransfers,
587
836
  instructions: allInstructions,
588
837
  counterparties,
589
- labels: /* @__PURE__ */ new Map(),
838
+ labels,
590
839
  tokenAccounts: [],
591
840
  timeRange: {
592
- earliest: rawData.blockTime,
593
- latest: rawData.blockTime
841
+ earliest: rawData.transaction ? rawData.blockTime : null,
842
+ latest: rawData.transaction ? rawData.blockTime : null
594
843
  },
595
- transactionCount: 1
844
+ transactionCount: rawData.transaction ? 1 : 0,
845
+ // Solana-specific fields
846
+ transactions: allTransactionMetadata,
847
+ tokenAccountEvents: allTokenAccountEvents,
848
+ pdaInteractions: allPDAInteractions,
849
+ feePayers,
850
+ signers,
851
+ programs
596
852
  };
597
853
  }
598
854
  function normalizeProgramData(rawData, labelProvider) {
599
855
  const allTransfers = [];
600
856
  const allInstructions = [];
601
857
  const counterparties = /* @__PURE__ */ new Set();
602
- for (const rawTx of rawData.relatedTransactions) {
603
- if (!rawTx.transaction)
604
- continue;
858
+ const allTransactionMetadata = [];
859
+ const allTokenAccountEvents = [];
860
+ const allPDAInteractions = [];
861
+ const feePayers = /* @__PURE__ */ new Set();
862
+ const signers = /* @__PURE__ */ new Set();
863
+ const programs = /* @__PURE__ */ new Set();
864
+ const transactions = rawData.relatedTransactions || [];
865
+ for (const rawTx of transactions) {
866
+ if (!rawTx.transaction) continue;
605
867
  try {
606
868
  const solTransfers = extractSOLTransfers(rawTx.transaction, rawTx.signature);
607
869
  const splTransfers = extractSPLTransfers(rawTx.transaction, rawTx.signature);
608
870
  allTransfers.push(...solTransfers, ...splTransfers);
609
871
  const instructions = extractInstructions(rawTx.transaction, rawTx.signature);
610
872
  allInstructions.push(...instructions);
873
+ const metadata = extractTransactionMetadata(rawTx.transaction, rawTx.signature);
874
+ allTransactionMetadata.push(metadata);
875
+ feePayers.add(metadata.feePayer);
876
+ for (const signer of metadata.signers) {
877
+ signers.add(signer);
878
+ }
879
+ const tokenEvents = extractTokenAccountEvents(rawTx.transaction, rawTx.signature);
880
+ allTokenAccountEvents.push(...tokenEvents);
881
+ const pdaInteractions = extractPDAInteractions(rawTx.transaction, rawTx.signature);
882
+ allPDAInteractions.push(...pdaInteractions);
611
883
  const accountKeys = rawTx.transaction.transaction.message.accountKeys;
612
884
  if (accountKeys && Array.isArray(accountKeys)) {
613
885
  for (const key of accountKeys) {
@@ -615,12 +887,15 @@ function normalizeProgramData(rawData, labelProvider) {
615
887
  counterparties.add(address);
616
888
  }
617
889
  }
890
+ for (const instruction of instructions) {
891
+ programs.add(instruction.programId);
892
+ }
618
893
  } catch (error) {
619
894
  console.warn(`Failed to normalize program transaction ${rawTx.signature}:`, error);
620
895
  continue;
621
896
  }
622
897
  }
623
- const timeRange = calculateTimeRange(rawData.relatedTransactions);
898
+ const timeRange = calculateTimeRange(transactions);
624
899
  const labels = labelProvider ? labelProvider.lookupMany(Array.from(counterparties)) : /* @__PURE__ */ new Map();
625
900
  return {
626
901
  target: rawData.programId,
@@ -628,58 +903,164 @@ function normalizeProgramData(rawData, labelProvider) {
628
903
  transfers: allTransfers,
629
904
  instructions: allInstructions,
630
905
  counterparties,
631
- labels: /* @__PURE__ */ new Map(),
906
+ labels,
632
907
  tokenAccounts: [],
633
908
  timeRange,
634
- transactionCount: rawData.relatedTransactions.length
909
+ transactionCount: transactions.length,
910
+ // Solana-specific fields
911
+ transactions: allTransactionMetadata,
912
+ tokenAccountEvents: allTokenAccountEvents,
913
+ pdaInteractions: allPDAInteractions,
914
+ feePayers,
915
+ signers,
916
+ programs
635
917
  };
636
918
  }
637
919
  function detectCounterpartyReuse(context) {
638
- if (context.targetType !== "wallet") {
639
- return null;
920
+ const signals = [];
921
+ if (context.targetType !== "wallet" || context.transactionCount < 2) {
922
+ return signals;
640
923
  }
641
- if (context.counterparties.size === 0 || context.transfers.length === 0) {
642
- return null;
924
+ if (context.transfers.length > 0) {
925
+ const interactionCounts = /* @__PURE__ */ new Map();
926
+ for (const transfer of context.transfers) {
927
+ const counterparty = transfer.from === context.target ? transfer.to : transfer.from;
928
+ if (counterparty === context.target) continue;
929
+ interactionCounts.set(counterparty, (interactionCounts.get(counterparty) || 0) + 1);
930
+ }
931
+ const reusedCounterparties = Array.from(interactionCounts.entries()).filter(([_, count]) => count >= 3).sort((a, b) => b[1] - a[1]);
932
+ if (reusedCounterparties.length > 0) {
933
+ const totalInteractions = context.transfers.length;
934
+ const topCounterpartyInteractions = reusedCounterparties[0][1];
935
+ const concentration = topCounterpartyInteractions / totalInteractions;
936
+ let severity = "LOW";
937
+ if (concentration > 0.5 || reusedCounterparties.length >= 5) {
938
+ severity = "HIGH";
939
+ } else if (concentration > 0.3 || reusedCounterparties.length >= 3) {
940
+ severity = "MEDIUM";
941
+ }
942
+ const evidence = reusedCounterparties.slice(0, 5).map(([addr, count]) => {
943
+ const label = context.labels.get(addr);
944
+ return {
945
+ description: `${count} transfers with ${addr.slice(0, 8)}...${addr.slice(-8)}${label ? ` (${label.name})` : ""}`,
946
+ severity: count > totalInteractions * 0.3 ? "HIGH" : count > totalInteractions * 0.15 ? "MEDIUM" : "LOW",
947
+ type: "address",
948
+ data: { address: addr, interactionCount: count }
949
+ };
950
+ });
951
+ signals.push({
952
+ id: "counterparty-reuse",
953
+ name: "Repeated Transfer Counterparties",
954
+ severity,
955
+ category: "linkability",
956
+ reason: `Wallet repeatedly transfers with ${reusedCounterparties.length} address(es). Top counterparty: ${topCounterpartyInteractions}/${totalInteractions} transfers.`,
957
+ impact: "Repeated interactions with the same addresses can be used to cluster wallets and build transaction graphs.",
958
+ evidence,
959
+ mitigation: "Use different wallets for different counterparties, or use privacy-preserving protocols."
960
+ });
961
+ }
643
962
  }
644
- const interactionCounts = /* @__PURE__ */ new Map();
645
- for (const transfer of context.transfers) {
646
- const counterparty = transfer.from === context.target ? transfer.to : transfer.from;
647
- if (counterparty === context.target)
648
- continue;
649
- interactionCounts.set(counterparty, (interactionCounts.get(counterparty) || 0) + 1);
963
+ if (context.programs && context.programs.size > 0) {
964
+ const programUsage = /* @__PURE__ */ new Map();
965
+ for (const instruction of context.instructions) {
966
+ programUsage.set(instruction.programId, (programUsage.get(instruction.programId) || 0) + 1);
967
+ }
968
+ const SYSTEM_PROGRAMS = [
969
+ "11111111111111111111111111111111",
970
+ "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA",
971
+ "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL",
972
+ "ComputeBudget111111111111111111111111111111"
973
+ ];
974
+ 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]);
975
+ if (significantPrograms.length >= 2) {
976
+ const evidence = significantPrograms.slice(0, 5).map(([programId, count]) => {
977
+ const label = context.labels.get(programId);
978
+ return {
979
+ description: `${programId.slice(0, 8)}...${label ? ` (${label.name})` : ""} used in ${count} instruction(s)`,
980
+ severity: "LOW",
981
+ reference: `https://solscan.io/account/${programId}`
982
+ };
983
+ });
984
+ signals.push({
985
+ id: "program-reuse",
986
+ name: "Repeated Program Interactions",
987
+ severity: "LOW",
988
+ category: "behavioral",
989
+ reason: `Wallet interacts with ${significantPrograms.length} non-system program(s) repeatedly.`,
990
+ impact: "Program usage patterns create a behavioral fingerprint. Addresses with similar patterns are likely related.",
991
+ mitigation: "This is generally unavoidable when using DeFi. Diversifying protocols can reduce fingerprinting.",
992
+ evidence
993
+ });
994
+ }
650
995
  }
651
- const reusedCounterparties = Array.from(interactionCounts.entries()).filter(([_, count]) => count >= 3).sort((a, b) => b[1] - a[1]);
652
- if (reusedCounterparties.length === 0) {
653
- return null;
996
+ if (context.pdaInteractions && context.pdaInteractions.length > 0) {
997
+ const pdaUsage = /* @__PURE__ */ new Map();
998
+ for (const pda of context.pdaInteractions) {
999
+ if (!pdaUsage.has(pda.pda)) {
1000
+ pdaUsage.set(pda.pda, { count: 0, programId: pda.programId });
1001
+ }
1002
+ pdaUsage.get(pda.pda).count++;
1003
+ }
1004
+ const repeatedPDAs = Array.from(pdaUsage.entries()).filter(([_, { count }]) => count >= 2).sort((a, b) => b[1].count - a[1].count);
1005
+ if (repeatedPDAs.length > 0) {
1006
+ const evidence = repeatedPDAs.slice(0, 5).map(([pda, { count, programId }]) => ({
1007
+ description: `PDA ${pda.slice(0, 8)}... (program: ${programId.slice(0, 8)}...) used ${count} times`,
1008
+ severity: count > 3 ? "MEDIUM" : "LOW",
1009
+ reference: `https://solscan.io/account/${pda}`
1010
+ }));
1011
+ const maxCount = repeatedPDAs[0][1].count;
1012
+ const severity = maxCount > 5 ? "MEDIUM" : "LOW";
1013
+ signals.push({
1014
+ id: "pda-reuse",
1015
+ name: "Repeated PDA Interactions",
1016
+ severity,
1017
+ category: "linkability",
1018
+ reason: `${repeatedPDAs.length} Program-Derived Address(es) are used repeatedly. Max usage: ${maxCount} times.`,
1019
+ impact: "PDAs often represent user-specific accounts (e.g., your position in a protocol). Repeated usage links all interactions.",
1020
+ mitigation: "Some PDA reuse is inherent to Solana protocols. For sensitive operations, use fresh wallets.",
1021
+ evidence
1022
+ });
1023
+ }
654
1024
  }
655
- const totalInteractions = context.transfers.length;
656
- const topCounterpartyInteractions = reusedCounterparties[0][1];
657
- const concentration = topCounterpartyInteractions / totalInteractions;
658
- let severity = "LOW";
659
- if (concentration > 0.5 || reusedCounterparties.length >= 5) {
660
- severity = "HIGH";
661
- } else if (concentration > 0.3 || reusedCounterparties.length >= 3) {
662
- severity = "MEDIUM";
1025
+ if (context.transfers.length > 0 && context.instructions.length > 0) {
1026
+ const combos = /* @__PURE__ */ new Map();
1027
+ for (const transfer of context.transfers) {
1028
+ const counterparty = transfer.from === context.target ? transfer.to : transfer.from;
1029
+ if (counterparty === context.target) continue;
1030
+ const txInstructions = context.instructions.filter((inst) => inst.signature === transfer.signature);
1031
+ for (const inst of txInstructions) {
1032
+ const combo = `${counterparty}:${inst.programId}`;
1033
+ combos.set(combo, (combos.get(combo) || 0) + 1);
1034
+ }
1035
+ }
1036
+ const repeatedCombos = Array.from(combos.entries()).filter(([_, count]) => count >= 2).sort((a, b) => b[1] - a[1]);
1037
+ if (repeatedCombos.length > 0) {
1038
+ const evidence = repeatedCombos.slice(0, 3).map(([combo, count]) => {
1039
+ const [counterparty, programId] = combo.split(":");
1040
+ const label = context.labels.get(counterparty);
1041
+ return {
1042
+ description: `${counterparty.slice(0, 8)}...${label ? ` (${label.name})` : ""} + program ${programId.slice(0, 8)}... used ${count} times`,
1043
+ severity: "MEDIUM"
1044
+ };
1045
+ });
1046
+ signals.push({
1047
+ id: "counterparty-program-combo",
1048
+ name: "Repeated Counterparty-Program Combination",
1049
+ severity: "MEDIUM",
1050
+ category: "linkability",
1051
+ reason: `${repeatedCombos.length} specific counterparty-program combination(s) are reused.`,
1052
+ impact: "This creates a very specific fingerprint. The combination of WHO you interact with and WHAT program is highly identifying.",
1053
+ mitigation: "Rotate both counterparties and programs if privacy is critical.",
1054
+ evidence
1055
+ });
1056
+ }
663
1057
  }
664
- const evidence = reusedCounterparties.slice(0, 5).map(([addr, count]) => ({
665
- type: "address",
666
- description: `${count} interactions with ${addr.slice(0, 8)}...${addr.slice(-8)}`,
667
- data: { address: addr, interactionCount: count }
668
- }));
669
- return {
670
- id: "counterparty-reuse",
671
- name: "Counterparty Reuse",
672
- severity,
673
- reason: `Wallet repeatedly interacts with ${reusedCounterparties.length} address(es)`,
674
- impact: "Repeated interactions with the same addresses can be used to cluster wallets and build transaction graphs, enabling surveillance of your activity patterns.",
675
- evidence,
676
- mitigation: "Use different wallets for different counterparties, or use privacy-preserving protocols that obscure transaction graphs.",
677
- confidence: 0.9
678
- };
1058
+ return signals;
679
1059
  }
680
1060
  function detectAmountReuse(context) {
681
- if (context.transfers.length < 3) {
682
- return null;
1061
+ const signals = [];
1062
+ if (context.transfers.length < 5) {
1063
+ return signals;
683
1064
  }
684
1065
  const amountCounts = /* @__PURE__ */ new Map();
685
1066
  const roundNumbers = [];
@@ -688,49 +1069,114 @@ function detectAmountReuse(context) {
688
1069
  roundNumbers.push(transfer.amount);
689
1070
  }
690
1071
  const amountKey = `${transfer.amount.toFixed(9)}-${transfer.token || "SOL"}`;
691
- amountCounts.set(amountKey, (amountCounts.get(amountKey) || 0) + 1);
1072
+ if (!amountCounts.has(amountKey)) {
1073
+ amountCounts.set(amountKey, { count: 0, counterparties: /* @__PURE__ */ new Set(), signers: /* @__PURE__ */ new Set() });
1074
+ }
1075
+ const data = amountCounts.get(amountKey);
1076
+ data.count++;
1077
+ const counterparty = transfer.from === context.target ? transfer.to : transfer.from;
1078
+ if (counterparty !== context.target) {
1079
+ data.counterparties.add(counterparty);
1080
+ }
1081
+ const tx = context.transactions ? context.transactions.find((t) => t.signature === transfer.signature) : null;
1082
+ if (tx) {
1083
+ tx.signers.forEach((s) => data.signers.add(s));
1084
+ }
692
1085
  }
693
- const reusedAmounts = Array.from(amountCounts.entries()).filter(([_, count]) => count >= 2).sort((a, b) => b[1] - a[1]);
694
- const hasRoundNumbers = roundNumbers.length >= 2;
1086
+ const reusedAmounts = Array.from(amountCounts.entries()).filter(([_, data]) => data.count >= 3).sort((a, b) => b[1].count - a[1].count);
1087
+ const hasRoundNumbers = roundNumbers.length >= 3;
695
1088
  const hasReusedAmounts = reusedAmounts.length >= 2;
696
- if (!hasRoundNumbers && !hasReusedAmounts) {
697
- return null;
1089
+ if (hasRoundNumbers && roundNumbers.length >= 5) {
1090
+ signals.push({
1091
+ id: "amount-round-numbers",
1092
+ name: "Frequent Round Number Transfers",
1093
+ severity: "LOW",
1094
+ category: "behavioral",
1095
+ reason: `${roundNumbers.length} round-number transfers detected (e.g., 1 SOL, 10 SOL).`,
1096
+ impact: "Round numbers are common on Solana and relatively benign alone. Combined with other patterns, they can contribute to fingerprinting.",
1097
+ mitigation: "Vary amounts slightly if possible, but this is low priority on Solana.",
1098
+ evidence: [{
1099
+ description: `${roundNumbers.length} round-number transfers: ${roundNumbers.slice(0, 5).join(", ")}...`,
1100
+ severity: "LOW",
1101
+ type: "amount",
1102
+ data: { roundNumbers: roundNumbers.slice(0, 5) }
1103
+ }]
1104
+ });
698
1105
  }
699
- let severity = "LOW";
700
- if (hasRoundNumbers && roundNumbers.length >= 5 || reusedAmounts.length >= 5) {
701
- severity = "HIGH";
702
- } else if (hasRoundNumbers && roundNumbers.length >= 3 || reusedAmounts.length >= 3) {
703
- severity = "MEDIUM";
1106
+ const suspiciousReuse = reusedAmounts.filter(([_, data]) => {
1107
+ return data.counterparties.size === 1 && data.count >= 3;
1108
+ });
1109
+ if (suspiciousReuse.length > 0) {
1110
+ const evidence = suspiciousReuse.slice(0, 3).map(([amountKey, data]) => {
1111
+ const [amount, token] = amountKey.split("-");
1112
+ const counterparty = Array.from(data.counterparties)[0];
1113
+ return {
1114
+ description: `${amount} ${token} sent to ${counterparty.slice(0, 8)}... ${data.count} times`,
1115
+ severity: "MEDIUM",
1116
+ type: "amount",
1117
+ data: { amount: parseFloat(amount), token, count: data.count, counterparty }
1118
+ };
1119
+ });
1120
+ signals.push({
1121
+ id: "amount-reuse-counterparty",
1122
+ name: "Same Amount to Same Counterparty",
1123
+ severity: "MEDIUM",
1124
+ category: "behavioral",
1125
+ reason: `${suspiciousReuse.length} amount(s) repeatedly sent to the same counterparty.`,
1126
+ impact: "Sending the same amount to the same address multiple times creates a strong pattern. This is likely automated or habitual behavior.",
1127
+ mitigation: "Vary amounts when sending to the same address, or use privacy protocols.",
1128
+ evidence
1129
+ });
704
1130
  }
705
- const evidence = [];
706
- if (hasRoundNumbers) {
707
- evidence.push({
708
- type: "amount",
709
- description: `${roundNumbers.length} round-number transfers detected`,
710
- data: { roundNumbers: roundNumbers.slice(0, 5) }
1131
+ const signerReuse = reusedAmounts.filter(([_, data]) => {
1132
+ return data.signers.size <= 2 && data.count >= 3;
1133
+ });
1134
+ if (signerReuse.length > 0 && suspiciousReuse.length === 0) {
1135
+ const evidence = signerReuse.slice(0, 3).map(([amountKey, data]) => {
1136
+ const [amount, token] = amountKey.split("-");
1137
+ return {
1138
+ description: `${amount} ${token} used ${data.count} times with ${data.signers.size} signer(s)`,
1139
+ severity: "LOW",
1140
+ type: "amount",
1141
+ data: { amount: parseFloat(amount), token, count: data.count }
1142
+ };
1143
+ });
1144
+ signals.push({
1145
+ id: "amount-reuse-pattern",
1146
+ name: "Repeated Amount Pattern",
1147
+ severity: "LOW",
1148
+ category: "behavioral",
1149
+ reason: `${signerReuse.length} amount(s) are reused multiple times with consistent signers.`,
1150
+ impact: "Amount reuse alone is relatively weak on Solana, but combined with other signals it contributes to behavioral fingerprinting.",
1151
+ mitigation: "Vary transaction amounts to reduce pattern visibility.",
1152
+ evidence
711
1153
  });
712
1154
  }
713
- if (hasReusedAmounts) {
714
- const topReused = reusedAmounts.slice(0, 3);
715
- for (const [amountKey, count] of topReused) {
1155
+ const veryReused = reusedAmounts.filter(([_, data]) => data.count >= 5);
1156
+ if (veryReused.length > 0 && suspiciousReuse.length === 0 && signerReuse.length === 0) {
1157
+ const evidence = veryReused.slice(0, 3).map(([amountKey, data]) => {
716
1158
  const [amount, token] = amountKey.split("-");
717
- evidence.push({
1159
+ return {
1160
+ description: `${amount} ${token} used ${data.count} times across ${data.counterparties.size} counterparties`,
1161
+ severity: data.count > 10 ? "MEDIUM" : "LOW",
718
1162
  type: "amount",
719
- description: `Amount ${amount} ${token} used ${count} times`,
720
- data: { amount: parseFloat(amount), token, count }
721
- });
722
- }
1163
+ data: { amount: parseFloat(amount), token, count: data.count }
1164
+ };
1165
+ });
1166
+ const maxCount = veryReused[0][1].count;
1167
+ const severity = maxCount > 10 ? "MEDIUM" : "LOW";
1168
+ signals.push({
1169
+ id: "amount-reuse-frequency",
1170
+ name: "High-Frequency Amount Reuse",
1171
+ severity,
1172
+ category: "behavioral",
1173
+ reason: `${veryReused.length} amount(s) are used very frequently (${maxCount} times for top amount).`,
1174
+ impact: "Extremely frequent reuse of specific amounts suggests automation or habitual behavior, creating a detectable pattern.",
1175
+ mitigation: "If running automated systems, add randomization to amounts.",
1176
+ evidence
1177
+ });
723
1178
  }
724
- return {
725
- id: "amount-reuse",
726
- name: "Deterministic Amount Patterns",
727
- severity,
728
- reason: `Wallet uses ${hasRoundNumbers ? "round numbers" : "repeated amounts"} in transactions`,
729
- impact: "Using the same amounts repeatedly or sending round numbers creates fingerprints that can be used to link transactions and identify patterns in your activity.",
730
- evidence,
731
- mitigation: "Vary transaction amounts slightly, avoid round numbers, and consider using privacy protocols that obscure amounts.",
732
- confidence: hasRoundNumbers ? 0.85 : 0.75
733
- };
1179
+ return signals;
734
1180
  }
735
1181
  function detectTimingPatterns(context) {
736
1182
  if (!context.timeRange.earliest || !context.timeRange.latest) {
@@ -893,12 +1339,536 @@ function detectBalanceTraceability(context) {
893
1339
  confidence: 0.7
894
1340
  };
895
1341
  }
1342
+ function detectFeePayerReuse(context) {
1343
+ const signals = [];
1344
+ if (context.targetType === "transaction") {
1345
+ return signals;
1346
+ }
1347
+ if (!context.feePayers || !context.transactions || context.transactions.length === 0) {
1348
+ return signals;
1349
+ }
1350
+ const feePayers = context.feePayers;
1351
+ const target = context.target;
1352
+ const targetIsFeePayer = feePayers.has(target);
1353
+ const onlyTargetPays = feePayers.size === 1 && targetIsFeePayer;
1354
+ if (onlyTargetPays) {
1355
+ return signals;
1356
+ }
1357
+ if (feePayers.size > 1 && targetIsFeePayer) {
1358
+ const externalFeePayers = Array.from(feePayers).filter((fp) => fp !== target);
1359
+ const feePayerCounts = /* @__PURE__ */ new Map();
1360
+ for (const tx of context.transactions) {
1361
+ if (tx.feePayer !== target) {
1362
+ feePayerCounts.set(tx.feePayer, (feePayerCounts.get(tx.feePayer) || 0) + 1);
1363
+ }
1364
+ }
1365
+ const evidence = [];
1366
+ for (const [feePayer, count] of feePayerCounts) {
1367
+ evidence.push({
1368
+ description: `${feePayer} paid fees for ${count} transaction(s)`,
1369
+ severity: count > 1 ? "HIGH" : "MEDIUM",
1370
+ reference: void 0
1371
+ });
1372
+ }
1373
+ const knownFeePayerLabel = externalFeePayers.find((fp) => context.labels.has(fp));
1374
+ const knownLabel = knownFeePayerLabel ? context.labels.get(knownFeePayerLabel) : null;
1375
+ signals.push({
1376
+ id: "fee-payer-external",
1377
+ name: "External Fee Payer Detected",
1378
+ severity: knownLabel ? "HIGH" : "MEDIUM",
1379
+ category: "linkability",
1380
+ reason: `${externalFeePayers.length} external wallet(s) paid fees for transactions involving this address${knownLabel ? `, including known entity: ${knownLabel.name}` : ""}.`,
1381
+ 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.",
1382
+ 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.",
1383
+ evidence
1384
+ });
1385
+ }
1386
+ if (!targetIsFeePayer && feePayers.size > 0) {
1387
+ const allFeePayers = Array.from(feePayers);
1388
+ const feePayerCounts = /* @__PURE__ */ new Map();
1389
+ for (const tx of context.transactions) {
1390
+ feePayerCounts.set(tx.feePayer, (feePayerCounts.get(tx.feePayer) || 0) + 1);
1391
+ }
1392
+ const evidence = [];
1393
+ for (const [feePayer, count] of feePayerCounts) {
1394
+ const label = context.labels.get(feePayer);
1395
+ evidence.push({
1396
+ description: `${feePayer}${label ? ` (${label.name})` : ""} paid fees for ${count} transaction(s)`,
1397
+ severity: "HIGH",
1398
+ reference: void 0
1399
+ });
1400
+ }
1401
+ const maxCount = Math.max(...Array.from(feePayerCounts.values()));
1402
+ const repeatedFeePayer = maxCount > 1;
1403
+ signals.push({
1404
+ id: "fee-payer-never-self",
1405
+ name: "Never Self-Pays Transaction Fees",
1406
+ severity: repeatedFeePayer ? "HIGH" : "HIGH",
1407
+ // Always HIGH - this is critical
1408
+ category: "linkability",
1409
+ reason: `This address has NEVER paid its own transaction fees. All ${context.transactionCount} transaction(s) were paid by ${allFeePayers.length} external wallet(s).`,
1410
+ 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.",
1411
+ 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).",
1412
+ evidence
1413
+ });
1414
+ }
1415
+ if (context.targetType === "program") {
1416
+ const feePayerCounts = /* @__PURE__ */ new Map();
1417
+ for (const tx of context.transactions) {
1418
+ if (!feePayerCounts.has(tx.feePayer)) {
1419
+ feePayerCounts.set(tx.feePayer, /* @__PURE__ */ new Set());
1420
+ }
1421
+ for (const signer of tx.signers) {
1422
+ feePayerCounts.get(tx.feePayer).add(signer);
1423
+ }
1424
+ }
1425
+ const multiFeePayerOperators = [];
1426
+ for (const [feePayer, signers] of feePayerCounts) {
1427
+ if (signers.size > 1) {
1428
+ const txCount = context.transactions.filter((tx) => tx.feePayer === feePayer).length;
1429
+ multiFeePayerOperators.push({
1430
+ feePayer,
1431
+ signerCount: signers.size,
1432
+ txCount
1433
+ });
1434
+ }
1435
+ }
1436
+ if (multiFeePayerOperators.length > 0) {
1437
+ const evidence = multiFeePayerOperators.map((op) => ({
1438
+ description: `${op.feePayer} paid fees for ${op.txCount} transaction(s) involving ${op.signerCount} different signer(s)`,
1439
+ severity: "HIGH",
1440
+ reference: void 0
1441
+ }));
1442
+ signals.push({
1443
+ id: "fee-payer-multi-signer",
1444
+ name: "Fee Payer Controls Multiple Signers",
1445
+ severity: "HIGH",
1446
+ category: "linkability",
1447
+ reason: `${multiFeePayerOperators.length} fee payer(s) are paying fees for multiple different signers, suggesting centralized control or bot operation.`,
1448
+ impact: "All addresses funded by the same fee payer are linkable. This pattern exposes operational infrastructure.",
1449
+ mitigation: "If running bots or managing multiple accounts, use a unique fee payer for each to avoid linking them on-chain.",
1450
+ evidence
1451
+ });
1452
+ }
1453
+ }
1454
+ return signals;
1455
+ }
1456
+ function detectSignerOverlap(context) {
1457
+ const signals = [];
1458
+ if (context.transactionCount < 2) {
1459
+ return signals;
1460
+ }
1461
+ if (!context.transactions || context.transactions.length === 0) {
1462
+ return signals;
1463
+ }
1464
+ const signerFrequency = /* @__PURE__ */ new Map();
1465
+ const signerTransactions = /* @__PURE__ */ new Map();
1466
+ for (const tx of context.transactions) {
1467
+ for (const signer of tx.signers) {
1468
+ signerFrequency.set(signer, (signerFrequency.get(signer) || 0) + 1);
1469
+ if (!signerTransactions.has(signer)) {
1470
+ signerTransactions.set(signer, []);
1471
+ }
1472
+ signerTransactions.get(signer).push(tx.signature);
1473
+ }
1474
+ }
1475
+ const target = context.target;
1476
+ 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]);
1477
+ if (frequentSigners.length > 0) {
1478
+ const evidence = frequentSigners.map(([signer, count]) => {
1479
+ const label = context.labels.get(signer);
1480
+ return {
1481
+ description: `${signer}${label ? ` (${label.name})` : ""} signed ${count}/${context.transactionCount} transactions`,
1482
+ severity: count > context.transactionCount * 0.7 ? "HIGH" : "MEDIUM",
1483
+ reference: void 0
1484
+ };
1485
+ });
1486
+ const topSignerCount = frequentSigners[0][1];
1487
+ const severity = topSignerCount > context.transactionCount * 0.7 ? "HIGH" : "MEDIUM";
1488
+ signals.push({
1489
+ id: "signer-repeated",
1490
+ name: "Repeated Signer Across Transactions",
1491
+ severity,
1492
+ category: "linkability",
1493
+ reason: `${frequentSigners.length} address(es) repeatedly sign transactions involving the target. The most frequent signer appears in ${topSignerCount}/${context.transactionCount} transactions.`,
1494
+ impact: "Repeated signers create hard links between transactions. All transactions signed by the same address are trivially linkable.",
1495
+ mitigation: "If you control multiple addresses that sign together, they are permanently linked. Use separate signing keys for unrelated activities.",
1496
+ evidence
1497
+ });
1498
+ }
1499
+ const signerSets = /* @__PURE__ */ new Map();
1500
+ const signerSetExamples = /* @__PURE__ */ new Map();
1501
+ for (const tx of context.transactions) {
1502
+ const sortedSigners = [...tx.signers].sort();
1503
+ const setKey = JSON.stringify(sortedSigners);
1504
+ signerSets.set(setKey, (signerSets.get(setKey) || 0) + 1);
1505
+ if (!signerSetExamples.has(setKey)) {
1506
+ signerSetExamples.set(setKey, tx.signature);
1507
+ }
1508
+ }
1509
+ const repeatedSets = Array.from(signerSets.entries()).filter(([_, count]) => count > 1).sort((a, b) => b[1] - a[1]);
1510
+ if (repeatedSets.length > 0) {
1511
+ const evidence = repeatedSets.map(([setKey, count]) => {
1512
+ const signers = JSON.parse(setKey);
1513
+ const exampleSig = signerSetExamples.get(setKey);
1514
+ return {
1515
+ description: `${count} transactions with identical signer set: [${signers.map((s) => s.slice(0, 8)).join(", ")}...]`,
1516
+ severity: count > 2 ? "MEDIUM" : "LOW",
1517
+ reference: `https://solscan.io/tx/${exampleSig}`
1518
+ };
1519
+ });
1520
+ signals.push({
1521
+ id: "signer-set-reuse",
1522
+ name: "Repeated Multi-Signature Pattern",
1523
+ severity: "MEDIUM",
1524
+ category: "linkability",
1525
+ reason: `${repeatedSets.length} distinct signer set(s) are reused multiple times. This creates a unique fingerprint.`,
1526
+ impact: "Reused multi-sig patterns are highly unique and easily linkable. Even if addresses differ, the signer set pattern can identify related activity.",
1527
+ mitigation: "If using multi-sig for multiple transactions, rotate signing keys or use threshold signatures to vary the signer set.",
1528
+ evidence
1529
+ });
1530
+ }
1531
+ if (context.targetType === "program" || context.transactionCount > 10) {
1532
+ const signerCoSigners = /* @__PURE__ */ new Map();
1533
+ for (const tx of context.transactions) {
1534
+ for (const signer of tx.signers) {
1535
+ if (!signerCoSigners.has(signer)) {
1536
+ signerCoSigners.set(signer, /* @__PURE__ */ new Set());
1537
+ }
1538
+ for (const otherSigner of tx.signers) {
1539
+ if (otherSigner !== signer) {
1540
+ signerCoSigners.get(signer).add(otherSigner);
1541
+ }
1542
+ }
1543
+ }
1544
+ }
1545
+ const authorityCandidates = Array.from(signerCoSigners.entries()).filter(([_, coSigners]) => coSigners.size >= 3).sort((a, b) => b[1].size - a[1].size);
1546
+ if (authorityCandidates.length > 0) {
1547
+ const evidence = authorityCandidates.slice(0, 3).map(([signer, coSigners]) => {
1548
+ const label = context.labels.get(signer);
1549
+ const txCount = signerFrequency.get(signer) || 0;
1550
+ return {
1551
+ description: `${signer}${label ? ` (${label.name})` : ""} co-signed with ${coSigners.size} different addresses across ${txCount} transactions`,
1552
+ severity: "HIGH",
1553
+ reference: void 0
1554
+ };
1555
+ });
1556
+ signals.push({
1557
+ id: "signer-authority-hub",
1558
+ name: "Authority Signer Detected",
1559
+ severity: "HIGH",
1560
+ category: "linkability",
1561
+ reason: `${authorityCandidates.length} address(es) act as an authority, co-signing with multiple different wallets. This exposes a control hub.`,
1562
+ impact: "An authority signer links all accounts it co-signs with. This reveals organizational structure or bot infrastructure.",
1563
+ mitigation: 'Use unique authority keys for each logical group of accounts. Avoid having a single "master" signer.',
1564
+ evidence
1565
+ });
1566
+ }
1567
+ }
1568
+ return signals;
1569
+ }
1570
+ function detectInstructionFingerprinting(context) {
1571
+ const signals = [];
1572
+ if (context.transactionCount < 3) {
1573
+ return signals;
1574
+ }
1575
+ if (!context.transactions || context.transactions.length === 0) {
1576
+ return signals;
1577
+ }
1578
+ const sequenceFingerprints = /* @__PURE__ */ new Map();
1579
+ const sequenceExamples = /* @__PURE__ */ new Map();
1580
+ for (const tx of context.transactions) {
1581
+ const txInstructions = context.instructions.filter((inst) => inst.signature === tx.signature).map((inst) => inst.programId);
1582
+ if (txInstructions.length === 0) continue;
1583
+ const sequence = txInstructions.join("->");
1584
+ sequenceFingerprints.set(sequence, (sequenceFingerprints.get(sequence) || 0) + 1);
1585
+ if (!sequenceExamples.has(sequence)) {
1586
+ sequenceExamples.set(sequence, []);
1587
+ }
1588
+ sequenceExamples.get(sequence).push(tx.signature);
1589
+ }
1590
+ 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]);
1591
+ if (repeatedSequences.length > 0) {
1592
+ const evidence = repeatedSequences.slice(0, 5).map(([sequence, count]) => {
1593
+ const exampleSigs = sequenceExamples.get(sequence).slice(0, 2);
1594
+ const programs = sequence.split("->").map((p) => p.slice(0, 8) + "...").join(" \u2192 ");
1595
+ return {
1596
+ description: `Instruction sequence repeated ${count} times: ${programs}`,
1597
+ severity: count > context.transactionCount * 0.5 ? "MEDIUM" : "LOW",
1598
+ reference: `https://solscan.io/tx/${exampleSigs[0]}`
1599
+ };
1600
+ });
1601
+ const topSequenceCount = repeatedSequences[0][1];
1602
+ const severity = topSequenceCount > context.transactionCount * 0.5 ? "MEDIUM" : "LOW";
1603
+ signals.push({
1604
+ id: "instruction-sequence-pattern",
1605
+ name: "Repeated Instruction Sequence Pattern",
1606
+ severity,
1607
+ category: "behavioral",
1608
+ reason: `${repeatedSequences.length} distinct instruction sequence(s) are repeated multiple times. The most common pattern appears in ${topSequenceCount}/${context.transactionCount} transactions.`,
1609
+ impact: "Repeated instruction patterns create a behavioral fingerprint. Even with different addresses, these patterns can link related activity.",
1610
+ mitigation: "Vary the order or combination of operations. Add dummy instructions or randomize transaction structure where possible.",
1611
+ evidence
1612
+ });
1613
+ }
1614
+ const programUsage = /* @__PURE__ */ new Map();
1615
+ for (const inst of context.instructions) {
1616
+ programUsage.set(inst.programId, (programUsage.get(inst.programId) || 0) + 1);
1617
+ }
1618
+ const COMMON_PROGRAMS = [
1619
+ "11111111111111111111111111111111",
1620
+ // System
1621
+ "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA",
1622
+ // SPL Token
1623
+ "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL",
1624
+ // Associated Token
1625
+ "MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr",
1626
+ // Memo
1627
+ "ComputeBudget111111111111111111111111111111"
1628
+ // Compute Budget
1629
+ ];
1630
+ 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]);
1631
+ if (uniquePrograms.length >= 2) {
1632
+ const evidence = uniquePrograms.slice(0, 5).map(([programId, count]) => {
1633
+ const label = context.labels.get(programId);
1634
+ return {
1635
+ description: `${programId.slice(0, 8)}...${label ? ` (${label.name})` : ""} used in ${count} transactions`,
1636
+ severity: "LOW",
1637
+ reference: `https://solscan.io/account/${programId}`
1638
+ };
1639
+ });
1640
+ signals.push({
1641
+ id: "program-usage-profile",
1642
+ name: "Distinctive Program Usage Profile",
1643
+ severity: "LOW",
1644
+ category: "behavioral",
1645
+ reason: `This address uses ${uniquePrograms.length} less-common programs repeatedly. This creates a unique usage profile.`,
1646
+ impact: "Program usage patterns can fingerprint wallet behavior. Addresses with similar program usage profiles are likely related.",
1647
+ mitigation: "Using niche protocols creates a fingerprint. This is difficult to mitigate without changing your DeFi strategy.",
1648
+ evidence
1649
+ });
1650
+ }
1651
+ if (context.pdaInteractions.length > 0) {
1652
+ const pdaUsage = /* @__PURE__ */ new Map();
1653
+ for (const pda of context.pdaInteractions) {
1654
+ if (!pdaUsage.has(pda.pda)) {
1655
+ pdaUsage.set(pda.pda, { count: 0, programId: pda.programId });
1656
+ }
1657
+ pdaUsage.get(pda.pda).count++;
1658
+ }
1659
+ const repeatedPDAs = Array.from(pdaUsage.entries()).filter(([_, { count }]) => count > 1).sort((a, b) => b[1].count - a[1].count);
1660
+ if (repeatedPDAs.length > 0) {
1661
+ const evidence = repeatedPDAs.slice(0, 5).map(([pda, { count, programId }]) => ({
1662
+ description: `PDA ${pda.slice(0, 8)}... used ${count} times (program: ${programId.slice(0, 8)}...)`,
1663
+ severity: count > 3 ? "MEDIUM" : "LOW",
1664
+ reference: `https://solscan.io/account/${pda}`
1665
+ }));
1666
+ const maxPDAUsage = repeatedPDAs[0][1].count;
1667
+ const severity = maxPDAUsage > 3 ? "MEDIUM" : "LOW";
1668
+ signals.push({
1669
+ id: "pda-reuse-pattern",
1670
+ name: "Repeated PDA Interaction",
1671
+ severity,
1672
+ category: "behavioral",
1673
+ reason: `${repeatedPDAs.length} Program-Derived Address(es) are used repeatedly. The most common PDA appears in ${maxPDAUsage} transactions.`,
1674
+ impact: "Repeated PDA usage links transactions. If the PDA is specific to you (e.g., a user account), all interactions with it are linked.",
1675
+ mitigation: "Some PDA reuse is unavoidable (e.g., your DEX pool position). For sensitive operations, consider using fresh accounts or different protocols.",
1676
+ evidence
1677
+ });
1678
+ }
1679
+ }
1680
+ const programInstructions = /* @__PURE__ */ new Map();
1681
+ for (const inst of context.instructions) {
1682
+ if (!programInstructions.has(inst.programId)) {
1683
+ programInstructions.set(inst.programId, []);
1684
+ }
1685
+ if (inst.data) {
1686
+ programInstructions.get(inst.programId).push(inst.data);
1687
+ }
1688
+ }
1689
+ for (const [programId, dataList] of programInstructions) {
1690
+ if (dataList.length < 2) continue;
1691
+ const typeMap = /* @__PURE__ */ new Map();
1692
+ for (const data of dataList) {
1693
+ if (data && typeof data === "object" && "type" in data) {
1694
+ const type = String(data.type);
1695
+ typeMap.set(type, (typeMap.get(type) || 0) + 1);
1696
+ }
1697
+ }
1698
+ const repeatedTypes = Array.from(typeMap.entries()).filter(([_, count]) => count >= 2).sort((a, b) => b[1] - a[1]);
1699
+ if (repeatedTypes.length > 0 && repeatedTypes[0][1] >= 3) {
1700
+ const [instructionType, count] = repeatedTypes[0];
1701
+ const label = context.labels.get(programId);
1702
+ signals.push({
1703
+ id: `instruction-type-${programId.slice(0, 8)}`,
1704
+ name: "Repeated Instruction Type",
1705
+ severity: "LOW",
1706
+ category: "behavioral",
1707
+ reason: `The instruction type "${instructionType}" on program ${programId.slice(0, 8)}...${label ? ` (${label.name})` : ""} is used ${count} times.`,
1708
+ impact: "Repeated instruction types on the same program suggest automated behavior or specific strategy execution.",
1709
+ mitigation: "This is generally low-risk but contributes to behavioral fingerprinting. Diversify your transaction types if possible.",
1710
+ evidence: [{
1711
+ description: `"${instructionType}" instruction used ${count} times`,
1712
+ severity: "LOW",
1713
+ reference: void 0
1714
+ }]
1715
+ });
1716
+ }
1717
+ }
1718
+ return signals;
1719
+ }
1720
+ function detectTokenAccountLifecycle(context) {
1721
+ const signals = [];
1722
+ if (!context.tokenAccountEvents || context.tokenAccountEvents.length === 0) {
1723
+ return signals;
1724
+ }
1725
+ const accountEvents = /* @__PURE__ */ new Map();
1726
+ for (const event of context.tokenAccountEvents) {
1727
+ if (!accountEvents.has(event.tokenAccount)) {
1728
+ accountEvents.set(event.tokenAccount, []);
1729
+ }
1730
+ accountEvents.get(event.tokenAccount).push(event);
1731
+ }
1732
+ const createEvents = context.tokenAccountEvents.filter((e) => e.type === "create");
1733
+ const closeEvents = context.tokenAccountEvents.filter((e) => e.type === "close");
1734
+ if (createEvents.length >= 2 && closeEvents.length >= 2) {
1735
+ const refundDestinations = /* @__PURE__ */ new Map();
1736
+ const totalRefunded = closeEvents.reduce((sum, event) => {
1737
+ if (event.rentRefund) {
1738
+ refundDestinations.set(event.owner, (refundDestinations.get(event.owner) || 0) + event.rentRefund);
1739
+ return sum + event.rentRefund;
1740
+ }
1741
+ return sum;
1742
+ }, 0);
1743
+ if (refundDestinations.size > 0) {
1744
+ const evidence = Array.from(refundDestinations.entries()).map(([owner, amount]) => ({
1745
+ description: `${amount.toFixed(4)} SOL refunded to ${owner.slice(0, 8)}... from ${closeEvents.filter((e) => e.owner === owner).length} closed account(s)`,
1746
+ severity: "MEDIUM",
1747
+ reference: void 0
1748
+ }));
1749
+ signals.push({
1750
+ id: "token-account-churn",
1751
+ name: "Frequent Token Account Creation/Closure",
1752
+ severity: "MEDIUM",
1753
+ category: "behavioral",
1754
+ reason: `${createEvents.length} token account(s) created and ${closeEvents.length} closed. Rent refunds totaling ${totalRefunded.toFixed(4)} SOL expose ownership.`,
1755
+ impact: 'Rent refunds link temporary token accounts back to the owner wallet. This pattern defeats the purpose of using "burner" accounts.',
1756
+ 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.",
1757
+ evidence
1758
+ });
1759
+ }
1760
+ }
1761
+ const completeLifecycles = [];
1762
+ for (const [tokenAccount, events] of accountEvents) {
1763
+ const creates = events.filter((e) => e.type === "create");
1764
+ const closes = events.filter((e) => e.type === "close");
1765
+ if (creates.length > 0 && closes.length > 0) {
1766
+ const createTime = creates[0].blockTime;
1767
+ const closeTime = closes[closes.length - 1].blockTime;
1768
+ if (createTime && closeTime) {
1769
+ const duration = closeTime - createTime;
1770
+ completeLifecycles.push({ tokenAccount, events, duration });
1771
+ }
1772
+ }
1773
+ }
1774
+ const shortLived = completeLifecycles.filter((lc) => lc.duration < 3600);
1775
+ if (shortLived.length >= 2) {
1776
+ const evidence = shortLived.slice(0, 5).map((lc) => {
1777
+ const durationMin = Math.floor(lc.duration / 60);
1778
+ const closeEvent = lc.events.find((e) => e.type === "close");
1779
+ return {
1780
+ description: `${lc.tokenAccount.slice(0, 8)}... lived for ${durationMin} minute(s)${closeEvent?.rentRefund ? `, refunded ${closeEvent.rentRefund.toFixed(4)} SOL` : ""}`,
1781
+ severity: "LOW",
1782
+ reference: void 0
1783
+ };
1784
+ });
1785
+ signals.push({
1786
+ id: "token-account-short-lived",
1787
+ name: "Short-Lived Token Accounts",
1788
+ severity: "LOW",
1789
+ category: "behavioral",
1790
+ reason: `${shortLived.length} token account(s) were created and closed within an hour, suggesting burner account usage.`,
1791
+ impact: "Short-lived accounts suggest privacy-conscious behavior, but rent refunds still create linkage.",
1792
+ mitigation: "For true privacy, do not close accounts immediately. The rent refund links the burner back to you.",
1793
+ evidence
1794
+ });
1795
+ }
1796
+ const ownerAccounts = /* @__PURE__ */ new Map();
1797
+ for (const event of context.tokenAccountEvents) {
1798
+ if (event.type === "create") {
1799
+ if (!ownerAccounts.has(event.owner)) {
1800
+ ownerAccounts.set(event.owner, /* @__PURE__ */ new Set());
1801
+ }
1802
+ ownerAccounts.get(event.owner).add(event.tokenAccount);
1803
+ }
1804
+ }
1805
+ const multiAccountOwners = Array.from(ownerAccounts.entries()).filter(([_, accounts]) => accounts.size >= 2).sort((a, b) => b[1].size - a[1].size);
1806
+ if (multiAccountOwners.length > 0) {
1807
+ const [owner, accounts] = multiAccountOwners[0];
1808
+ const isTarget = owner === context.target;
1809
+ if (!isTarget || multiAccountOwners.length > 1) {
1810
+ const evidence = multiAccountOwners.slice(0, 3).map(([own, accs]) => {
1811
+ const label = context.labels.get(own);
1812
+ return {
1813
+ description: `${own.slice(0, 8)}...${label ? ` (${label.name})` : ""} owns ${accs.size} token account(s)`,
1814
+ severity: "LOW",
1815
+ reference: void 0
1816
+ };
1817
+ });
1818
+ signals.push({
1819
+ id: "token-account-common-owner",
1820
+ name: "Common Owner Across Token Accounts",
1821
+ severity: "LOW",
1822
+ category: "linkability",
1823
+ reason: `${multiAccountOwners.length} wallet(s) control multiple token accounts. The top owner controls ${accounts.size} accounts.`,
1824
+ impact: "All token accounts with the same owner are trivially linked.",
1825
+ mitigation: "This is inherent to Solana's token account model and cannot be avoided.",
1826
+ evidence
1827
+ });
1828
+ }
1829
+ }
1830
+ const rentRefundReceivers = /* @__PURE__ */ new Map();
1831
+ for (const event of context.tokenAccountEvents) {
1832
+ if (event.type === "close" && event.rentRefund) {
1833
+ const current = rentRefundReceivers.get(event.owner) || { count: 0, total: 0 };
1834
+ current.count++;
1835
+ current.total += event.rentRefund;
1836
+ rentRefundReceivers.set(event.owner, current);
1837
+ }
1838
+ }
1839
+ const significantRefunds = Array.from(rentRefundReceivers.entries()).filter(([_, { count }]) => count >= 3).sort((a, b) => b[1].count - a[1].count);
1840
+ if (significantRefunds.length > 0) {
1841
+ const evidence = significantRefunds.slice(0, 3).map(([owner, { count, total }]) => ({
1842
+ description: `${owner.slice(0, 8)}... received ${count} rent refunds totaling ${total.toFixed(4)} SOL`,
1843
+ severity: "MEDIUM",
1844
+ reference: void 0
1845
+ }));
1846
+ const [topOwner, topData] = significantRefunds[0];
1847
+ signals.push({
1848
+ id: "rent-refund-clustering",
1849
+ name: "Rent Refund Clustering",
1850
+ severity: "MEDIUM",
1851
+ category: "linkability",
1852
+ reason: `${significantRefunds.length} address(es) receive multiple rent refunds. ${topOwner.slice(0, 8)}... received ${topData.count} refunds.`,
1853
+ impact: "Rent refunds link closed token accounts back to a central wallet. This exposes the control structure.",
1854
+ mitigation: "Do not close token accounts if privacy is important. The small rent cost (~0.002 SOL) is cheaper than the privacy loss.",
1855
+ evidence
1856
+ });
1857
+ }
1858
+ return signals;
1859
+ }
896
1860
  var REPORT_VERSION = "1.0.0";
897
1861
  var HEURISTICS = [
1862
+ // Solana-specific (highest priority)
1863
+ detectFeePayerReuse,
1864
+ detectSignerOverlap,
1865
+ detectKnownEntityInteraction,
898
1866
  detectCounterpartyReuse,
899
- detectAmountReuse,
1867
+ detectInstructionFingerprinting,
1868
+ detectTokenAccountLifecycle,
1869
+ // Traditional heuristics
900
1870
  detectTimingPatterns,
901
- detectKnownEntityInteraction,
1871
+ detectAmountReuse,
902
1872
  detectBalanceTraceability
903
1873
  ];
904
1874
  function calculateOverallRisk(signals) {
@@ -923,10 +1893,22 @@ function generateMitigations(signals) {
923
1893
  }
924
1894
  mitigations.add("Consider using multiple wallets to compartmentalize different activities.");
925
1895
  const signalIds = new Set(signals.map((s) => s.id));
1896
+ if (signalIds.has("fee-payer-never-self") || signalIds.has("fee-payer-external")) {
1897
+ mitigations.add("Always pay your own transaction fees to avoid linkage.");
1898
+ }
1899
+ if (signalIds.has("signer-repeated") || signalIds.has("signer-set-reuse")) {
1900
+ mitigations.add("Use separate signing keys for unrelated activities.");
1901
+ }
1902
+ if (signalIds.has("instruction-sequence-pattern") || signalIds.has("program-usage-profile")) {
1903
+ mitigations.add("Diversify transaction patterns and protocols to reduce behavioral fingerprinting.");
1904
+ }
1905
+ if (signalIds.has("token-account-churn") || signalIds.has("rent-refund-clustering")) {
1906
+ mitigations.add("Avoid closing token accounts if privacy is important - the rent refund creates linkage.");
1907
+ }
926
1908
  if (signalIds.has("known-entity-interaction")) {
927
1909
  mitigations.add("Avoid direct interactions between privacy-sensitive wallets and KYC services.");
928
1910
  }
929
- if (signalIds.has("counterparty-reuse")) {
1911
+ if (signalIds.has("counterparty-reuse") || signalIds.has("pda-reuse")) {
930
1912
  mitigations.add("Use different addresses for different counterparties or contexts.");
931
1913
  }
932
1914
  if (signalIds.has("timing-correlation") || signalIds.has("balance-traceability")) {
@@ -942,9 +1924,11 @@ function evaluateHeuristics(context) {
942
1924
  const signals = [];
943
1925
  for (const heuristic of HEURISTICS) {
944
1926
  try {
945
- const signal = heuristic(context);
946
- if (signal) {
947
- signals.push(signal);
1927
+ const result = heuristic(context);
1928
+ if (Array.isArray(result)) {
1929
+ signals.push(...result);
1930
+ } else if (result) {
1931
+ signals.push(result);
948
1932
  }
949
1933
  } catch (error) {
950
1934
  console.warn(`Heuristic evaluation failed:`, error);