solana-privacy-scanner 0.1.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +1160 -175
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -12,13 +12,14 @@ import { Connection } from "@solana/web3.js";
|
|
|
12
12
|
import { readFileSync } from "fs";
|
|
13
13
|
import { fileURLToPath } from "url";
|
|
14
14
|
import { dirname, join } from "path";
|
|
15
|
+
var DEFAULT_RPC_URL = "https://late-hardworking-waterfall.solana-mainnet.quiknode.pro/4017b48acf3a2a1665603cac096822ce4bec3a90/";
|
|
16
|
+
var VERSION = "0.3.0";
|
|
15
17
|
var RateLimiter = class {
|
|
16
|
-
maxConcurrency;
|
|
17
|
-
activeRequests = 0;
|
|
18
|
-
queue = [];
|
|
19
18
|
constructor(maxConcurrency) {
|
|
20
19
|
this.maxConcurrency = maxConcurrency;
|
|
21
20
|
}
|
|
21
|
+
activeRequests = 0;
|
|
22
|
+
queue = [];
|
|
22
23
|
async acquire() {
|
|
23
24
|
if (this.activeRequests < this.maxConcurrency) {
|
|
24
25
|
this.activeRequests++;
|
|
@@ -52,14 +53,16 @@ var RPCClient = class {
|
|
|
52
53
|
connection;
|
|
53
54
|
config;
|
|
54
55
|
rateLimiter;
|
|
55
|
-
constructor(
|
|
56
|
+
constructor(configOrUrl) {
|
|
57
|
+
const config2 = !configOrUrl ? {} : typeof configOrUrl === "string" ? { rpcUrl: configOrUrl } : configOrUrl;
|
|
58
|
+
const rpcUrl = (config2.rpcUrl || DEFAULT_RPC_URL).trim();
|
|
56
59
|
this.config = {
|
|
57
60
|
maxRetries: config2.maxRetries ?? 3,
|
|
58
61
|
retryDelay: config2.retryDelay ?? 1e3,
|
|
59
62
|
timeout: config2.timeout ?? 3e4,
|
|
60
63
|
maxConcurrency: config2.maxConcurrency ?? 10,
|
|
61
64
|
debug: config2.debug ?? false,
|
|
62
|
-
rpcUrl
|
|
65
|
+
rpcUrl
|
|
63
66
|
};
|
|
64
67
|
const connectionConfig = {
|
|
65
68
|
commitment: "confirmed",
|
|
@@ -90,7 +93,10 @@ var RPCClient = class {
|
|
|
90
93
|
} catch (error) {
|
|
91
94
|
lastError = error;
|
|
92
95
|
if (this.config.debug) {
|
|
93
|
-
console.error(
|
|
96
|
+
console.error(
|
|
97
|
+
`[RPCClient] Error in ${operationName} (attempt ${attempt + 1}/${this.config.maxRetries + 1}):`,
|
|
98
|
+
error
|
|
99
|
+
);
|
|
94
100
|
}
|
|
95
101
|
if (attempt < this.config.maxRetries) {
|
|
96
102
|
const delay = this.config.retryDelay * Math.pow(2, attempt);
|
|
@@ -99,7 +105,9 @@ var RPCClient = class {
|
|
|
99
105
|
}
|
|
100
106
|
}
|
|
101
107
|
this.rateLimiter.release();
|
|
102
|
-
throw new Error(
|
|
108
|
+
throw new Error(
|
|
109
|
+
`RPC operation ${operationName} failed after ${this.config.maxRetries + 1} attempts: ${lastError?.message}`
|
|
110
|
+
);
|
|
103
111
|
}
|
|
104
112
|
/**
|
|
105
113
|
* Get the underlying Solana Connection
|
|
@@ -121,20 +129,29 @@ var RPCClient = class {
|
|
|
121
129
|
* Get signatures for an address with retry and rate limiting
|
|
122
130
|
*/
|
|
123
131
|
async getSignaturesForAddress(address, options) {
|
|
124
|
-
return this.executeWithRetry(
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
+
);
|
|
128
142
|
}
|
|
129
143
|
/**
|
|
130
144
|
* Get transaction details with retry and rate limiting
|
|
131
145
|
*/
|
|
132
146
|
async getTransaction(signature, options) {
|
|
133
|
-
return this.executeWithRetry(
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
147
|
+
return this.executeWithRetry(
|
|
148
|
+
async () => {
|
|
149
|
+
return this.connection.getTransaction(signature, {
|
|
150
|
+
maxSupportedTransactionVersion: options?.maxSupportedTransactionVersion ?? 0
|
|
151
|
+
});
|
|
152
|
+
},
|
|
153
|
+
`getTransaction(${signature})`
|
|
154
|
+
);
|
|
138
155
|
}
|
|
139
156
|
/**
|
|
140
157
|
* Get multiple transactions in parallel (respects rate limiting)
|
|
@@ -147,54 +164,69 @@ var RPCClient = class {
|
|
|
147
164
|
* Get token accounts by owner with retry and rate limiting
|
|
148
165
|
*/
|
|
149
166
|
async getTokenAccountsByOwner(ownerAddress, mintAddress) {
|
|
150
|
-
return this.executeWithRetry(
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
+
);
|
|
163
183
|
}
|
|
164
184
|
/**
|
|
165
185
|
* Get program accounts with retry and rate limiting
|
|
166
186
|
*/
|
|
167
187
|
async getProgramAccounts(programId, config2) {
|
|
168
|
-
return this.executeWithRetry(
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
+
);
|
|
172
195
|
}
|
|
173
196
|
/**
|
|
174
197
|
* Get account info with retry and rate limiting
|
|
175
198
|
*/
|
|
176
199
|
async getAccountInfo(address) {
|
|
177
|
-
return this.executeWithRetry(
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
+
);
|
|
181
207
|
}
|
|
182
208
|
/**
|
|
183
209
|
* Get multiple account infos in parallel (respects rate limiting)
|
|
184
210
|
*/
|
|
185
211
|
async getMultipleAccountsInfo(addresses) {
|
|
186
|
-
return this.executeWithRetry(
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
+
);
|
|
191
220
|
}
|
|
192
221
|
/**
|
|
193
222
|
* Check if the RPC connection is healthy
|
|
194
223
|
*/
|
|
195
224
|
async healthCheck() {
|
|
196
225
|
try {
|
|
197
|
-
const version = await this.executeWithRetry(
|
|
226
|
+
const version = await this.executeWithRetry(
|
|
227
|
+
() => this.connection.getVersion(),
|
|
228
|
+
"healthCheck"
|
|
229
|
+
);
|
|
198
230
|
return !!version;
|
|
199
231
|
} catch {
|
|
200
232
|
return false;
|
|
@@ -204,23 +236,32 @@ var RPCClient = class {
|
|
|
204
236
|
async function collectWalletData(client, address, options = {}) {
|
|
205
237
|
const maxSignatures = options.maxSignatures ?? 100;
|
|
206
238
|
const includeTokenAccounts = options.includeTokenAccounts ?? true;
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
+
}
|
|
210
247
|
const transactions = [];
|
|
211
248
|
const BATCH_SIZE = 10;
|
|
212
249
|
for (let i = 0; i < signatures.length; i += BATCH_SIZE) {
|
|
213
250
|
const batch = signatures.slice(i, i + BATCH_SIZE);
|
|
214
251
|
const batchSignatures = batch.map((sig) => sig.signature);
|
|
215
|
-
|
|
216
|
-
|
|
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
|
|
252
|
+
try {
|
|
253
|
+
const txs = await client.getTransactions(batchSignatures, {
|
|
254
|
+
maxSupportedTransactionVersion: 0
|
|
223
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);
|
|
224
265
|
}
|
|
225
266
|
}
|
|
226
267
|
let tokenAccounts = [];
|
|
@@ -240,9 +281,14 @@ async function collectWalletData(client, address, options = {}) {
|
|
|
240
281
|
};
|
|
241
282
|
}
|
|
242
283
|
async function collectTransactionData(client, signature) {
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
+
}
|
|
246
292
|
return {
|
|
247
293
|
signature,
|
|
248
294
|
transaction,
|
|
@@ -309,6 +355,162 @@ var PROGRAM_IDS = {
|
|
|
309
355
|
MEMO: "MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr",
|
|
310
356
|
MEMO_V1: "Memo1UhkJRfHyvLMcVucJwxXeuD728EqVDDwQDxFMNo"
|
|
311
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
|
+
}
|
|
312
514
|
function extractSOLTransfers(tx, signature) {
|
|
313
515
|
const transfers = [];
|
|
314
516
|
if (!tx.meta || !tx.transaction) {
|
|
@@ -330,14 +532,11 @@ function extractSOLTransfers(tx, signature) {
|
|
|
330
532
|
continue;
|
|
331
533
|
}
|
|
332
534
|
const diff = post - pre;
|
|
333
|
-
if (diff === 0)
|
|
334
|
-
continue;
|
|
535
|
+
if (diff === 0) continue;
|
|
335
536
|
const account = accountKeys[i];
|
|
336
|
-
if (!account)
|
|
337
|
-
continue;
|
|
537
|
+
if (!account) continue;
|
|
338
538
|
const address = typeof account === "string" ? account : account.pubkey?.toString();
|
|
339
|
-
if (!address)
|
|
340
|
-
continue;
|
|
539
|
+
if (!address) continue;
|
|
341
540
|
if (diff > 0) {
|
|
342
541
|
for (let j = 0; j < accountKeys.length; j++) {
|
|
343
542
|
const preSender = preBalances[j];
|
|
@@ -347,11 +546,9 @@ function extractSOLTransfers(tx, signature) {
|
|
|
347
546
|
}
|
|
348
547
|
if (postSender < preSender) {
|
|
349
548
|
const sender = accountKeys[j];
|
|
350
|
-
if (!sender)
|
|
351
|
-
continue;
|
|
549
|
+
if (!sender) continue;
|
|
352
550
|
const senderAddress = typeof sender === "string" ? sender : sender.pubkey?.toString();
|
|
353
|
-
if (!senderAddress)
|
|
354
|
-
continue;
|
|
551
|
+
if (!senderAddress) continue;
|
|
355
552
|
transfers.push({
|
|
356
553
|
from: senderAddress,
|
|
357
554
|
to: address,
|
|
@@ -377,7 +574,9 @@ function extractSPLTransfers(tx, signature) {
|
|
|
377
574
|
const postTokenBalances = tx.meta.postTokenBalances;
|
|
378
575
|
const balanceChanges = /* @__PURE__ */ new Map();
|
|
379
576
|
for (const post of postTokenBalances) {
|
|
380
|
-
const pre = preTokenBalances.find(
|
|
577
|
+
const pre = preTokenBalances.find(
|
|
578
|
+
(p) => p.accountIndex === post.accountIndex && p.mint === post.mint
|
|
579
|
+
);
|
|
381
580
|
const preAmount = pre?.uiTokenAmount.uiAmount ?? 0;
|
|
382
581
|
const postAmount = post.uiTokenAmount.uiAmount ?? 0;
|
|
383
582
|
const change = postAmount - preAmount;
|
|
@@ -398,20 +597,16 @@ function extractSPLTransfers(tx, signature) {
|
|
|
398
597
|
return;
|
|
399
598
|
}
|
|
400
599
|
const account = accountKeys[accountIndex];
|
|
401
|
-
if (!account)
|
|
402
|
-
return;
|
|
600
|
+
if (!account) return;
|
|
403
601
|
const address = typeof account === "string" ? account : account.pubkey?.toString();
|
|
404
|
-
if (!address)
|
|
405
|
-
return;
|
|
602
|
+
if (!address) return;
|
|
406
603
|
if (info.change > 0) {
|
|
407
604
|
balanceChanges.forEach((senderInfo, senderIndex) => {
|
|
408
605
|
if (senderInfo.mint === info.mint && senderInfo.change < 0 && senderIndex !== accountIndex && senderIndex < accountKeys.length) {
|
|
409
606
|
const sender = accountKeys[senderIndex];
|
|
410
|
-
if (!sender)
|
|
411
|
-
return;
|
|
607
|
+
if (!sender) return;
|
|
412
608
|
const senderAddress = typeof sender === "string" ? sender : sender.pubkey?.toString();
|
|
413
|
-
if (!senderAddress)
|
|
414
|
-
return;
|
|
609
|
+
if (!senderAddress) return;
|
|
415
610
|
transfers.push({
|
|
416
611
|
from: senderAddress,
|
|
417
612
|
to: address,
|
|
@@ -479,12 +674,20 @@ function extractInstructions(tx, signature) {
|
|
|
479
674
|
if ("parsed" in instruction) {
|
|
480
675
|
data = instruction.parsed;
|
|
481
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
|
+
}
|
|
482
684
|
instructions.push({
|
|
483
685
|
programId,
|
|
484
686
|
category,
|
|
485
687
|
signature,
|
|
486
688
|
blockTime: tx.blockTime,
|
|
487
|
-
data
|
|
689
|
+
data,
|
|
690
|
+
accounts: accounts.length > 0 ? accounts : void 0
|
|
488
691
|
});
|
|
489
692
|
}
|
|
490
693
|
return instructions;
|
|
@@ -518,15 +721,24 @@ function calculateTimeRange(transactions) {
|
|
|
518
721
|
function normalizeWalletData(rawData, labelProvider) {
|
|
519
722
|
const allTransfers = [];
|
|
520
723
|
const allInstructions = [];
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
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;
|
|
524
730
|
try {
|
|
525
731
|
const solTransfers = extractSOLTransfers(rawTx.transaction, rawTx.signature);
|
|
526
732
|
const splTransfers = extractSPLTransfers(rawTx.transaction, rawTx.signature);
|
|
527
733
|
allTransfers.push(...solTransfers, ...splTransfers);
|
|
528
734
|
const instructions = extractInstructions(rawTx.transaction, rawTx.signature);
|
|
529
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);
|
|
530
742
|
} catch (error) {
|
|
531
743
|
console.warn(`Failed to normalize transaction ${rawTx.signature}:`, error);
|
|
532
744
|
continue;
|
|
@@ -534,7 +746,7 @@ function normalizeWalletData(rawData, labelProvider) {
|
|
|
534
746
|
}
|
|
535
747
|
const counterparties = extractCounterparties(allTransfers, rawData.address);
|
|
536
748
|
const labels = labelProvider ? labelProvider.lookupMany(Array.from(counterparties)) : /* @__PURE__ */ new Map();
|
|
537
|
-
const timeRange = calculateTimeRange(
|
|
749
|
+
const timeRange = calculateTimeRange(transactions);
|
|
538
750
|
const tokenAccounts = rawData.tokenAccounts.map((ta) => {
|
|
539
751
|
try {
|
|
540
752
|
return {
|
|
@@ -546,22 +758,47 @@ function normalizeWalletData(rawData, labelProvider) {
|
|
|
546
758
|
return null;
|
|
547
759
|
}
|
|
548
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
|
+
}
|
|
549
773
|
return {
|
|
550
774
|
target: rawData.address,
|
|
551
775
|
targetType: "wallet",
|
|
552
776
|
transfers: allTransfers,
|
|
553
777
|
instructions: allInstructions,
|
|
554
778
|
counterparties,
|
|
555
|
-
labels
|
|
779
|
+
labels,
|
|
556
780
|
tokenAccounts,
|
|
557
781
|
timeRange,
|
|
558
|
-
transactionCount:
|
|
782
|
+
transactionCount: transactions.length,
|
|
783
|
+
// Solana-specific fields
|
|
784
|
+
transactions: allTransactionMetadata,
|
|
785
|
+
tokenAccountEvents: allTokenAccountEvents,
|
|
786
|
+
pdaInteractions: allPDAInteractions,
|
|
787
|
+
feePayers,
|
|
788
|
+
signers,
|
|
789
|
+
programs
|
|
559
790
|
};
|
|
560
791
|
}
|
|
561
792
|
function normalizeTransactionData(rawData, labelProvider) {
|
|
562
793
|
const allTransfers = [];
|
|
563
794
|
const allInstructions = [];
|
|
564
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();
|
|
565
802
|
if (rawData.transaction) {
|
|
566
803
|
try {
|
|
567
804
|
const solTransfers = extractSOLTransfers(rawData.transaction, rawData.signature);
|
|
@@ -569,6 +806,16 @@ function normalizeTransactionData(rawData, labelProvider) {
|
|
|
569
806
|
allTransfers.push(...solTransfers, ...splTransfers);
|
|
570
807
|
const instructions = extractInstructions(rawData.transaction, rawData.signature);
|
|
571
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);
|
|
572
819
|
const accountKeys = rawData.transaction.transaction.message.accountKeys;
|
|
573
820
|
if (accountKeys && Array.isArray(accountKeys)) {
|
|
574
821
|
for (const key of accountKeys) {
|
|
@@ -576,38 +823,65 @@ function normalizeTransactionData(rawData, labelProvider) {
|
|
|
576
823
|
counterparties.add(address);
|
|
577
824
|
}
|
|
578
825
|
}
|
|
826
|
+
for (const instruction of instructions) {
|
|
827
|
+
programs.add(instruction.programId);
|
|
828
|
+
}
|
|
579
829
|
} catch (error) {
|
|
580
830
|
console.warn(`Failed to normalize transaction ${rawData.signature}:`, error);
|
|
581
831
|
}
|
|
582
832
|
}
|
|
833
|
+
const labels = labelProvider ? labelProvider.lookupMany(Array.from(counterparties)) : /* @__PURE__ */ new Map();
|
|
583
834
|
return {
|
|
584
835
|
target: rawData.signature,
|
|
585
836
|
targetType: "transaction",
|
|
586
837
|
transfers: allTransfers,
|
|
587
838
|
instructions: allInstructions,
|
|
588
839
|
counterparties,
|
|
589
|
-
labels
|
|
840
|
+
labels,
|
|
590
841
|
tokenAccounts: [],
|
|
591
842
|
timeRange: {
|
|
592
|
-
earliest: rawData.blockTime,
|
|
593
|
-
latest: rawData.blockTime
|
|
843
|
+
earliest: rawData.transaction ? rawData.blockTime : null,
|
|
844
|
+
latest: rawData.transaction ? rawData.blockTime : null
|
|
594
845
|
},
|
|
595
|
-
transactionCount: 1
|
|
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
|
|
596
854
|
};
|
|
597
855
|
}
|
|
598
856
|
function normalizeProgramData(rawData, labelProvider) {
|
|
599
857
|
const allTransfers = [];
|
|
600
858
|
const allInstructions = [];
|
|
601
859
|
const counterparties = /* @__PURE__ */ new Set();
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
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;
|
|
605
869
|
try {
|
|
606
870
|
const solTransfers = extractSOLTransfers(rawTx.transaction, rawTx.signature);
|
|
607
871
|
const splTransfers = extractSPLTransfers(rawTx.transaction, rawTx.signature);
|
|
608
872
|
allTransfers.push(...solTransfers, ...splTransfers);
|
|
609
873
|
const instructions = extractInstructions(rawTx.transaction, rawTx.signature);
|
|
610
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);
|
|
611
885
|
const accountKeys = rawTx.transaction.transaction.message.accountKeys;
|
|
612
886
|
if (accountKeys && Array.isArray(accountKeys)) {
|
|
613
887
|
for (const key of accountKeys) {
|
|
@@ -615,12 +889,15 @@ function normalizeProgramData(rawData, labelProvider) {
|
|
|
615
889
|
counterparties.add(address);
|
|
616
890
|
}
|
|
617
891
|
}
|
|
892
|
+
for (const instruction of instructions) {
|
|
893
|
+
programs.add(instruction.programId);
|
|
894
|
+
}
|
|
618
895
|
} catch (error) {
|
|
619
896
|
console.warn(`Failed to normalize program transaction ${rawTx.signature}:`, error);
|
|
620
897
|
continue;
|
|
621
898
|
}
|
|
622
899
|
}
|
|
623
|
-
const timeRange = calculateTimeRange(
|
|
900
|
+
const timeRange = calculateTimeRange(transactions);
|
|
624
901
|
const labels = labelProvider ? labelProvider.lookupMany(Array.from(counterparties)) : /* @__PURE__ */ new Map();
|
|
625
902
|
return {
|
|
626
903
|
target: rawData.programId,
|
|
@@ -628,58 +905,164 @@ function normalizeProgramData(rawData, labelProvider) {
|
|
|
628
905
|
transfers: allTransfers,
|
|
629
906
|
instructions: allInstructions,
|
|
630
907
|
counterparties,
|
|
631
|
-
labels
|
|
908
|
+
labels,
|
|
632
909
|
tokenAccounts: [],
|
|
633
910
|
timeRange,
|
|
634
|
-
transactionCount:
|
|
911
|
+
transactionCount: transactions.length,
|
|
912
|
+
// Solana-specific fields
|
|
913
|
+
transactions: allTransactionMetadata,
|
|
914
|
+
tokenAccountEvents: allTokenAccountEvents,
|
|
915
|
+
pdaInteractions: allPDAInteractions,
|
|
916
|
+
feePayers,
|
|
917
|
+
signers,
|
|
918
|
+
programs
|
|
635
919
|
};
|
|
636
920
|
}
|
|
637
921
|
function detectCounterpartyReuse(context) {
|
|
638
|
-
|
|
639
|
-
|
|
922
|
+
const signals = [];
|
|
923
|
+
if (context.targetType !== "wallet" || context.transactionCount < 2) {
|
|
924
|
+
return signals;
|
|
640
925
|
}
|
|
641
|
-
if (context.
|
|
642
|
-
|
|
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
|
+
}
|
|
643
964
|
}
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
const
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
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
|
+
}
|
|
650
997
|
}
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
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
|
+
}
|
|
654
1026
|
}
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
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
|
+
}
|
|
663
1059
|
}
|
|
664
|
-
|
|
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
|
-
};
|
|
1060
|
+
return signals;
|
|
679
1061
|
}
|
|
680
1062
|
function detectAmountReuse(context) {
|
|
681
|
-
|
|
682
|
-
|
|
1063
|
+
const signals = [];
|
|
1064
|
+
if (context.transfers.length < 5) {
|
|
1065
|
+
return signals;
|
|
683
1066
|
}
|
|
684
1067
|
const amountCounts = /* @__PURE__ */ new Map();
|
|
685
1068
|
const roundNumbers = [];
|
|
@@ -688,49 +1071,114 @@ function detectAmountReuse(context) {
|
|
|
688
1071
|
roundNumbers.push(transfer.amount);
|
|
689
1072
|
}
|
|
690
1073
|
const amountKey = `${transfer.amount.toFixed(9)}-${transfer.token || "SOL"}`;
|
|
691
|
-
|
|
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
|
+
}
|
|
692
1087
|
}
|
|
693
|
-
const reusedAmounts = Array.from(amountCounts.entries()).filter(([_,
|
|
694
|
-
const hasRoundNumbers = roundNumbers.length >=
|
|
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;
|
|
695
1090
|
const hasReusedAmounts = reusedAmounts.length >= 2;
|
|
696
|
-
if (
|
|
697
|
-
|
|
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 and relatively benign alone. Combined with other patterns, they can contribute to fingerprinting.",
|
|
1099
|
+
mitigation: "Vary amounts slightly if possible, but this is low priority on Solana.",
|
|
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
|
+
});
|
|
698
1107
|
}
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
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
|
+
});
|
|
704
1132
|
}
|
|
705
|
-
const
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
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 on Solana, but combined with other signals it contributes to behavioral fingerprinting.",
|
|
1153
|
+
mitigation: "Vary transaction amounts to reduce pattern visibility.",
|
|
1154
|
+
evidence
|
|
711
1155
|
});
|
|
712
1156
|
}
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
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]) => {
|
|
716
1160
|
const [amount, token] = amountKey.split("-");
|
|
717
|
-
|
|
1161
|
+
return {
|
|
1162
|
+
description: `${amount} ${token} used ${data.count} times across ${data.counterparties.size} counterparties`,
|
|
1163
|
+
severity: data.count > 10 ? "MEDIUM" : "LOW",
|
|
718
1164
|
type: "amount",
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
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
|
+
});
|
|
723
1180
|
}
|
|
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
|
-
};
|
|
1181
|
+
return signals;
|
|
734
1182
|
}
|
|
735
1183
|
function detectTimingPatterns(context) {
|
|
736
1184
|
if (!context.timeRange.earliest || !context.timeRange.latest) {
|
|
@@ -893,12 +1341,536 @@ function detectBalanceTraceability(context) {
|
|
|
893
1341
|
confidence: 0.7
|
|
894
1342
|
};
|
|
895
1343
|
}
|
|
1344
|
+
function detectFeePayerReuse(context) {
|
|
1345
|
+
const signals = [];
|
|
1346
|
+
if (context.targetType === "transaction") {
|
|
1347
|
+
return signals;
|
|
1348
|
+
}
|
|
1349
|
+
if (!context.feePayers || !context.transactions || context.transactions.length === 0) {
|
|
1350
|
+
return signals;
|
|
1351
|
+
}
|
|
1352
|
+
const feePayers = context.feePayers;
|
|
1353
|
+
const target = context.target;
|
|
1354
|
+
const targetIsFeePayer = feePayers.has(target);
|
|
1355
|
+
const onlyTargetPays = feePayers.size === 1 && targetIsFeePayer;
|
|
1356
|
+
if (onlyTargetPays) {
|
|
1357
|
+
return signals;
|
|
1358
|
+
}
|
|
1359
|
+
if (feePayers.size > 1 && targetIsFeePayer) {
|
|
1360
|
+
const externalFeePayers = Array.from(feePayers).filter((fp) => fp !== target);
|
|
1361
|
+
const feePayerCounts = /* @__PURE__ */ new Map();
|
|
1362
|
+
for (const tx of context.transactions) {
|
|
1363
|
+
if (tx.feePayer !== target) {
|
|
1364
|
+
feePayerCounts.set(tx.feePayer, (feePayerCounts.get(tx.feePayer) || 0) + 1);
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
const evidence = [];
|
|
1368
|
+
for (const [feePayer, count] of feePayerCounts) {
|
|
1369
|
+
evidence.push({
|
|
1370
|
+
description: `${feePayer} paid fees for ${count} transaction(s)`,
|
|
1371
|
+
severity: count > 1 ? "HIGH" : "MEDIUM",
|
|
1372
|
+
reference: void 0
|
|
1373
|
+
});
|
|
1374
|
+
}
|
|
1375
|
+
const knownFeePayerLabel = externalFeePayers.find((fp) => context.labels.has(fp));
|
|
1376
|
+
const knownLabel = knownFeePayerLabel ? context.labels.get(knownFeePayerLabel) : null;
|
|
1377
|
+
signals.push({
|
|
1378
|
+
id: "fee-payer-external",
|
|
1379
|
+
name: "External Fee Payer Detected",
|
|
1380
|
+
severity: knownLabel ? "HIGH" : "MEDIUM",
|
|
1381
|
+
category: "linkability",
|
|
1382
|
+
reason: `${externalFeePayers.length} external wallet(s) paid fees for transactions involving this address${knownLabel ? `, including known entity: ${knownLabel.name}` : ""}.`,
|
|
1383
|
+
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.",
|
|
1384
|
+
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.",
|
|
1385
|
+
evidence
|
|
1386
|
+
});
|
|
1387
|
+
}
|
|
1388
|
+
if (!targetIsFeePayer && feePayers.size > 0) {
|
|
1389
|
+
const allFeePayers = Array.from(feePayers);
|
|
1390
|
+
const feePayerCounts = /* @__PURE__ */ new Map();
|
|
1391
|
+
for (const tx of context.transactions) {
|
|
1392
|
+
feePayerCounts.set(tx.feePayer, (feePayerCounts.get(tx.feePayer) || 0) + 1);
|
|
1393
|
+
}
|
|
1394
|
+
const evidence = [];
|
|
1395
|
+
for (const [feePayer, count] of feePayerCounts) {
|
|
1396
|
+
const label = context.labels.get(feePayer);
|
|
1397
|
+
evidence.push({
|
|
1398
|
+
description: `${feePayer}${label ? ` (${label.name})` : ""} paid fees for ${count} transaction(s)`,
|
|
1399
|
+
severity: "HIGH",
|
|
1400
|
+
reference: void 0
|
|
1401
|
+
});
|
|
1402
|
+
}
|
|
1403
|
+
const maxCount = Math.max(...Array.from(feePayerCounts.values()));
|
|
1404
|
+
const repeatedFeePayer = maxCount > 1;
|
|
1405
|
+
signals.push({
|
|
1406
|
+
id: "fee-payer-never-self",
|
|
1407
|
+
name: "Never Self-Pays Transaction Fees",
|
|
1408
|
+
severity: repeatedFeePayer ? "HIGH" : "HIGH",
|
|
1409
|
+
// Always HIGH - this is critical
|
|
1410
|
+
category: "linkability",
|
|
1411
|
+
reason: `This address has NEVER paid its own transaction fees. All ${context.transactionCount} transaction(s) were paid by ${allFeePayers.length} external wallet(s).`,
|
|
1412
|
+
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.",
|
|
1413
|
+
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).",
|
|
1414
|
+
evidence
|
|
1415
|
+
});
|
|
1416
|
+
}
|
|
1417
|
+
if (context.targetType === "program") {
|
|
1418
|
+
const feePayerCounts = /* @__PURE__ */ new Map();
|
|
1419
|
+
for (const tx of context.transactions) {
|
|
1420
|
+
if (!feePayerCounts.has(tx.feePayer)) {
|
|
1421
|
+
feePayerCounts.set(tx.feePayer, /* @__PURE__ */ new Set());
|
|
1422
|
+
}
|
|
1423
|
+
for (const signer of tx.signers) {
|
|
1424
|
+
feePayerCounts.get(tx.feePayer).add(signer);
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
const multiFeePayerOperators = [];
|
|
1428
|
+
for (const [feePayer, signers] of feePayerCounts) {
|
|
1429
|
+
if (signers.size > 1) {
|
|
1430
|
+
const txCount = context.transactions.filter((tx) => tx.feePayer === feePayer).length;
|
|
1431
|
+
multiFeePayerOperators.push({
|
|
1432
|
+
feePayer,
|
|
1433
|
+
signerCount: signers.size,
|
|
1434
|
+
txCount
|
|
1435
|
+
});
|
|
1436
|
+
}
|
|
1437
|
+
}
|
|
1438
|
+
if (multiFeePayerOperators.length > 0) {
|
|
1439
|
+
const evidence = multiFeePayerOperators.map((op) => ({
|
|
1440
|
+
description: `${op.feePayer} paid fees for ${op.txCount} transaction(s) involving ${op.signerCount} different signer(s)`,
|
|
1441
|
+
severity: "HIGH",
|
|
1442
|
+
reference: void 0
|
|
1443
|
+
}));
|
|
1444
|
+
signals.push({
|
|
1445
|
+
id: "fee-payer-multi-signer",
|
|
1446
|
+
name: "Fee Payer Controls Multiple Signers",
|
|
1447
|
+
severity: "HIGH",
|
|
1448
|
+
category: "linkability",
|
|
1449
|
+
reason: `${multiFeePayerOperators.length} fee payer(s) are paying fees for multiple different signers, suggesting centralized control or bot operation.`,
|
|
1450
|
+
impact: "All addresses funded by the same fee payer are linkable. This pattern exposes operational infrastructure.",
|
|
1451
|
+
mitigation: "If running bots or managing multiple accounts, use a unique fee payer for each to avoid linking them on-chain.",
|
|
1452
|
+
evidence
|
|
1453
|
+
});
|
|
1454
|
+
}
|
|
1455
|
+
}
|
|
1456
|
+
return signals;
|
|
1457
|
+
}
|
|
1458
|
+
function detectSignerOverlap(context) {
|
|
1459
|
+
const signals = [];
|
|
1460
|
+
if (context.transactionCount < 2) {
|
|
1461
|
+
return signals;
|
|
1462
|
+
}
|
|
1463
|
+
if (!context.transactions || context.transactions.length === 0) {
|
|
1464
|
+
return signals;
|
|
1465
|
+
}
|
|
1466
|
+
const signerFrequency = /* @__PURE__ */ new Map();
|
|
1467
|
+
const signerTransactions = /* @__PURE__ */ new Map();
|
|
1468
|
+
for (const tx of context.transactions) {
|
|
1469
|
+
for (const signer of tx.signers) {
|
|
1470
|
+
signerFrequency.set(signer, (signerFrequency.get(signer) || 0) + 1);
|
|
1471
|
+
if (!signerTransactions.has(signer)) {
|
|
1472
|
+
signerTransactions.set(signer, []);
|
|
1473
|
+
}
|
|
1474
|
+
signerTransactions.get(signer).push(tx.signature);
|
|
1475
|
+
}
|
|
1476
|
+
}
|
|
1477
|
+
const target = context.target;
|
|
1478
|
+
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]);
|
|
1479
|
+
if (frequentSigners.length > 0) {
|
|
1480
|
+
const evidence = frequentSigners.map(([signer, count]) => {
|
|
1481
|
+
const label = context.labels.get(signer);
|
|
1482
|
+
return {
|
|
1483
|
+
description: `${signer}${label ? ` (${label.name})` : ""} signed ${count}/${context.transactionCount} transactions`,
|
|
1484
|
+
severity: count > context.transactionCount * 0.7 ? "HIGH" : "MEDIUM",
|
|
1485
|
+
reference: void 0
|
|
1486
|
+
};
|
|
1487
|
+
});
|
|
1488
|
+
const topSignerCount = frequentSigners[0][1];
|
|
1489
|
+
const severity = topSignerCount > context.transactionCount * 0.7 ? "HIGH" : "MEDIUM";
|
|
1490
|
+
signals.push({
|
|
1491
|
+
id: "signer-repeated",
|
|
1492
|
+
name: "Repeated Signer Across Transactions",
|
|
1493
|
+
severity,
|
|
1494
|
+
category: "linkability",
|
|
1495
|
+
reason: `${frequentSigners.length} address(es) repeatedly sign transactions involving the target. The most frequent signer appears in ${topSignerCount}/${context.transactionCount} transactions.`,
|
|
1496
|
+
impact: "Repeated signers create hard links between transactions. All transactions signed by the same address are trivially linkable.",
|
|
1497
|
+
mitigation: "If you control multiple addresses that sign together, they are permanently linked. Use separate signing keys for unrelated activities.",
|
|
1498
|
+
evidence
|
|
1499
|
+
});
|
|
1500
|
+
}
|
|
1501
|
+
const signerSets = /* @__PURE__ */ new Map();
|
|
1502
|
+
const signerSetExamples = /* @__PURE__ */ new Map();
|
|
1503
|
+
for (const tx of context.transactions) {
|
|
1504
|
+
const sortedSigners = [...tx.signers].sort();
|
|
1505
|
+
const setKey = JSON.stringify(sortedSigners);
|
|
1506
|
+
signerSets.set(setKey, (signerSets.get(setKey) || 0) + 1);
|
|
1507
|
+
if (!signerSetExamples.has(setKey)) {
|
|
1508
|
+
signerSetExamples.set(setKey, tx.signature);
|
|
1509
|
+
}
|
|
1510
|
+
}
|
|
1511
|
+
const repeatedSets = Array.from(signerSets.entries()).filter(([_, count]) => count > 1).sort((a, b) => b[1] - a[1]);
|
|
1512
|
+
if (repeatedSets.length > 0) {
|
|
1513
|
+
const evidence = repeatedSets.map(([setKey, count]) => {
|
|
1514
|
+
const signers = JSON.parse(setKey);
|
|
1515
|
+
const exampleSig = signerSetExamples.get(setKey);
|
|
1516
|
+
return {
|
|
1517
|
+
description: `${count} transactions with identical signer set: [${signers.map((s) => s.slice(0, 8)).join(", ")}...]`,
|
|
1518
|
+
severity: count > 2 ? "MEDIUM" : "LOW",
|
|
1519
|
+
reference: `https://solscan.io/tx/${exampleSig}`
|
|
1520
|
+
};
|
|
1521
|
+
});
|
|
1522
|
+
signals.push({
|
|
1523
|
+
id: "signer-set-reuse",
|
|
1524
|
+
name: "Repeated Multi-Signature Pattern",
|
|
1525
|
+
severity: "MEDIUM",
|
|
1526
|
+
category: "linkability",
|
|
1527
|
+
reason: `${repeatedSets.length} distinct signer set(s) are reused multiple times. This creates a unique fingerprint.`,
|
|
1528
|
+
impact: "Reused multi-sig patterns are highly unique and easily linkable. Even if addresses differ, the signer set pattern can identify related activity.",
|
|
1529
|
+
mitigation: "If using multi-sig for multiple transactions, rotate signing keys or use threshold signatures to vary the signer set.",
|
|
1530
|
+
evidence
|
|
1531
|
+
});
|
|
1532
|
+
}
|
|
1533
|
+
if (context.targetType === "program" || context.transactionCount > 10) {
|
|
1534
|
+
const signerCoSigners = /* @__PURE__ */ new Map();
|
|
1535
|
+
for (const tx of context.transactions) {
|
|
1536
|
+
for (const signer of tx.signers) {
|
|
1537
|
+
if (!signerCoSigners.has(signer)) {
|
|
1538
|
+
signerCoSigners.set(signer, /* @__PURE__ */ new Set());
|
|
1539
|
+
}
|
|
1540
|
+
for (const otherSigner of tx.signers) {
|
|
1541
|
+
if (otherSigner !== signer) {
|
|
1542
|
+
signerCoSigners.get(signer).add(otherSigner);
|
|
1543
|
+
}
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
}
|
|
1547
|
+
const authorityCandidates = Array.from(signerCoSigners.entries()).filter(([_, coSigners]) => coSigners.size >= 3).sort((a, b) => b[1].size - a[1].size);
|
|
1548
|
+
if (authorityCandidates.length > 0) {
|
|
1549
|
+
const evidence = authorityCandidates.slice(0, 3).map(([signer, coSigners]) => {
|
|
1550
|
+
const label = context.labels.get(signer);
|
|
1551
|
+
const txCount = signerFrequency.get(signer) || 0;
|
|
1552
|
+
return {
|
|
1553
|
+
description: `${signer}${label ? ` (${label.name})` : ""} co-signed with ${coSigners.size} different addresses across ${txCount} transactions`,
|
|
1554
|
+
severity: "HIGH",
|
|
1555
|
+
reference: void 0
|
|
1556
|
+
};
|
|
1557
|
+
});
|
|
1558
|
+
signals.push({
|
|
1559
|
+
id: "signer-authority-hub",
|
|
1560
|
+
name: "Authority Signer Detected",
|
|
1561
|
+
severity: "HIGH",
|
|
1562
|
+
category: "linkability",
|
|
1563
|
+
reason: `${authorityCandidates.length} address(es) act as an authority, co-signing with multiple different wallets. This exposes a control hub.`,
|
|
1564
|
+
impact: "An authority signer links all accounts it co-signs with. This reveals organizational structure or bot infrastructure.",
|
|
1565
|
+
mitigation: 'Use unique authority keys for each logical group of accounts. Avoid having a single "master" signer.',
|
|
1566
|
+
evidence
|
|
1567
|
+
});
|
|
1568
|
+
}
|
|
1569
|
+
}
|
|
1570
|
+
return signals;
|
|
1571
|
+
}
|
|
1572
|
+
function detectInstructionFingerprinting(context) {
|
|
1573
|
+
const signals = [];
|
|
1574
|
+
if (context.transactionCount < 3) {
|
|
1575
|
+
return signals;
|
|
1576
|
+
}
|
|
1577
|
+
if (!context.transactions || context.transactions.length === 0) {
|
|
1578
|
+
return signals;
|
|
1579
|
+
}
|
|
1580
|
+
const sequenceFingerprints = /* @__PURE__ */ new Map();
|
|
1581
|
+
const sequenceExamples = /* @__PURE__ */ new Map();
|
|
1582
|
+
for (const tx of context.transactions) {
|
|
1583
|
+
const txInstructions = context.instructions.filter((inst) => inst.signature === tx.signature).map((inst) => inst.programId);
|
|
1584
|
+
if (txInstructions.length === 0) continue;
|
|
1585
|
+
const sequence = txInstructions.join("->");
|
|
1586
|
+
sequenceFingerprints.set(sequence, (sequenceFingerprints.get(sequence) || 0) + 1);
|
|
1587
|
+
if (!sequenceExamples.has(sequence)) {
|
|
1588
|
+
sequenceExamples.set(sequence, []);
|
|
1589
|
+
}
|
|
1590
|
+
sequenceExamples.get(sequence).push(tx.signature);
|
|
1591
|
+
}
|
|
1592
|
+
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]);
|
|
1593
|
+
if (repeatedSequences.length > 0) {
|
|
1594
|
+
const evidence = repeatedSequences.slice(0, 5).map(([sequence, count]) => {
|
|
1595
|
+
const exampleSigs = sequenceExamples.get(sequence).slice(0, 2);
|
|
1596
|
+
const programs = sequence.split("->").map((p) => p.slice(0, 8) + "...").join(" \u2192 ");
|
|
1597
|
+
return {
|
|
1598
|
+
description: `Instruction sequence repeated ${count} times: ${programs}`,
|
|
1599
|
+
severity: count > context.transactionCount * 0.5 ? "MEDIUM" : "LOW",
|
|
1600
|
+
reference: `https://solscan.io/tx/${exampleSigs[0]}`
|
|
1601
|
+
};
|
|
1602
|
+
});
|
|
1603
|
+
const topSequenceCount = repeatedSequences[0][1];
|
|
1604
|
+
const severity = topSequenceCount > context.transactionCount * 0.5 ? "MEDIUM" : "LOW";
|
|
1605
|
+
signals.push({
|
|
1606
|
+
id: "instruction-sequence-pattern",
|
|
1607
|
+
name: "Repeated Instruction Sequence Pattern",
|
|
1608
|
+
severity,
|
|
1609
|
+
category: "behavioral",
|
|
1610
|
+
reason: `${repeatedSequences.length} distinct instruction sequence(s) are repeated multiple times. The most common pattern appears in ${topSequenceCount}/${context.transactionCount} transactions.`,
|
|
1611
|
+
impact: "Repeated instruction patterns create a behavioral fingerprint. Even with different addresses, these patterns can link related activity.",
|
|
1612
|
+
mitigation: "Vary the order or combination of operations. Add dummy instructions or randomize transaction structure where possible.",
|
|
1613
|
+
evidence
|
|
1614
|
+
});
|
|
1615
|
+
}
|
|
1616
|
+
const programUsage = /* @__PURE__ */ new Map();
|
|
1617
|
+
for (const inst of context.instructions) {
|
|
1618
|
+
programUsage.set(inst.programId, (programUsage.get(inst.programId) || 0) + 1);
|
|
1619
|
+
}
|
|
1620
|
+
const COMMON_PROGRAMS = [
|
|
1621
|
+
"11111111111111111111111111111111",
|
|
1622
|
+
// System
|
|
1623
|
+
"TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA",
|
|
1624
|
+
// SPL Token
|
|
1625
|
+
"ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL",
|
|
1626
|
+
// Associated Token
|
|
1627
|
+
"MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr",
|
|
1628
|
+
// Memo
|
|
1629
|
+
"ComputeBudget111111111111111111111111111111"
|
|
1630
|
+
// Compute Budget
|
|
1631
|
+
];
|
|
1632
|
+
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]);
|
|
1633
|
+
if (uniquePrograms.length >= 2) {
|
|
1634
|
+
const evidence = uniquePrograms.slice(0, 5).map(([programId, count]) => {
|
|
1635
|
+
const label = context.labels.get(programId);
|
|
1636
|
+
return {
|
|
1637
|
+
description: `${programId.slice(0, 8)}...${label ? ` (${label.name})` : ""} used in ${count} transactions`,
|
|
1638
|
+
severity: "LOW",
|
|
1639
|
+
reference: `https://solscan.io/account/${programId}`
|
|
1640
|
+
};
|
|
1641
|
+
});
|
|
1642
|
+
signals.push({
|
|
1643
|
+
id: "program-usage-profile",
|
|
1644
|
+
name: "Distinctive Program Usage Profile",
|
|
1645
|
+
severity: "LOW",
|
|
1646
|
+
category: "behavioral",
|
|
1647
|
+
reason: `This address uses ${uniquePrograms.length} less-common programs repeatedly. This creates a unique usage profile.`,
|
|
1648
|
+
impact: "Program usage patterns can fingerprint wallet behavior. Addresses with similar program usage profiles are likely related.",
|
|
1649
|
+
mitigation: "Using niche protocols creates a fingerprint. This is difficult to mitigate without changing your DeFi strategy.",
|
|
1650
|
+
evidence
|
|
1651
|
+
});
|
|
1652
|
+
}
|
|
1653
|
+
if (context.pdaInteractions.length > 0) {
|
|
1654
|
+
const pdaUsage = /* @__PURE__ */ new Map();
|
|
1655
|
+
for (const pda of context.pdaInteractions) {
|
|
1656
|
+
if (!pdaUsage.has(pda.pda)) {
|
|
1657
|
+
pdaUsage.set(pda.pda, { count: 0, programId: pda.programId });
|
|
1658
|
+
}
|
|
1659
|
+
pdaUsage.get(pda.pda).count++;
|
|
1660
|
+
}
|
|
1661
|
+
const repeatedPDAs = Array.from(pdaUsage.entries()).filter(([_, { count }]) => count > 1).sort((a, b) => b[1].count - a[1].count);
|
|
1662
|
+
if (repeatedPDAs.length > 0) {
|
|
1663
|
+
const evidence = repeatedPDAs.slice(0, 5).map(([pda, { count, programId }]) => ({
|
|
1664
|
+
description: `PDA ${pda.slice(0, 8)}... used ${count} times (program: ${programId.slice(0, 8)}...)`,
|
|
1665
|
+
severity: count > 3 ? "MEDIUM" : "LOW",
|
|
1666
|
+
reference: `https://solscan.io/account/${pda}`
|
|
1667
|
+
}));
|
|
1668
|
+
const maxPDAUsage = repeatedPDAs[0][1].count;
|
|
1669
|
+
const severity = maxPDAUsage > 3 ? "MEDIUM" : "LOW";
|
|
1670
|
+
signals.push({
|
|
1671
|
+
id: "pda-reuse-pattern",
|
|
1672
|
+
name: "Repeated PDA Interaction",
|
|
1673
|
+
severity,
|
|
1674
|
+
category: "behavioral",
|
|
1675
|
+
reason: `${repeatedPDAs.length} Program-Derived Address(es) are used repeatedly. The most common PDA appears in ${maxPDAUsage} transactions.`,
|
|
1676
|
+
impact: "Repeated PDA usage links transactions. If the PDA is specific to you (e.g., a user account), all interactions with it are linked.",
|
|
1677
|
+
mitigation: "Some PDA reuse is unavoidable (e.g., your DEX pool position). For sensitive operations, consider using fresh accounts or different protocols.",
|
|
1678
|
+
evidence
|
|
1679
|
+
});
|
|
1680
|
+
}
|
|
1681
|
+
}
|
|
1682
|
+
const programInstructions = /* @__PURE__ */ new Map();
|
|
1683
|
+
for (const inst of context.instructions) {
|
|
1684
|
+
if (!programInstructions.has(inst.programId)) {
|
|
1685
|
+
programInstructions.set(inst.programId, []);
|
|
1686
|
+
}
|
|
1687
|
+
if (inst.data) {
|
|
1688
|
+
programInstructions.get(inst.programId).push(inst.data);
|
|
1689
|
+
}
|
|
1690
|
+
}
|
|
1691
|
+
for (const [programId, dataList] of programInstructions) {
|
|
1692
|
+
if (dataList.length < 2) continue;
|
|
1693
|
+
const typeMap = /* @__PURE__ */ new Map();
|
|
1694
|
+
for (const data of dataList) {
|
|
1695
|
+
if (data && typeof data === "object" && "type" in data) {
|
|
1696
|
+
const type = String(data.type);
|
|
1697
|
+
typeMap.set(type, (typeMap.get(type) || 0) + 1);
|
|
1698
|
+
}
|
|
1699
|
+
}
|
|
1700
|
+
const repeatedTypes = Array.from(typeMap.entries()).filter(([_, count]) => count >= 2).sort((a, b) => b[1] - a[1]);
|
|
1701
|
+
if (repeatedTypes.length > 0 && repeatedTypes[0][1] >= 3) {
|
|
1702
|
+
const [instructionType, count] = repeatedTypes[0];
|
|
1703
|
+
const label = context.labels.get(programId);
|
|
1704
|
+
signals.push({
|
|
1705
|
+
id: `instruction-type-${programId.slice(0, 8)}`,
|
|
1706
|
+
name: "Repeated Instruction Type",
|
|
1707
|
+
severity: "LOW",
|
|
1708
|
+
category: "behavioral",
|
|
1709
|
+
reason: `The instruction type "${instructionType}" on program ${programId.slice(0, 8)}...${label ? ` (${label.name})` : ""} is used ${count} times.`,
|
|
1710
|
+
impact: "Repeated instruction types on the same program suggest automated behavior or specific strategy execution.",
|
|
1711
|
+
mitigation: "This is generally low-risk but contributes to behavioral fingerprinting. Diversify your transaction types if possible.",
|
|
1712
|
+
evidence: [{
|
|
1713
|
+
description: `"${instructionType}" instruction used ${count} times`,
|
|
1714
|
+
severity: "LOW",
|
|
1715
|
+
reference: void 0
|
|
1716
|
+
}]
|
|
1717
|
+
});
|
|
1718
|
+
}
|
|
1719
|
+
}
|
|
1720
|
+
return signals;
|
|
1721
|
+
}
|
|
1722
|
+
function detectTokenAccountLifecycle(context) {
|
|
1723
|
+
const signals = [];
|
|
1724
|
+
if (!context.tokenAccountEvents || context.tokenAccountEvents.length === 0) {
|
|
1725
|
+
return signals;
|
|
1726
|
+
}
|
|
1727
|
+
const accountEvents = /* @__PURE__ */ new Map();
|
|
1728
|
+
for (const event of context.tokenAccountEvents) {
|
|
1729
|
+
if (!accountEvents.has(event.tokenAccount)) {
|
|
1730
|
+
accountEvents.set(event.tokenAccount, []);
|
|
1731
|
+
}
|
|
1732
|
+
accountEvents.get(event.tokenAccount).push(event);
|
|
1733
|
+
}
|
|
1734
|
+
const createEvents = context.tokenAccountEvents.filter((e) => e.type === "create");
|
|
1735
|
+
const closeEvents = context.tokenAccountEvents.filter((e) => e.type === "close");
|
|
1736
|
+
if (createEvents.length >= 2 && closeEvents.length >= 2) {
|
|
1737
|
+
const refundDestinations = /* @__PURE__ */ new Map();
|
|
1738
|
+
const totalRefunded = closeEvents.reduce((sum, event) => {
|
|
1739
|
+
if (event.rentRefund) {
|
|
1740
|
+
refundDestinations.set(event.owner, (refundDestinations.get(event.owner) || 0) + event.rentRefund);
|
|
1741
|
+
return sum + event.rentRefund;
|
|
1742
|
+
}
|
|
1743
|
+
return sum;
|
|
1744
|
+
}, 0);
|
|
1745
|
+
if (refundDestinations.size > 0) {
|
|
1746
|
+
const evidence = Array.from(refundDestinations.entries()).map(([owner, amount]) => ({
|
|
1747
|
+
description: `${amount.toFixed(4)} SOL refunded to ${owner.slice(0, 8)}... from ${closeEvents.filter((e) => e.owner === owner).length} closed account(s)`,
|
|
1748
|
+
severity: "MEDIUM",
|
|
1749
|
+
reference: void 0
|
|
1750
|
+
}));
|
|
1751
|
+
signals.push({
|
|
1752
|
+
id: "token-account-churn",
|
|
1753
|
+
name: "Frequent Token Account Creation/Closure",
|
|
1754
|
+
severity: "MEDIUM",
|
|
1755
|
+
category: "behavioral",
|
|
1756
|
+
reason: `${createEvents.length} token account(s) created and ${closeEvents.length} closed. Rent refunds totaling ${totalRefunded.toFixed(4)} SOL expose ownership.`,
|
|
1757
|
+
impact: 'Rent refunds link temporary token accounts back to the owner wallet. This pattern defeats the purpose of using "burner" accounts.',
|
|
1758
|
+
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.",
|
|
1759
|
+
evidence
|
|
1760
|
+
});
|
|
1761
|
+
}
|
|
1762
|
+
}
|
|
1763
|
+
const completeLifecycles = [];
|
|
1764
|
+
for (const [tokenAccount, events] of accountEvents) {
|
|
1765
|
+
const creates = events.filter((e) => e.type === "create");
|
|
1766
|
+
const closes = events.filter((e) => e.type === "close");
|
|
1767
|
+
if (creates.length > 0 && closes.length > 0) {
|
|
1768
|
+
const createTime = creates[0].blockTime;
|
|
1769
|
+
const closeTime = closes[closes.length - 1].blockTime;
|
|
1770
|
+
if (createTime && closeTime) {
|
|
1771
|
+
const duration = closeTime - createTime;
|
|
1772
|
+
completeLifecycles.push({ tokenAccount, events, duration });
|
|
1773
|
+
}
|
|
1774
|
+
}
|
|
1775
|
+
}
|
|
1776
|
+
const shortLived = completeLifecycles.filter((lc) => lc.duration < 3600);
|
|
1777
|
+
if (shortLived.length >= 2) {
|
|
1778
|
+
const evidence = shortLived.slice(0, 5).map((lc) => {
|
|
1779
|
+
const durationMin = Math.floor(lc.duration / 60);
|
|
1780
|
+
const closeEvent = lc.events.find((e) => e.type === "close");
|
|
1781
|
+
return {
|
|
1782
|
+
description: `${lc.tokenAccount.slice(0, 8)}... lived for ${durationMin} minute(s)${closeEvent?.rentRefund ? `, refunded ${closeEvent.rentRefund.toFixed(4)} SOL` : ""}`,
|
|
1783
|
+
severity: "LOW",
|
|
1784
|
+
reference: void 0
|
|
1785
|
+
};
|
|
1786
|
+
});
|
|
1787
|
+
signals.push({
|
|
1788
|
+
id: "token-account-short-lived",
|
|
1789
|
+
name: "Short-Lived Token Accounts",
|
|
1790
|
+
severity: "LOW",
|
|
1791
|
+
category: "behavioral",
|
|
1792
|
+
reason: `${shortLived.length} token account(s) were created and closed within an hour, suggesting burner account usage.`,
|
|
1793
|
+
impact: "Short-lived accounts suggest privacy-conscious behavior, but rent refunds still create linkage.",
|
|
1794
|
+
mitigation: "For true privacy, do not close accounts immediately. The rent refund links the burner back to you.",
|
|
1795
|
+
evidence
|
|
1796
|
+
});
|
|
1797
|
+
}
|
|
1798
|
+
const ownerAccounts = /* @__PURE__ */ new Map();
|
|
1799
|
+
for (const event of context.tokenAccountEvents) {
|
|
1800
|
+
if (event.type === "create") {
|
|
1801
|
+
if (!ownerAccounts.has(event.owner)) {
|
|
1802
|
+
ownerAccounts.set(event.owner, /* @__PURE__ */ new Set());
|
|
1803
|
+
}
|
|
1804
|
+
ownerAccounts.get(event.owner).add(event.tokenAccount);
|
|
1805
|
+
}
|
|
1806
|
+
}
|
|
1807
|
+
const multiAccountOwners = Array.from(ownerAccounts.entries()).filter(([_, accounts]) => accounts.size >= 2).sort((a, b) => b[1].size - a[1].size);
|
|
1808
|
+
if (multiAccountOwners.length > 0) {
|
|
1809
|
+
const [owner, accounts] = multiAccountOwners[0];
|
|
1810
|
+
const isTarget = owner === context.target;
|
|
1811
|
+
if (!isTarget || multiAccountOwners.length > 1) {
|
|
1812
|
+
const evidence = multiAccountOwners.slice(0, 3).map(([own, accs]) => {
|
|
1813
|
+
const label = context.labels.get(own);
|
|
1814
|
+
return {
|
|
1815
|
+
description: `${own.slice(0, 8)}...${label ? ` (${label.name})` : ""} owns ${accs.size} token account(s)`,
|
|
1816
|
+
severity: "LOW",
|
|
1817
|
+
reference: void 0
|
|
1818
|
+
};
|
|
1819
|
+
});
|
|
1820
|
+
signals.push({
|
|
1821
|
+
id: "token-account-common-owner",
|
|
1822
|
+
name: "Common Owner Across Token Accounts",
|
|
1823
|
+
severity: "LOW",
|
|
1824
|
+
category: "linkability",
|
|
1825
|
+
reason: `${multiAccountOwners.length} wallet(s) control multiple token accounts. The top owner controls ${accounts.size} accounts.`,
|
|
1826
|
+
impact: "All token accounts with the same owner are trivially linked.",
|
|
1827
|
+
mitigation: "This is inherent to Solana's token account model and cannot be avoided.",
|
|
1828
|
+
evidence
|
|
1829
|
+
});
|
|
1830
|
+
}
|
|
1831
|
+
}
|
|
1832
|
+
const rentRefundReceivers = /* @__PURE__ */ new Map();
|
|
1833
|
+
for (const event of context.tokenAccountEvents) {
|
|
1834
|
+
if (event.type === "close" && event.rentRefund) {
|
|
1835
|
+
const current = rentRefundReceivers.get(event.owner) || { count: 0, total: 0 };
|
|
1836
|
+
current.count++;
|
|
1837
|
+
current.total += event.rentRefund;
|
|
1838
|
+
rentRefundReceivers.set(event.owner, current);
|
|
1839
|
+
}
|
|
1840
|
+
}
|
|
1841
|
+
const significantRefunds = Array.from(rentRefundReceivers.entries()).filter(([_, { count }]) => count >= 3).sort((a, b) => b[1].count - a[1].count);
|
|
1842
|
+
if (significantRefunds.length > 0) {
|
|
1843
|
+
const evidence = significantRefunds.slice(0, 3).map(([owner, { count, total }]) => ({
|
|
1844
|
+
description: `${owner.slice(0, 8)}... received ${count} rent refunds totaling ${total.toFixed(4)} SOL`,
|
|
1845
|
+
severity: "MEDIUM",
|
|
1846
|
+
reference: void 0
|
|
1847
|
+
}));
|
|
1848
|
+
const [topOwner, topData] = significantRefunds[0];
|
|
1849
|
+
signals.push({
|
|
1850
|
+
id: "rent-refund-clustering",
|
|
1851
|
+
name: "Rent Refund Clustering",
|
|
1852
|
+
severity: "MEDIUM",
|
|
1853
|
+
category: "linkability",
|
|
1854
|
+
reason: `${significantRefunds.length} address(es) receive multiple rent refunds. ${topOwner.slice(0, 8)}... received ${topData.count} refunds.`,
|
|
1855
|
+
impact: "Rent refunds link closed token accounts back to a central wallet. This exposes the control structure.",
|
|
1856
|
+
mitigation: "Do not close token accounts if privacy is important. The small rent cost (~0.002 SOL) is cheaper than the privacy loss.",
|
|
1857
|
+
evidence
|
|
1858
|
+
});
|
|
1859
|
+
}
|
|
1860
|
+
return signals;
|
|
1861
|
+
}
|
|
896
1862
|
var REPORT_VERSION = "1.0.0";
|
|
897
1863
|
var HEURISTICS = [
|
|
1864
|
+
// Solana-specific (highest priority)
|
|
1865
|
+
detectFeePayerReuse,
|
|
1866
|
+
detectSignerOverlap,
|
|
1867
|
+
detectKnownEntityInteraction,
|
|
898
1868
|
detectCounterpartyReuse,
|
|
899
|
-
|
|
1869
|
+
detectInstructionFingerprinting,
|
|
1870
|
+
detectTokenAccountLifecycle,
|
|
1871
|
+
// Traditional heuristics
|
|
900
1872
|
detectTimingPatterns,
|
|
901
|
-
|
|
1873
|
+
detectAmountReuse,
|
|
902
1874
|
detectBalanceTraceability
|
|
903
1875
|
];
|
|
904
1876
|
function calculateOverallRisk(signals) {
|
|
@@ -923,10 +1895,22 @@ function generateMitigations(signals) {
|
|
|
923
1895
|
}
|
|
924
1896
|
mitigations.add("Consider using multiple wallets to compartmentalize different activities.");
|
|
925
1897
|
const signalIds = new Set(signals.map((s) => s.id));
|
|
1898
|
+
if (signalIds.has("fee-payer-never-self") || signalIds.has("fee-payer-external")) {
|
|
1899
|
+
mitigations.add("Always pay your own transaction fees to avoid linkage.");
|
|
1900
|
+
}
|
|
1901
|
+
if (signalIds.has("signer-repeated") || signalIds.has("signer-set-reuse")) {
|
|
1902
|
+
mitigations.add("Use separate signing keys for unrelated activities.");
|
|
1903
|
+
}
|
|
1904
|
+
if (signalIds.has("instruction-sequence-pattern") || signalIds.has("program-usage-profile")) {
|
|
1905
|
+
mitigations.add("Diversify transaction patterns and protocols to reduce behavioral fingerprinting.");
|
|
1906
|
+
}
|
|
1907
|
+
if (signalIds.has("token-account-churn") || signalIds.has("rent-refund-clustering")) {
|
|
1908
|
+
mitigations.add("Avoid closing token accounts if privacy is important - the rent refund creates linkage.");
|
|
1909
|
+
}
|
|
926
1910
|
if (signalIds.has("known-entity-interaction")) {
|
|
927
1911
|
mitigations.add("Avoid direct interactions between privacy-sensitive wallets and KYC services.");
|
|
928
1912
|
}
|
|
929
|
-
if (signalIds.has("counterparty-reuse")) {
|
|
1913
|
+
if (signalIds.has("counterparty-reuse") || signalIds.has("pda-reuse")) {
|
|
930
1914
|
mitigations.add("Use different addresses for different counterparties or contexts.");
|
|
931
1915
|
}
|
|
932
1916
|
if (signalIds.has("timing-correlation") || signalIds.has("balance-traceability")) {
|
|
@@ -942,9 +1926,11 @@ function evaluateHeuristics(context) {
|
|
|
942
1926
|
const signals = [];
|
|
943
1927
|
for (const heuristic of HEURISTICS) {
|
|
944
1928
|
try {
|
|
945
|
-
const
|
|
946
|
-
if (
|
|
947
|
-
signals.push(
|
|
1929
|
+
const result = heuristic(context);
|
|
1930
|
+
if (Array.isArray(result)) {
|
|
1931
|
+
signals.push(...result);
|
|
1932
|
+
} else if (result) {
|
|
1933
|
+
signals.push(result);
|
|
948
1934
|
}
|
|
949
1935
|
} catch (error) {
|
|
950
1936
|
console.warn(`Heuristic evaluation failed:`, error);
|
|
@@ -1053,7 +2039,6 @@ var StaticLabelProvider = class {
|
|
|
1053
2039
|
function createDefaultLabelProvider() {
|
|
1054
2040
|
return new StaticLabelProvider();
|
|
1055
2041
|
}
|
|
1056
|
-
var VERSION = "0.1.0";
|
|
1057
2042
|
|
|
1058
2043
|
// src/formatter.js
|
|
1059
2044
|
import chalk from "chalk";
|
|
@@ -1357,8 +2342,8 @@ dotenv.config({ path: ".env.local" });
|
|
|
1357
2342
|
dotenv.config();
|
|
1358
2343
|
var program = new Command();
|
|
1359
2344
|
program.name("solana-privacy-scanner").description("Privacy risk scanner for Solana blockchain").version(VERSION);
|
|
1360
|
-
program.command("scan-wallet").alias("wallet").description("Scan a Solana wallet address for privacy risks").argument("<address>", "Wallet address to scan").option("--rpc <url>", "RPC endpoint URL", process.env.SOLANA_RPC).option("--json", "Output as JSON", false).option("--max-signatures <number>", "Maximum number of signatures to fetch", "100").option("--output <file>", "Write output to file").action(scanWallet);
|
|
1361
|
-
program.command("scan-transaction").alias("tx").description("Scan a single Solana transaction for privacy risks").argument("<signature>", "Transaction signature to scan").option("--rpc <url>", "RPC endpoint URL", process.env.SOLANA_RPC).option("--json", "Output as JSON", false).option("--output <file>", "Write output to file").action(scanTransaction);
|
|
1362
|
-
program.command("scan-program").alias("program").description("Scan a Solana program for privacy risks").argument("<programId>", "Program ID to scan").option("--rpc <url>", "RPC endpoint URL", process.env.SOLANA_RPC).option("--json", "Output as JSON", false).option("--max-accounts <number>", "Maximum number of accounts to fetch", "100").option("--max-transactions <number>", "Maximum number of transactions to fetch", "50").option("--output <file>", "Write output to file").action(scanProgram);
|
|
2345
|
+
program.command("scan-wallet").alias("wallet").description("Scan a Solana wallet address for privacy risks").argument("<address>", "Wallet address to scan").option("--rpc <url>", "Custom RPC endpoint URL (optional)", process.env.SOLANA_RPC).option("--json", "Output as JSON", false).option("--max-signatures <number>", "Maximum number of signatures to fetch", "100").option("--output <file>", "Write output to file").action(scanWallet);
|
|
2346
|
+
program.command("scan-transaction").alias("tx").description("Scan a single Solana transaction for privacy risks").argument("<signature>", "Transaction signature to scan").option("--rpc <url>", "Custom RPC endpoint URL (optional)", process.env.SOLANA_RPC).option("--json", "Output as JSON", false).option("--output <file>", "Write output to file").action(scanTransaction);
|
|
2347
|
+
program.command("scan-program").alias("program").description("Scan a Solana program for privacy risks").argument("<programId>", "Program ID to scan").option("--rpc <url>", "Custom RPC endpoint URL (optional)", process.env.SOLANA_RPC).option("--json", "Output as JSON", false).option("--max-accounts <number>", "Maximum number of accounts to fetch", "100").option("--max-transactions <number>", "Maximum number of transactions to fetch", "50").option("--output <file>", "Write output to file").action(scanProgram);
|
|
1363
2348
|
program.parse();
|
|
1364
2349
|
//# sourceMappingURL=index.js.map
|