solana-privacy-scanner 0.3.1 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +28 -2541
- package/dist/known-addresses.json +475 -0
- package/package.json +3 -2
package/dist/index.js
CHANGED
|
@@ -6,2541 +6,13 @@ import * as dotenv from "dotenv";
|
|
|
6
6
|
|
|
7
7
|
// src/commands/wallet.ts
|
|
8
8
|
import { writeFileSync } from "fs";
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
var VERSION = "0.4.0";
|
|
17
|
-
var RateLimiter = class {
|
|
18
|
-
constructor(maxConcurrency) {
|
|
19
|
-
this.maxConcurrency = maxConcurrency;
|
|
20
|
-
}
|
|
21
|
-
activeRequests = 0;
|
|
22
|
-
queue = [];
|
|
23
|
-
async acquire() {
|
|
24
|
-
if (this.activeRequests < this.maxConcurrency) {
|
|
25
|
-
this.activeRequests++;
|
|
26
|
-
return;
|
|
27
|
-
}
|
|
28
|
-
return new Promise((resolve) => {
|
|
29
|
-
this.queue.push(() => {
|
|
30
|
-
this.activeRequests++;
|
|
31
|
-
resolve();
|
|
32
|
-
});
|
|
33
|
-
});
|
|
34
|
-
}
|
|
35
|
-
release() {
|
|
36
|
-
this.activeRequests--;
|
|
37
|
-
const next = this.queue.shift();
|
|
38
|
-
if (next) {
|
|
39
|
-
next();
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
getActiveCount() {
|
|
43
|
-
return this.activeRequests;
|
|
44
|
-
}
|
|
45
|
-
getQueueLength() {
|
|
46
|
-
return this.queue.length;
|
|
47
|
-
}
|
|
48
|
-
};
|
|
49
|
-
function sleep(ms) {
|
|
50
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
51
|
-
}
|
|
52
|
-
var RPCClient = class {
|
|
53
|
-
connection;
|
|
54
|
-
config;
|
|
55
|
-
rateLimiter;
|
|
56
|
-
constructor(configOrUrl) {
|
|
57
|
-
const config2 = !configOrUrl ? {} : typeof configOrUrl === "string" ? { rpcUrl: configOrUrl } : configOrUrl;
|
|
58
|
-
const rpcUrl = (config2.rpcUrl || DEFAULT_RPC_URL).trim();
|
|
59
|
-
this.config = {
|
|
60
|
-
maxRetries: config2.maxRetries ?? 3,
|
|
61
|
-
retryDelay: config2.retryDelay ?? 1e3,
|
|
62
|
-
timeout: config2.timeout ?? 3e4,
|
|
63
|
-
maxConcurrency: config2.maxConcurrency ?? 10,
|
|
64
|
-
debug: config2.debug ?? false,
|
|
65
|
-
rpcUrl
|
|
66
|
-
};
|
|
67
|
-
const connectionConfig = {
|
|
68
|
-
commitment: "confirmed",
|
|
69
|
-
confirmTransactionInitialTimeout: this.config.timeout
|
|
70
|
-
};
|
|
71
|
-
this.connection = new Connection(this.config.rpcUrl, connectionConfig);
|
|
72
|
-
this.rateLimiter = new RateLimiter(this.config.maxConcurrency);
|
|
73
|
-
if (this.config.debug) {
|
|
74
|
-
console.log(`[RPCClient] Initialized with URL: ${this.config.rpcUrl}`);
|
|
75
|
-
console.log(`[RPCClient] Max concurrency: ${this.config.maxConcurrency}`);
|
|
76
|
-
console.log(`[RPCClient] Max retries: ${this.config.maxRetries}`);
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
/**
|
|
80
|
-
* Execute an RPC call with retry logic and rate limiting
|
|
81
|
-
*/
|
|
82
|
-
async executeWithRetry(operation, operationName) {
|
|
83
|
-
await this.rateLimiter.acquire();
|
|
84
|
-
let lastError = null;
|
|
85
|
-
for (let attempt = 0; attempt <= this.config.maxRetries; attempt++) {
|
|
86
|
-
try {
|
|
87
|
-
if (this.config.debug && attempt > 0) {
|
|
88
|
-
console.log(`[RPCClient] Retry attempt ${attempt} for ${operationName}`);
|
|
89
|
-
}
|
|
90
|
-
const result = await operation();
|
|
91
|
-
this.rateLimiter.release();
|
|
92
|
-
return result;
|
|
93
|
-
} catch (error) {
|
|
94
|
-
lastError = error;
|
|
95
|
-
if (this.config.debug) {
|
|
96
|
-
console.error(
|
|
97
|
-
`[RPCClient] Error in ${operationName} (attempt ${attempt + 1}/${this.config.maxRetries + 1}):`,
|
|
98
|
-
error
|
|
99
|
-
);
|
|
100
|
-
}
|
|
101
|
-
if (attempt < this.config.maxRetries) {
|
|
102
|
-
const delay = this.config.retryDelay * Math.pow(2, attempt);
|
|
103
|
-
await sleep(delay);
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
this.rateLimiter.release();
|
|
108
|
-
throw new Error(
|
|
109
|
-
`RPC operation ${operationName} failed after ${this.config.maxRetries + 1} attempts: ${lastError?.message}`
|
|
110
|
-
);
|
|
111
|
-
}
|
|
112
|
-
/**
|
|
113
|
-
* Get the underlying Solana Connection
|
|
114
|
-
* Use this sparingly - prefer the wrapped methods for automatic retry/rate limiting
|
|
115
|
-
*/
|
|
116
|
-
getConnection() {
|
|
117
|
-
return this.connection;
|
|
118
|
-
}
|
|
119
|
-
/**
|
|
120
|
-
* Get current rate limiter stats
|
|
121
|
-
*/
|
|
122
|
-
getStats() {
|
|
123
|
-
return {
|
|
124
|
-
activeRequests: this.rateLimiter.getActiveCount(),
|
|
125
|
-
queueLength: this.rateLimiter.getQueueLength()
|
|
126
|
-
};
|
|
127
|
-
}
|
|
128
|
-
/**
|
|
129
|
-
* Get signatures for an address with retry and rate limiting
|
|
130
|
-
*/
|
|
131
|
-
async getSignaturesForAddress(address, options) {
|
|
132
|
-
return this.executeWithRetry(
|
|
133
|
-
async () => {
|
|
134
|
-
const { PublicKey } = await import("@solana/web3.js");
|
|
135
|
-
return this.connection.getSignaturesForAddress(
|
|
136
|
-
new PublicKey(address),
|
|
137
|
-
options
|
|
138
|
-
);
|
|
139
|
-
},
|
|
140
|
-
`getSignaturesForAddress(${address})`
|
|
141
|
-
);
|
|
142
|
-
}
|
|
143
|
-
/**
|
|
144
|
-
* Get transaction details with retry and rate limiting
|
|
145
|
-
*/
|
|
146
|
-
async getTransaction(signature, options) {
|
|
147
|
-
return this.executeWithRetry(
|
|
148
|
-
async () => {
|
|
149
|
-
return this.connection.getTransaction(signature, {
|
|
150
|
-
maxSupportedTransactionVersion: options?.maxSupportedTransactionVersion ?? 0
|
|
151
|
-
});
|
|
152
|
-
},
|
|
153
|
-
`getTransaction(${signature})`
|
|
154
|
-
);
|
|
155
|
-
}
|
|
156
|
-
/**
|
|
157
|
-
* Get multiple transactions in parallel (respects rate limiting)
|
|
158
|
-
*/
|
|
159
|
-
async getTransactions(signatures, options) {
|
|
160
|
-
const promises = signatures.map((sig) => this.getTransaction(sig, options));
|
|
161
|
-
return Promise.all(promises);
|
|
162
|
-
}
|
|
163
|
-
/**
|
|
164
|
-
* Get token accounts by owner with retry and rate limiting
|
|
165
|
-
*/
|
|
166
|
-
async getTokenAccountsByOwner(ownerAddress, mintAddress) {
|
|
167
|
-
return this.executeWithRetry(
|
|
168
|
-
async () => {
|
|
169
|
-
const { PublicKey } = await import("@solana/web3.js");
|
|
170
|
-
const owner = new PublicKey(ownerAddress);
|
|
171
|
-
const TOKEN_PROGRAM_ID = new PublicKey("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA");
|
|
172
|
-
if (mintAddress) {
|
|
173
|
-
const mint = new PublicKey(mintAddress);
|
|
174
|
-
return this.connection.getTokenAccountsByOwner(owner, { mint });
|
|
175
|
-
} else {
|
|
176
|
-
return this.connection.getTokenAccountsByOwner(owner, {
|
|
177
|
-
programId: TOKEN_PROGRAM_ID
|
|
178
|
-
});
|
|
179
|
-
}
|
|
180
|
-
},
|
|
181
|
-
`getTokenAccountsByOwner(${ownerAddress})`
|
|
182
|
-
);
|
|
183
|
-
}
|
|
184
|
-
/**
|
|
185
|
-
* Get program accounts with retry and rate limiting
|
|
186
|
-
*/
|
|
187
|
-
async getProgramAccounts(programId, config2) {
|
|
188
|
-
return this.executeWithRetry(
|
|
189
|
-
async () => {
|
|
190
|
-
const { PublicKey } = await import("@solana/web3.js");
|
|
191
|
-
return this.connection.getProgramAccounts(new PublicKey(programId), config2);
|
|
192
|
-
},
|
|
193
|
-
`getProgramAccounts(${programId})`
|
|
194
|
-
);
|
|
195
|
-
}
|
|
196
|
-
/**
|
|
197
|
-
* Get account info with retry and rate limiting
|
|
198
|
-
*/
|
|
199
|
-
async getAccountInfo(address) {
|
|
200
|
-
return this.executeWithRetry(
|
|
201
|
-
async () => {
|
|
202
|
-
const { PublicKey } = await import("@solana/web3.js");
|
|
203
|
-
return this.connection.getAccountInfo(new PublicKey(address));
|
|
204
|
-
},
|
|
205
|
-
`getAccountInfo(${address})`
|
|
206
|
-
);
|
|
207
|
-
}
|
|
208
|
-
/**
|
|
209
|
-
* Get multiple account infos in parallel (respects rate limiting)
|
|
210
|
-
*/
|
|
211
|
-
async getMultipleAccountsInfo(addresses) {
|
|
212
|
-
return this.executeWithRetry(
|
|
213
|
-
async () => {
|
|
214
|
-
const { PublicKey } = await import("@solana/web3.js");
|
|
215
|
-
const pubkeys = addresses.map((addr) => new PublicKey(addr));
|
|
216
|
-
return this.connection.getMultipleAccountsInfo(pubkeys);
|
|
217
|
-
},
|
|
218
|
-
`getMultipleAccountsInfo(${addresses.length} addresses)`
|
|
219
|
-
);
|
|
220
|
-
}
|
|
221
|
-
/**
|
|
222
|
-
* Check if the RPC connection is healthy
|
|
223
|
-
*/
|
|
224
|
-
async healthCheck() {
|
|
225
|
-
try {
|
|
226
|
-
const version = await this.executeWithRetry(
|
|
227
|
-
() => this.connection.getVersion(),
|
|
228
|
-
"healthCheck"
|
|
229
|
-
);
|
|
230
|
-
return !!version;
|
|
231
|
-
} catch {
|
|
232
|
-
return false;
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
};
|
|
236
|
-
async function collectWalletData(client, address, options = {}) {
|
|
237
|
-
const maxSignatures = options.maxSignatures ?? 100;
|
|
238
|
-
const includeTokenAccounts = options.includeTokenAccounts ?? true;
|
|
239
|
-
let signatures = [];
|
|
240
|
-
try {
|
|
241
|
-
signatures = await client.getSignaturesForAddress(address, {
|
|
242
|
-
limit: maxSignatures
|
|
243
|
-
});
|
|
244
|
-
} catch (error) {
|
|
245
|
-
console.warn(`Failed to fetch signatures for ${address}:`, error);
|
|
246
|
-
}
|
|
247
|
-
const transactions = [];
|
|
248
|
-
const BATCH_SIZE = 10;
|
|
249
|
-
for (let i = 0; i < signatures.length; i += BATCH_SIZE) {
|
|
250
|
-
const batch = signatures.slice(i, i + BATCH_SIZE);
|
|
251
|
-
const batchSignatures = batch.map((sig) => sig.signature);
|
|
252
|
-
try {
|
|
253
|
-
const txs = await client.getTransactions(batchSignatures, {
|
|
254
|
-
maxSupportedTransactionVersion: 0
|
|
255
|
-
});
|
|
256
|
-
for (let j = 0; j < batch.length; j++) {
|
|
257
|
-
transactions.push({
|
|
258
|
-
signature: batch[j].signature,
|
|
259
|
-
transaction: txs[j],
|
|
260
|
-
blockTime: batch[j].blockTime
|
|
261
|
-
});
|
|
262
|
-
}
|
|
263
|
-
} catch (error) {
|
|
264
|
-
console.warn(`Failed to fetch transaction batch for ${address}:`, error);
|
|
265
|
-
}
|
|
266
|
-
}
|
|
267
|
-
let tokenAccounts = [];
|
|
268
|
-
if (includeTokenAccounts) {
|
|
269
|
-
try {
|
|
270
|
-
const response = await client.getTokenAccountsByOwner(address);
|
|
271
|
-
tokenAccounts = response.value;
|
|
272
|
-
} catch (error) {
|
|
273
|
-
console.warn(`Failed to fetch token accounts for ${address}:`, error);
|
|
274
|
-
}
|
|
275
|
-
}
|
|
276
|
-
return {
|
|
277
|
-
address,
|
|
278
|
-
signatures,
|
|
279
|
-
transactions,
|
|
280
|
-
tokenAccounts
|
|
281
|
-
};
|
|
282
|
-
}
|
|
283
|
-
async function collectTransactionData(client, signature) {
|
|
284
|
-
let transaction = null;
|
|
285
|
-
try {
|
|
286
|
-
transaction = await client.getTransaction(signature, {
|
|
287
|
-
maxSupportedTransactionVersion: 0
|
|
288
|
-
});
|
|
289
|
-
} catch (error) {
|
|
290
|
-
console.warn(`Failed to fetch transaction ${signature}:`, error);
|
|
291
|
-
}
|
|
292
|
-
return {
|
|
293
|
-
signature,
|
|
294
|
-
transaction,
|
|
295
|
-
blockTime: transaction?.blockTime ?? null
|
|
296
|
-
};
|
|
297
|
-
}
|
|
298
|
-
async function collectProgramData(client, programId, options = {}) {
|
|
299
|
-
const maxAccounts = options.maxAccounts ?? 100;
|
|
300
|
-
const maxTransactions = options.maxTransactions ?? 50;
|
|
301
|
-
let accounts = [];
|
|
302
|
-
try {
|
|
303
|
-
const response = await client.getProgramAccounts(programId, {
|
|
304
|
-
encoding: "jsonParsed",
|
|
305
|
-
dataSlice: { offset: 0, length: 0 }
|
|
306
|
-
// Don't fetch full account data
|
|
307
|
-
});
|
|
308
|
-
accounts = response.slice(0, maxAccounts).map((acc) => ({
|
|
309
|
-
pubkey: acc.pubkey.toString(),
|
|
310
|
-
account: acc.account
|
|
311
|
-
}));
|
|
312
|
-
} catch (error) {
|
|
313
|
-
console.warn(`Failed to fetch program accounts for ${programId}:`, error);
|
|
314
|
-
}
|
|
315
|
-
let signatures = [];
|
|
316
|
-
try {
|
|
317
|
-
signatures = await client.getSignaturesForAddress(programId, {
|
|
318
|
-
limit: maxTransactions
|
|
319
|
-
});
|
|
320
|
-
} catch (error) {
|
|
321
|
-
console.warn(`Failed to fetch signatures for program ${programId}:`, error);
|
|
322
|
-
}
|
|
323
|
-
const relatedTransactions = [];
|
|
324
|
-
const BATCH_SIZE = 10;
|
|
325
|
-
for (let i = 0; i < Math.min(signatures.length, maxTransactions); i += BATCH_SIZE) {
|
|
326
|
-
const batch = signatures.slice(i, i + BATCH_SIZE);
|
|
327
|
-
const batchSignatures = batch.map((sig) => sig.signature);
|
|
328
|
-
try {
|
|
329
|
-
const txs = await client.getTransactions(batchSignatures, {
|
|
330
|
-
maxSupportedTransactionVersion: 0
|
|
331
|
-
});
|
|
332
|
-
for (let j = 0; j < batch.length; j++) {
|
|
333
|
-
relatedTransactions.push({
|
|
334
|
-
signature: batch[j].signature,
|
|
335
|
-
transaction: txs[j],
|
|
336
|
-
blockTime: batch[j].blockTime
|
|
337
|
-
});
|
|
338
|
-
}
|
|
339
|
-
} catch (error) {
|
|
340
|
-
console.warn(`Failed to fetch transaction batch for program ${programId}:`, error);
|
|
341
|
-
}
|
|
342
|
-
}
|
|
343
|
-
return {
|
|
344
|
-
programId,
|
|
345
|
-
accounts,
|
|
346
|
-
relatedTransactions
|
|
347
|
-
};
|
|
348
|
-
}
|
|
349
|
-
var PROGRAM_IDS = {
|
|
350
|
-
SYSTEM: "11111111111111111111111111111111",
|
|
351
|
-
TOKEN: "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA",
|
|
352
|
-
ASSOCIATED_TOKEN: "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL",
|
|
353
|
-
STAKE: "Stake11111111111111111111111111111111111111",
|
|
354
|
-
VOTE: "Vote111111111111111111111111111111111111111",
|
|
355
|
-
MEMO: "MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr",
|
|
356
|
-
MEMO_V1: "Memo1UhkJRfHyvLMcVucJwxXeuD728EqVDDwQDxFMNo"
|
|
357
|
-
};
|
|
358
|
-
function extractTransactionMetadata(tx, signature) {
|
|
359
|
-
if (!tx || !tx.transaction || !tx.transaction.message || !tx.transaction.message.accountKeys) {
|
|
360
|
-
return {
|
|
361
|
-
signature,
|
|
362
|
-
blockTime: tx?.blockTime || null,
|
|
363
|
-
feePayer: "unknown",
|
|
364
|
-
signers: []
|
|
365
|
-
};
|
|
366
|
-
}
|
|
367
|
-
const feePayer = tx.transaction.message.accountKeys[0];
|
|
368
|
-
const feePayerAddress = typeof feePayer === "string" ? feePayer : feePayer.pubkey.toString();
|
|
369
|
-
const signers = [];
|
|
370
|
-
const accountKeys = tx.transaction.message.accountKeys;
|
|
371
|
-
signers.push(feePayerAddress);
|
|
372
|
-
if (accountKeys && Array.isArray(accountKeys)) {
|
|
373
|
-
for (let i = 1; i < accountKeys.length; i++) {
|
|
374
|
-
const key = accountKeys[i];
|
|
375
|
-
const address = typeof key === "string" ? key : key.pubkey?.toString();
|
|
376
|
-
if (typeof key !== "string" && key.signer) {
|
|
377
|
-
if (address && !signers.includes(address)) {
|
|
378
|
-
signers.push(address);
|
|
379
|
-
}
|
|
380
|
-
}
|
|
381
|
-
}
|
|
382
|
-
}
|
|
383
|
-
let memo;
|
|
384
|
-
const instructions = tx.transaction.message.instructions;
|
|
385
|
-
if (instructions && Array.isArray(instructions)) {
|
|
386
|
-
for (const instruction of instructions) {
|
|
387
|
-
if (!instruction || !instruction.programId) continue;
|
|
388
|
-
const programId = instruction.programId.toString();
|
|
389
|
-
if (programId === PROGRAM_IDS.MEMO || programId === PROGRAM_IDS.MEMO_V1) {
|
|
390
|
-
if ("parsed" in instruction && instruction.parsed) {
|
|
391
|
-
const parsed = instruction.parsed;
|
|
392
|
-
if (parsed.type === "memo" && typeof parsed.info === "string") {
|
|
393
|
-
memo = parsed.info;
|
|
394
|
-
}
|
|
395
|
-
} else if ("data" in instruction && typeof instruction.data === "string") {
|
|
396
|
-
try {
|
|
397
|
-
memo = Buffer.from(instruction.data, "base64").toString("utf8");
|
|
398
|
-
} catch {
|
|
399
|
-
memo = instruction.data;
|
|
400
|
-
}
|
|
401
|
-
}
|
|
402
|
-
break;
|
|
403
|
-
}
|
|
404
|
-
}
|
|
405
|
-
}
|
|
406
|
-
let computeUnitsUsed;
|
|
407
|
-
let priorityFee;
|
|
408
|
-
if (tx.meta) {
|
|
409
|
-
computeUnitsUsed = tx.meta.computeUnitsConsumed;
|
|
410
|
-
if (tx.meta.fee !== void 0 && tx.meta.fee > 5e3) {
|
|
411
|
-
priorityFee = tx.meta.fee - 5e3;
|
|
412
|
-
}
|
|
413
|
-
}
|
|
414
|
-
return {
|
|
415
|
-
signature,
|
|
416
|
-
blockTime: tx.blockTime,
|
|
417
|
-
feePayer: feePayerAddress,
|
|
418
|
-
signers,
|
|
419
|
-
computeUnitsUsed,
|
|
420
|
-
priorityFee,
|
|
421
|
-
memo
|
|
422
|
-
};
|
|
423
|
-
}
|
|
424
|
-
function extractTokenAccountEvents(tx, signature) {
|
|
425
|
-
const events = [];
|
|
426
|
-
if (!tx.transaction?.message?.instructions) {
|
|
427
|
-
return events;
|
|
428
|
-
}
|
|
429
|
-
for (const instruction of tx.transaction.message.instructions) {
|
|
430
|
-
if (!instruction || !instruction.programId) continue;
|
|
431
|
-
const programId = instruction.programId.toString();
|
|
432
|
-
if (programId === PROGRAM_IDS.TOKEN || programId === PROGRAM_IDS.ASSOCIATED_TOKEN) {
|
|
433
|
-
if ("parsed" in instruction && instruction.parsed) {
|
|
434
|
-
const parsed = instruction.parsed;
|
|
435
|
-
if (parsed.type === "initializeAccount" || parsed.type === "create") {
|
|
436
|
-
const info = parsed.info;
|
|
437
|
-
events.push({
|
|
438
|
-
type: "create",
|
|
439
|
-
tokenAccount: info.account || info.newAccount,
|
|
440
|
-
owner: info.owner,
|
|
441
|
-
mint: info.mint,
|
|
442
|
-
signature,
|
|
443
|
-
blockTime: tx.blockTime
|
|
444
|
-
});
|
|
445
|
-
}
|
|
446
|
-
if (parsed.type === "closeAccount") {
|
|
447
|
-
const info = parsed.info;
|
|
448
|
-
let rentRefund;
|
|
449
|
-
if (tx.meta?.postBalances && tx.meta?.preBalances) {
|
|
450
|
-
const accountKeys = tx.transaction.message.accountKeys;
|
|
451
|
-
for (let i = 0; i < accountKeys.length; i++) {
|
|
452
|
-
const key = accountKeys[i];
|
|
453
|
-
const address = typeof key === "string" ? key : key.pubkey.toString();
|
|
454
|
-
if (address === info.destination) {
|
|
455
|
-
const diff = tx.meta.postBalances[i] - tx.meta.preBalances[i];
|
|
456
|
-
if (diff > 0) {
|
|
457
|
-
rentRefund = diff / 1e9;
|
|
458
|
-
}
|
|
459
|
-
break;
|
|
460
|
-
}
|
|
461
|
-
}
|
|
462
|
-
}
|
|
463
|
-
events.push({
|
|
464
|
-
type: "close",
|
|
465
|
-
tokenAccount: info.account,
|
|
466
|
-
owner: info.owner || info.destination,
|
|
467
|
-
signature,
|
|
468
|
-
blockTime: tx.blockTime,
|
|
469
|
-
rentRefund
|
|
470
|
-
});
|
|
471
|
-
}
|
|
472
|
-
}
|
|
473
|
-
}
|
|
474
|
-
}
|
|
475
|
-
return events;
|
|
476
|
-
}
|
|
477
|
-
function extractPDAInteractions(tx, signature) {
|
|
478
|
-
const interactions = [];
|
|
479
|
-
if (!tx.transaction?.message?.accountKeys) {
|
|
480
|
-
return interactions;
|
|
481
|
-
}
|
|
482
|
-
const accountProgramMap = /* @__PURE__ */ new Map();
|
|
483
|
-
for (const instruction of tx.transaction.message.instructions) {
|
|
484
|
-
if (!instruction || !instruction.programId) continue;
|
|
485
|
-
const programId = instruction.programId.toString();
|
|
486
|
-
const accounts = [];
|
|
487
|
-
if ("accounts" in instruction && Array.isArray(instruction.accounts)) {
|
|
488
|
-
for (const acc of instruction.accounts) {
|
|
489
|
-
const address = typeof acc === "string" ? acc : acc.toString();
|
|
490
|
-
accounts.push(address);
|
|
491
|
-
}
|
|
492
|
-
}
|
|
493
|
-
for (const account of accounts) {
|
|
494
|
-
if (!accountProgramMap.has(account)) {
|
|
495
|
-
accountProgramMap.set(account, /* @__PURE__ */ new Set());
|
|
496
|
-
}
|
|
497
|
-
accountProgramMap.get(account).add(programId);
|
|
498
|
-
}
|
|
499
|
-
}
|
|
500
|
-
for (const [address, programs] of accountProgramMap) {
|
|
501
|
-
for (const programId of programs) {
|
|
502
|
-
if (programId === PROGRAM_IDS.SYSTEM || programId === PROGRAM_IDS.MEMO || programId === PROGRAM_IDS.MEMO_V1) {
|
|
503
|
-
continue;
|
|
504
|
-
}
|
|
505
|
-
interactions.push({
|
|
506
|
-
pda: address,
|
|
507
|
-
programId,
|
|
508
|
-
signature
|
|
509
|
-
});
|
|
510
|
-
}
|
|
511
|
-
}
|
|
512
|
-
return interactions;
|
|
513
|
-
}
|
|
514
|
-
function extractSOLTransfers(tx, signature) {
|
|
515
|
-
const transfers = [];
|
|
516
|
-
if (!tx.meta || !tx.transaction) {
|
|
517
|
-
return transfers;
|
|
518
|
-
}
|
|
519
|
-
const preBalances = tx.meta.preBalances;
|
|
520
|
-
const postBalances = tx.meta.postBalances;
|
|
521
|
-
const accountKeys = tx.transaction.message.accountKeys;
|
|
522
|
-
if (!accountKeys || !Array.isArray(accountKeys) || accountKeys.length === 0) {
|
|
523
|
-
return transfers;
|
|
524
|
-
}
|
|
525
|
-
if (!preBalances || !postBalances) {
|
|
526
|
-
return transfers;
|
|
527
|
-
}
|
|
528
|
-
for (let i = 0; i < accountKeys.length; i++) {
|
|
529
|
-
const pre = preBalances[i];
|
|
530
|
-
const post = postBalances[i];
|
|
531
|
-
if (pre === void 0 || post === void 0) {
|
|
532
|
-
continue;
|
|
533
|
-
}
|
|
534
|
-
const diff = post - pre;
|
|
535
|
-
if (diff === 0) continue;
|
|
536
|
-
const account = accountKeys[i];
|
|
537
|
-
if (!account) continue;
|
|
538
|
-
const address = typeof account === "string" ? account : account.pubkey?.toString();
|
|
539
|
-
if (!address) continue;
|
|
540
|
-
if (diff > 0) {
|
|
541
|
-
for (let j = 0; j < accountKeys.length; j++) {
|
|
542
|
-
const preSender = preBalances[j];
|
|
543
|
-
const postSender = postBalances[j];
|
|
544
|
-
if (preSender === void 0 || postSender === void 0) {
|
|
545
|
-
continue;
|
|
546
|
-
}
|
|
547
|
-
if (postSender < preSender) {
|
|
548
|
-
const sender = accountKeys[j];
|
|
549
|
-
if (!sender) continue;
|
|
550
|
-
const senderAddress = typeof sender === "string" ? sender : sender.pubkey?.toString();
|
|
551
|
-
if (!senderAddress) continue;
|
|
552
|
-
transfers.push({
|
|
553
|
-
from: senderAddress,
|
|
554
|
-
to: address,
|
|
555
|
-
amount: diff / 1e9,
|
|
556
|
-
// Convert lamports to SOL
|
|
557
|
-
token: void 0,
|
|
558
|
-
signature,
|
|
559
|
-
blockTime: tx.blockTime
|
|
560
|
-
});
|
|
561
|
-
break;
|
|
562
|
-
}
|
|
563
|
-
}
|
|
564
|
-
}
|
|
565
|
-
}
|
|
566
|
-
return transfers;
|
|
567
|
-
}
|
|
568
|
-
function extractSPLTransfers(tx, signature) {
|
|
569
|
-
const transfers = [];
|
|
570
|
-
if (!tx.meta || !tx.meta.postTokenBalances || !tx.meta.preTokenBalances) {
|
|
571
|
-
return transfers;
|
|
572
|
-
}
|
|
573
|
-
const preTokenBalances = tx.meta.preTokenBalances;
|
|
574
|
-
const postTokenBalances = tx.meta.postTokenBalances;
|
|
575
|
-
const balanceChanges = /* @__PURE__ */ new Map();
|
|
576
|
-
for (const post of postTokenBalances) {
|
|
577
|
-
const pre = preTokenBalances.find(
|
|
578
|
-
(p) => p.accountIndex === post.accountIndex && p.mint === post.mint
|
|
579
|
-
);
|
|
580
|
-
const preAmount = pre?.uiTokenAmount.uiAmount ?? 0;
|
|
581
|
-
const postAmount = post.uiTokenAmount.uiAmount ?? 0;
|
|
582
|
-
const change = postAmount - preAmount;
|
|
583
|
-
if (change !== 0) {
|
|
584
|
-
balanceChanges.set(post.accountIndex, {
|
|
585
|
-
mint: post.mint,
|
|
586
|
-
change,
|
|
587
|
-
decimals: post.uiTokenAmount.decimals
|
|
588
|
-
});
|
|
589
|
-
}
|
|
590
|
-
}
|
|
591
|
-
const accountKeys = tx.transaction.message.accountKeys;
|
|
592
|
-
if (!accountKeys || !Array.isArray(accountKeys)) {
|
|
593
|
-
return transfers;
|
|
594
|
-
}
|
|
595
|
-
balanceChanges.forEach((info, accountIndex) => {
|
|
596
|
-
if (accountIndex >= accountKeys.length) {
|
|
597
|
-
return;
|
|
598
|
-
}
|
|
599
|
-
const account = accountKeys[accountIndex];
|
|
600
|
-
if (!account) return;
|
|
601
|
-
const address = typeof account === "string" ? account : account.pubkey?.toString();
|
|
602
|
-
if (!address) return;
|
|
603
|
-
if (info.change > 0) {
|
|
604
|
-
balanceChanges.forEach((senderInfo, senderIndex) => {
|
|
605
|
-
if (senderInfo.mint === info.mint && senderInfo.change < 0 && senderIndex !== accountIndex && senderIndex < accountKeys.length) {
|
|
606
|
-
const sender = accountKeys[senderIndex];
|
|
607
|
-
if (!sender) return;
|
|
608
|
-
const senderAddress = typeof sender === "string" ? sender : sender.pubkey?.toString();
|
|
609
|
-
if (!senderAddress) return;
|
|
610
|
-
transfers.push({
|
|
611
|
-
from: senderAddress,
|
|
612
|
-
to: address,
|
|
613
|
-
amount: info.change,
|
|
614
|
-
token: info.mint,
|
|
615
|
-
signature,
|
|
616
|
-
blockTime: tx.blockTime
|
|
617
|
-
});
|
|
618
|
-
}
|
|
619
|
-
});
|
|
620
|
-
}
|
|
621
|
-
});
|
|
622
|
-
return transfers;
|
|
623
|
-
}
|
|
624
|
-
function categorizeInstruction(instruction) {
|
|
625
|
-
const programId = instruction.programId.toString();
|
|
626
|
-
if (programId === PROGRAM_IDS.SYSTEM) {
|
|
627
|
-
if ("parsed" in instruction && instruction.parsed.type) {
|
|
628
|
-
const type = instruction.parsed.type;
|
|
629
|
-
if (type === "transfer" || type === "transferWithSeed") {
|
|
630
|
-
return "transfer";
|
|
631
|
-
}
|
|
632
|
-
}
|
|
633
|
-
return "transfer";
|
|
634
|
-
}
|
|
635
|
-
if (programId === PROGRAM_IDS.TOKEN || programId === PROGRAM_IDS.ASSOCIATED_TOKEN) {
|
|
636
|
-
if ("parsed" in instruction && instruction.parsed.type) {
|
|
637
|
-
const type = instruction.parsed.type;
|
|
638
|
-
if (type === "transfer" || type === "transferChecked") {
|
|
639
|
-
return "transfer";
|
|
640
|
-
}
|
|
641
|
-
return "token_operation";
|
|
642
|
-
}
|
|
643
|
-
return "token_operation";
|
|
644
|
-
}
|
|
645
|
-
if (programId === PROGRAM_IDS.STAKE) {
|
|
646
|
-
return "stake";
|
|
647
|
-
}
|
|
648
|
-
if (programId === PROGRAM_IDS.VOTE) {
|
|
649
|
-
return "vote";
|
|
650
|
-
}
|
|
651
|
-
if (programId.includes("Swap") || programId.includes("swap") || programId === "JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4" || // Jupiter
|
|
652
|
-
programId === "whirLbMiicVdio4qvUfM5KAg6Ct8VwpYzGff3uctyCc") {
|
|
653
|
-
return "swap";
|
|
654
|
-
}
|
|
655
|
-
return "program_interaction";
|
|
656
|
-
}
|
|
657
|
-
function extractInstructions(tx, signature) {
|
|
658
|
-
const instructions = [];
|
|
659
|
-
if (!tx.transaction || !tx.transaction.message) {
|
|
660
|
-
return instructions;
|
|
661
|
-
}
|
|
662
|
-
const message = tx.transaction.message;
|
|
663
|
-
const allInstructions = message.instructions;
|
|
664
|
-
if (!allInstructions || !Array.isArray(allInstructions)) {
|
|
665
|
-
return instructions;
|
|
666
|
-
}
|
|
667
|
-
for (const instruction of allInstructions) {
|
|
668
|
-
if (!instruction || !instruction.programId) {
|
|
669
|
-
continue;
|
|
670
|
-
}
|
|
671
|
-
const programId = instruction.programId.toString();
|
|
672
|
-
const category = categorizeInstruction(instruction);
|
|
673
|
-
let data;
|
|
674
|
-
if ("parsed" in instruction) {
|
|
675
|
-
data = instruction.parsed;
|
|
676
|
-
}
|
|
677
|
-
const accounts = [];
|
|
678
|
-
if ("accounts" in instruction && Array.isArray(instruction.accounts)) {
|
|
679
|
-
for (const acc of instruction.accounts) {
|
|
680
|
-
const address = typeof acc === "string" ? acc : acc.toString();
|
|
681
|
-
accounts.push(address);
|
|
682
|
-
}
|
|
683
|
-
}
|
|
684
|
-
instructions.push({
|
|
685
|
-
programId,
|
|
686
|
-
category,
|
|
687
|
-
signature,
|
|
688
|
-
blockTime: tx.blockTime,
|
|
689
|
-
data,
|
|
690
|
-
accounts: accounts.length > 0 ? accounts : void 0
|
|
691
|
-
});
|
|
692
|
-
}
|
|
693
|
-
return instructions;
|
|
694
|
-
}
|
|
695
|
-
function extractCounterparties(transfers, targetAddress) {
|
|
696
|
-
const counterparties = /* @__PURE__ */ new Set();
|
|
697
|
-
for (const transfer of transfers) {
|
|
698
|
-
if (transfer.from === targetAddress) {
|
|
699
|
-
counterparties.add(transfer.to);
|
|
700
|
-
} else if (transfer.to === targetAddress) {
|
|
701
|
-
counterparties.add(transfer.from);
|
|
702
|
-
}
|
|
703
|
-
}
|
|
704
|
-
return counterparties;
|
|
705
|
-
}
|
|
706
|
-
function calculateTimeRange(transactions) {
|
|
707
|
-
let earliest = null;
|
|
708
|
-
let latest = null;
|
|
709
|
-
for (const tx of transactions) {
|
|
710
|
-
if (tx.blockTime) {
|
|
711
|
-
if (earliest === null || tx.blockTime < earliest) {
|
|
712
|
-
earliest = tx.blockTime;
|
|
713
|
-
}
|
|
714
|
-
if (latest === null || tx.blockTime > latest) {
|
|
715
|
-
latest = tx.blockTime;
|
|
716
|
-
}
|
|
717
|
-
}
|
|
718
|
-
}
|
|
719
|
-
return { earliest, latest };
|
|
720
|
-
}
|
|
721
|
-
function normalizeWalletData(rawData, labelProvider) {
|
|
722
|
-
const allTransfers = [];
|
|
723
|
-
const allInstructions = [];
|
|
724
|
-
const allTransactionMetadata = [];
|
|
725
|
-
const allTokenAccountEvents = [];
|
|
726
|
-
const allPDAInteractions = [];
|
|
727
|
-
const transactions = rawData.transactions || [];
|
|
728
|
-
for (const rawTx of transactions) {
|
|
729
|
-
if (!rawTx.transaction) continue;
|
|
730
|
-
try {
|
|
731
|
-
const solTransfers = extractSOLTransfers(rawTx.transaction, rawTx.signature);
|
|
732
|
-
const splTransfers = extractSPLTransfers(rawTx.transaction, rawTx.signature);
|
|
733
|
-
allTransfers.push(...solTransfers, ...splTransfers);
|
|
734
|
-
const instructions = extractInstructions(rawTx.transaction, rawTx.signature);
|
|
735
|
-
allInstructions.push(...instructions);
|
|
736
|
-
const metadata = extractTransactionMetadata(rawTx.transaction, rawTx.signature);
|
|
737
|
-
allTransactionMetadata.push(metadata);
|
|
738
|
-
const tokenEvents = extractTokenAccountEvents(rawTx.transaction, rawTx.signature);
|
|
739
|
-
allTokenAccountEvents.push(...tokenEvents);
|
|
740
|
-
const pdaInteractions = extractPDAInteractions(rawTx.transaction, rawTx.signature);
|
|
741
|
-
allPDAInteractions.push(...pdaInteractions);
|
|
742
|
-
} catch (error) {
|
|
743
|
-
console.warn(`Failed to normalize transaction ${rawTx.signature}:`, error);
|
|
744
|
-
continue;
|
|
745
|
-
}
|
|
746
|
-
}
|
|
747
|
-
const counterparties = extractCounterparties(allTransfers, rawData.address);
|
|
748
|
-
const labels = labelProvider ? labelProvider.lookupMany(Array.from(counterparties)) : /* @__PURE__ */ new Map();
|
|
749
|
-
const timeRange = calculateTimeRange(transactions);
|
|
750
|
-
const tokenAccounts = rawData.tokenAccounts.map((ta) => {
|
|
751
|
-
try {
|
|
752
|
-
return {
|
|
753
|
-
mint: ta.account.data.parsed.info.mint,
|
|
754
|
-
address: ta.pubkey.toString(),
|
|
755
|
-
balance: ta.account.data.parsed.info.tokenAmount.uiAmount ?? 0
|
|
756
|
-
};
|
|
757
|
-
} catch (error) {
|
|
758
|
-
return null;
|
|
759
|
-
}
|
|
760
|
-
}).filter((ta) => ta !== null);
|
|
761
|
-
const feePayers = /* @__PURE__ */ new Set();
|
|
762
|
-
const signers = /* @__PURE__ */ new Set();
|
|
763
|
-
const programs = /* @__PURE__ */ new Set();
|
|
764
|
-
for (const metadata of allTransactionMetadata) {
|
|
765
|
-
feePayers.add(metadata.feePayer);
|
|
766
|
-
for (const signer of metadata.signers) {
|
|
767
|
-
signers.add(signer);
|
|
768
|
-
}
|
|
769
|
-
}
|
|
770
|
-
for (const instruction of allInstructions) {
|
|
771
|
-
programs.add(instruction.programId);
|
|
772
|
-
}
|
|
773
|
-
return {
|
|
774
|
-
target: rawData.address,
|
|
775
|
-
targetType: "wallet",
|
|
776
|
-
transfers: allTransfers,
|
|
777
|
-
instructions: allInstructions,
|
|
778
|
-
counterparties,
|
|
779
|
-
labels,
|
|
780
|
-
tokenAccounts,
|
|
781
|
-
timeRange,
|
|
782
|
-
transactionCount: transactions.length,
|
|
783
|
-
// Solana-specific fields
|
|
784
|
-
transactions: allTransactionMetadata,
|
|
785
|
-
tokenAccountEvents: allTokenAccountEvents,
|
|
786
|
-
pdaInteractions: allPDAInteractions,
|
|
787
|
-
feePayers,
|
|
788
|
-
signers,
|
|
789
|
-
programs
|
|
790
|
-
};
|
|
791
|
-
}
|
|
792
|
-
function normalizeTransactionData(rawData, labelProvider) {
|
|
793
|
-
const allTransfers = [];
|
|
794
|
-
const allInstructions = [];
|
|
795
|
-
const counterparties = /* @__PURE__ */ new Set();
|
|
796
|
-
const allTransactionMetadata = [];
|
|
797
|
-
const allTokenAccountEvents = [];
|
|
798
|
-
const allPDAInteractions = [];
|
|
799
|
-
const feePayers = /* @__PURE__ */ new Set();
|
|
800
|
-
const signers = /* @__PURE__ */ new Set();
|
|
801
|
-
const programs = /* @__PURE__ */ new Set();
|
|
802
|
-
if (rawData.transaction) {
|
|
803
|
-
try {
|
|
804
|
-
const solTransfers = extractSOLTransfers(rawData.transaction, rawData.signature);
|
|
805
|
-
const splTransfers = extractSPLTransfers(rawData.transaction, rawData.signature);
|
|
806
|
-
allTransfers.push(...solTransfers, ...splTransfers);
|
|
807
|
-
const instructions = extractInstructions(rawData.transaction, rawData.signature);
|
|
808
|
-
allInstructions.push(...instructions);
|
|
809
|
-
const metadata = extractTransactionMetadata(rawData.transaction, rawData.signature);
|
|
810
|
-
allTransactionMetadata.push(metadata);
|
|
811
|
-
feePayers.add(metadata.feePayer);
|
|
812
|
-
for (const signer of metadata.signers) {
|
|
813
|
-
signers.add(signer);
|
|
814
|
-
}
|
|
815
|
-
const tokenEvents = extractTokenAccountEvents(rawData.transaction, rawData.signature);
|
|
816
|
-
allTokenAccountEvents.push(...tokenEvents);
|
|
817
|
-
const pdaInteractions = extractPDAInteractions(rawData.transaction, rawData.signature);
|
|
818
|
-
allPDAInteractions.push(...pdaInteractions);
|
|
819
|
-
const accountKeys = rawData.transaction.transaction.message.accountKeys;
|
|
820
|
-
if (accountKeys && Array.isArray(accountKeys)) {
|
|
821
|
-
for (const key of accountKeys) {
|
|
822
|
-
const address = typeof key === "string" ? key : key.pubkey.toString();
|
|
823
|
-
counterparties.add(address);
|
|
824
|
-
}
|
|
825
|
-
}
|
|
826
|
-
for (const instruction of instructions) {
|
|
827
|
-
programs.add(instruction.programId);
|
|
828
|
-
}
|
|
829
|
-
} catch (error) {
|
|
830
|
-
console.warn(`Failed to normalize transaction ${rawData.signature}:`, error);
|
|
831
|
-
}
|
|
832
|
-
}
|
|
833
|
-
const labels = labelProvider ? labelProvider.lookupMany(Array.from(counterparties)) : /* @__PURE__ */ new Map();
|
|
834
|
-
return {
|
|
835
|
-
target: rawData.signature,
|
|
836
|
-
targetType: "transaction",
|
|
837
|
-
transfers: allTransfers,
|
|
838
|
-
instructions: allInstructions,
|
|
839
|
-
counterparties,
|
|
840
|
-
labels,
|
|
841
|
-
tokenAccounts: [],
|
|
842
|
-
timeRange: {
|
|
843
|
-
earliest: rawData.transaction ? rawData.blockTime : null,
|
|
844
|
-
latest: rawData.transaction ? rawData.blockTime : null
|
|
845
|
-
},
|
|
846
|
-
transactionCount: rawData.transaction ? 1 : 0,
|
|
847
|
-
// Solana-specific fields
|
|
848
|
-
transactions: allTransactionMetadata,
|
|
849
|
-
tokenAccountEvents: allTokenAccountEvents,
|
|
850
|
-
pdaInteractions: allPDAInteractions,
|
|
851
|
-
feePayers,
|
|
852
|
-
signers,
|
|
853
|
-
programs
|
|
854
|
-
};
|
|
855
|
-
}
|
|
856
|
-
function normalizeProgramData(rawData, labelProvider) {
|
|
857
|
-
const allTransfers = [];
|
|
858
|
-
const allInstructions = [];
|
|
859
|
-
const counterparties = /* @__PURE__ */ new Set();
|
|
860
|
-
const allTransactionMetadata = [];
|
|
861
|
-
const allTokenAccountEvents = [];
|
|
862
|
-
const allPDAInteractions = [];
|
|
863
|
-
const feePayers = /* @__PURE__ */ new Set();
|
|
864
|
-
const signers = /* @__PURE__ */ new Set();
|
|
865
|
-
const programs = /* @__PURE__ */ new Set();
|
|
866
|
-
const transactions = rawData.relatedTransactions || [];
|
|
867
|
-
for (const rawTx of transactions) {
|
|
868
|
-
if (!rawTx.transaction) continue;
|
|
869
|
-
try {
|
|
870
|
-
const solTransfers = extractSOLTransfers(rawTx.transaction, rawTx.signature);
|
|
871
|
-
const splTransfers = extractSPLTransfers(rawTx.transaction, rawTx.signature);
|
|
872
|
-
allTransfers.push(...solTransfers, ...splTransfers);
|
|
873
|
-
const instructions = extractInstructions(rawTx.transaction, rawTx.signature);
|
|
874
|
-
allInstructions.push(...instructions);
|
|
875
|
-
const metadata = extractTransactionMetadata(rawTx.transaction, rawTx.signature);
|
|
876
|
-
allTransactionMetadata.push(metadata);
|
|
877
|
-
feePayers.add(metadata.feePayer);
|
|
878
|
-
for (const signer of metadata.signers) {
|
|
879
|
-
signers.add(signer);
|
|
880
|
-
}
|
|
881
|
-
const tokenEvents = extractTokenAccountEvents(rawTx.transaction, rawTx.signature);
|
|
882
|
-
allTokenAccountEvents.push(...tokenEvents);
|
|
883
|
-
const pdaInteractions = extractPDAInteractions(rawTx.transaction, rawTx.signature);
|
|
884
|
-
allPDAInteractions.push(...pdaInteractions);
|
|
885
|
-
const accountKeys = rawTx.transaction.transaction.message.accountKeys;
|
|
886
|
-
if (accountKeys && Array.isArray(accountKeys)) {
|
|
887
|
-
for (const key of accountKeys) {
|
|
888
|
-
const address = typeof key === "string" ? key : key.pubkey.toString();
|
|
889
|
-
counterparties.add(address);
|
|
890
|
-
}
|
|
891
|
-
}
|
|
892
|
-
for (const instruction of instructions) {
|
|
893
|
-
programs.add(instruction.programId);
|
|
894
|
-
}
|
|
895
|
-
} catch (error) {
|
|
896
|
-
console.warn(`Failed to normalize program transaction ${rawTx.signature}:`, error);
|
|
897
|
-
continue;
|
|
898
|
-
}
|
|
899
|
-
}
|
|
900
|
-
const timeRange = calculateTimeRange(transactions);
|
|
901
|
-
const labels = labelProvider ? labelProvider.lookupMany(Array.from(counterparties)) : /* @__PURE__ */ new Map();
|
|
902
|
-
return {
|
|
903
|
-
target: rawData.programId,
|
|
904
|
-
targetType: "program",
|
|
905
|
-
transfers: allTransfers,
|
|
906
|
-
instructions: allInstructions,
|
|
907
|
-
counterparties,
|
|
908
|
-
labels,
|
|
909
|
-
tokenAccounts: [],
|
|
910
|
-
timeRange,
|
|
911
|
-
transactionCount: transactions.length,
|
|
912
|
-
// Solana-specific fields
|
|
913
|
-
transactions: allTransactionMetadata,
|
|
914
|
-
tokenAccountEvents: allTokenAccountEvents,
|
|
915
|
-
pdaInteractions: allPDAInteractions,
|
|
916
|
-
feePayers,
|
|
917
|
-
signers,
|
|
918
|
-
programs
|
|
919
|
-
};
|
|
920
|
-
}
|
|
921
|
-
function detectCounterpartyReuse(context) {
|
|
922
|
-
const signals = [];
|
|
923
|
-
if (context.targetType !== "wallet" || context.transactionCount < 2) {
|
|
924
|
-
return signals;
|
|
925
|
-
}
|
|
926
|
-
if (context.transfers.length > 0) {
|
|
927
|
-
const interactionCounts = /* @__PURE__ */ new Map();
|
|
928
|
-
for (const transfer of context.transfers) {
|
|
929
|
-
const counterparty = transfer.from === context.target ? transfer.to : transfer.from;
|
|
930
|
-
if (counterparty === context.target) continue;
|
|
931
|
-
interactionCounts.set(counterparty, (interactionCounts.get(counterparty) || 0) + 1);
|
|
932
|
-
}
|
|
933
|
-
const reusedCounterparties = Array.from(interactionCounts.entries()).filter(([_, count]) => count >= 3).sort((a, b) => b[1] - a[1]);
|
|
934
|
-
if (reusedCounterparties.length > 0) {
|
|
935
|
-
const totalInteractions = context.transfers.length;
|
|
936
|
-
const topCounterpartyInteractions = reusedCounterparties[0][1];
|
|
937
|
-
const concentration = topCounterpartyInteractions / totalInteractions;
|
|
938
|
-
let severity = "LOW";
|
|
939
|
-
if (concentration > 0.5 || reusedCounterparties.length >= 5) {
|
|
940
|
-
severity = "HIGH";
|
|
941
|
-
} else if (concentration > 0.3 || reusedCounterparties.length >= 3) {
|
|
942
|
-
severity = "MEDIUM";
|
|
943
|
-
}
|
|
944
|
-
const evidence = reusedCounterparties.slice(0, 5).map(([addr, count]) => {
|
|
945
|
-
const label = context.labels.get(addr);
|
|
946
|
-
return {
|
|
947
|
-
description: `${count} transfers with ${addr.slice(0, 8)}...${addr.slice(-8)}${label ? ` (${label.name})` : ""}`,
|
|
948
|
-
severity: count > totalInteractions * 0.3 ? "HIGH" : count > totalInteractions * 0.15 ? "MEDIUM" : "LOW",
|
|
949
|
-
type: "address",
|
|
950
|
-
data: { address: addr, interactionCount: count }
|
|
951
|
-
};
|
|
952
|
-
});
|
|
953
|
-
signals.push({
|
|
954
|
-
id: "counterparty-reuse",
|
|
955
|
-
name: "Repeated Transfer Counterparties",
|
|
956
|
-
severity,
|
|
957
|
-
category: "linkability",
|
|
958
|
-
reason: `Wallet repeatedly transfers with ${reusedCounterparties.length} address(es). Top counterparty: ${topCounterpartyInteractions}/${totalInteractions} transfers.`,
|
|
959
|
-
impact: "Repeated interactions with the same addresses can be used to cluster wallets and build transaction graphs.",
|
|
960
|
-
evidence,
|
|
961
|
-
mitigation: "Use different wallets for different counterparties, or use privacy-preserving protocols."
|
|
962
|
-
});
|
|
963
|
-
}
|
|
964
|
-
}
|
|
965
|
-
if (context.programs && context.programs.size > 0) {
|
|
966
|
-
const programUsage = /* @__PURE__ */ new Map();
|
|
967
|
-
for (const instruction of context.instructions) {
|
|
968
|
-
programUsage.set(instruction.programId, (programUsage.get(instruction.programId) || 0) + 1);
|
|
969
|
-
}
|
|
970
|
-
const SYSTEM_PROGRAMS = [
|
|
971
|
-
"11111111111111111111111111111111",
|
|
972
|
-
"TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA",
|
|
973
|
-
"ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL",
|
|
974
|
-
"ComputeBudget111111111111111111111111111111"
|
|
975
|
-
];
|
|
976
|
-
const significantPrograms = Array.from(programUsage.entries()).filter(([programId]) => !SYSTEM_PROGRAMS.includes(programId)).filter(([_, count]) => count >= Math.min(3, Math.ceil(context.instructions.length * 0.1))).sort((a, b) => b[1] - a[1]);
|
|
977
|
-
if (significantPrograms.length >= 2) {
|
|
978
|
-
const evidence = significantPrograms.slice(0, 5).map(([programId, count]) => {
|
|
979
|
-
const label = context.labels.get(programId);
|
|
980
|
-
return {
|
|
981
|
-
description: `${programId.slice(0, 8)}...${label ? ` (${label.name})` : ""} used in ${count} instruction(s)`,
|
|
982
|
-
severity: "LOW",
|
|
983
|
-
reference: `https://solscan.io/account/${programId}`
|
|
984
|
-
};
|
|
985
|
-
});
|
|
986
|
-
signals.push({
|
|
987
|
-
id: "program-reuse",
|
|
988
|
-
name: "Repeated Program Interactions",
|
|
989
|
-
severity: "LOW",
|
|
990
|
-
category: "behavioral",
|
|
991
|
-
reason: `Wallet interacts with ${significantPrograms.length} non-system program(s) repeatedly.`,
|
|
992
|
-
impact: "Program usage patterns create a behavioral fingerprint. Addresses with similar patterns are likely related.",
|
|
993
|
-
mitigation: "This is generally unavoidable when using DeFi. Diversifying protocols can reduce fingerprinting.",
|
|
994
|
-
evidence
|
|
995
|
-
});
|
|
996
|
-
}
|
|
997
|
-
}
|
|
998
|
-
if (context.pdaInteractions && context.pdaInteractions.length > 0) {
|
|
999
|
-
const pdaUsage = /* @__PURE__ */ new Map();
|
|
1000
|
-
for (const pda of context.pdaInteractions) {
|
|
1001
|
-
if (!pdaUsage.has(pda.pda)) {
|
|
1002
|
-
pdaUsage.set(pda.pda, { count: 0, programId: pda.programId });
|
|
1003
|
-
}
|
|
1004
|
-
pdaUsage.get(pda.pda).count++;
|
|
1005
|
-
}
|
|
1006
|
-
const repeatedPDAs = Array.from(pdaUsage.entries()).filter(([_, { count }]) => count >= 2).sort((a, b) => b[1].count - a[1].count);
|
|
1007
|
-
if (repeatedPDAs.length > 0) {
|
|
1008
|
-
const evidence = repeatedPDAs.slice(0, 5).map(([pda, { count, programId }]) => ({
|
|
1009
|
-
description: `PDA ${pda.slice(0, 8)}... (program: ${programId.slice(0, 8)}...) used ${count} times`,
|
|
1010
|
-
severity: count > 3 ? "MEDIUM" : "LOW",
|
|
1011
|
-
reference: `https://solscan.io/account/${pda}`
|
|
1012
|
-
}));
|
|
1013
|
-
const maxCount = repeatedPDAs[0][1].count;
|
|
1014
|
-
const severity = maxCount > 5 ? "MEDIUM" : "LOW";
|
|
1015
|
-
signals.push({
|
|
1016
|
-
id: "pda-reuse",
|
|
1017
|
-
name: "Repeated PDA Interactions",
|
|
1018
|
-
severity,
|
|
1019
|
-
category: "linkability",
|
|
1020
|
-
reason: `${repeatedPDAs.length} Program-Derived Address(es) are used repeatedly. Max usage: ${maxCount} times.`,
|
|
1021
|
-
impact: "PDAs often represent user-specific accounts (e.g., your position in a protocol). Repeated usage links all interactions.",
|
|
1022
|
-
mitigation: "Some PDA reuse is inherent to Solana protocols. For sensitive operations, use fresh wallets.",
|
|
1023
|
-
evidence
|
|
1024
|
-
});
|
|
1025
|
-
}
|
|
1026
|
-
}
|
|
1027
|
-
if (context.transfers.length > 0 && context.instructions.length > 0) {
|
|
1028
|
-
const combos = /* @__PURE__ */ new Map();
|
|
1029
|
-
for (const transfer of context.transfers) {
|
|
1030
|
-
const counterparty = transfer.from === context.target ? transfer.to : transfer.from;
|
|
1031
|
-
if (counterparty === context.target) continue;
|
|
1032
|
-
const txInstructions = context.instructions.filter((inst) => inst.signature === transfer.signature);
|
|
1033
|
-
for (const inst of txInstructions) {
|
|
1034
|
-
const combo = `${counterparty}:${inst.programId}`;
|
|
1035
|
-
combos.set(combo, (combos.get(combo) || 0) + 1);
|
|
1036
|
-
}
|
|
1037
|
-
}
|
|
1038
|
-
const repeatedCombos = Array.from(combos.entries()).filter(([_, count]) => count >= 2).sort((a, b) => b[1] - a[1]);
|
|
1039
|
-
if (repeatedCombos.length > 0) {
|
|
1040
|
-
const evidence = repeatedCombos.slice(0, 3).map(([combo, count]) => {
|
|
1041
|
-
const [counterparty, programId] = combo.split(":");
|
|
1042
|
-
const label = context.labels.get(counterparty);
|
|
1043
|
-
return {
|
|
1044
|
-
description: `${counterparty.slice(0, 8)}...${label ? ` (${label.name})` : ""} + program ${programId.slice(0, 8)}... used ${count} times`,
|
|
1045
|
-
severity: "MEDIUM"
|
|
1046
|
-
};
|
|
1047
|
-
});
|
|
1048
|
-
signals.push({
|
|
1049
|
-
id: "counterparty-program-combo",
|
|
1050
|
-
name: "Repeated Counterparty-Program Combination",
|
|
1051
|
-
severity: "MEDIUM",
|
|
1052
|
-
category: "linkability",
|
|
1053
|
-
reason: `${repeatedCombos.length} specific counterparty-program combination(s) are reused.`,
|
|
1054
|
-
impact: "This creates a very specific fingerprint. The combination of WHO you interact with and WHAT program is highly identifying.",
|
|
1055
|
-
mitigation: "Rotate both counterparties and programs if privacy is critical.",
|
|
1056
|
-
evidence
|
|
1057
|
-
});
|
|
1058
|
-
}
|
|
1059
|
-
}
|
|
1060
|
-
return signals;
|
|
1061
|
-
}
|
|
1062
|
-
function detectAmountReuse(context) {
|
|
1063
|
-
const signals = [];
|
|
1064
|
-
if (context.transfers.length < 5) {
|
|
1065
|
-
return signals;
|
|
1066
|
-
}
|
|
1067
|
-
const amountCounts = /* @__PURE__ */ new Map();
|
|
1068
|
-
const roundNumbers = [];
|
|
1069
|
-
for (const transfer of context.transfers) {
|
|
1070
|
-
if (transfer.amount > 0 && Number.isInteger(transfer.amount) && transfer.amount >= 1) {
|
|
1071
|
-
roundNumbers.push(transfer.amount);
|
|
1072
|
-
}
|
|
1073
|
-
const amountKey = `${transfer.amount.toFixed(9)}-${transfer.token || "SOL"}`;
|
|
1074
|
-
if (!amountCounts.has(amountKey)) {
|
|
1075
|
-
amountCounts.set(amountKey, { count: 0, counterparties: /* @__PURE__ */ new Set(), signers: /* @__PURE__ */ new Set() });
|
|
1076
|
-
}
|
|
1077
|
-
const data = amountCounts.get(amountKey);
|
|
1078
|
-
data.count++;
|
|
1079
|
-
const counterparty = transfer.from === context.target ? transfer.to : transfer.from;
|
|
1080
|
-
if (counterparty !== context.target) {
|
|
1081
|
-
data.counterparties.add(counterparty);
|
|
1082
|
-
}
|
|
1083
|
-
const tx = context.transactions ? context.transactions.find((t) => t.signature === transfer.signature) : null;
|
|
1084
|
-
if (tx) {
|
|
1085
|
-
tx.signers.forEach((s) => data.signers.add(s));
|
|
1086
|
-
}
|
|
1087
|
-
}
|
|
1088
|
-
const reusedAmounts = Array.from(amountCounts.entries()).filter(([_, data]) => data.count >= 3).sort((a, b) => b[1].count - a[1].count);
|
|
1089
|
-
const hasRoundNumbers = roundNumbers.length >= 3;
|
|
1090
|
-
const hasReusedAmounts = reusedAmounts.length >= 2;
|
|
1091
|
-
if (hasRoundNumbers && roundNumbers.length >= 5) {
|
|
1092
|
-
signals.push({
|
|
1093
|
-
id: "amount-round-numbers",
|
|
1094
|
-
name: "Frequent Round Number Transfers",
|
|
1095
|
-
severity: "LOW",
|
|
1096
|
-
category: "behavioral",
|
|
1097
|
-
reason: `${roundNumbers.length} round-number transfers detected (e.g., 1 SOL, 10 SOL).`,
|
|
1098
|
-
impact: "Round numbers are common on Solana. Combined with other patterns, they can contribute to fingerprinting.",
|
|
1099
|
-
mitigation: "Vary amounts slightly if possible, but this is low priority.",
|
|
1100
|
-
evidence: [{
|
|
1101
|
-
description: `${roundNumbers.length} round-number transfers: ${roundNumbers.slice(0, 5).join(", ")}...`,
|
|
1102
|
-
severity: "LOW",
|
|
1103
|
-
type: "amount",
|
|
1104
|
-
data: { roundNumbers: roundNumbers.slice(0, 5) }
|
|
1105
|
-
}]
|
|
1106
|
-
});
|
|
1107
|
-
}
|
|
1108
|
-
const suspiciousReuse = reusedAmounts.filter(([_, data]) => {
|
|
1109
|
-
return data.counterparties.size === 1 && data.count >= 3;
|
|
1110
|
-
});
|
|
1111
|
-
if (suspiciousReuse.length > 0) {
|
|
1112
|
-
const evidence = suspiciousReuse.slice(0, 3).map(([amountKey, data]) => {
|
|
1113
|
-
const [amount, token] = amountKey.split("-");
|
|
1114
|
-
const counterparty = Array.from(data.counterparties)[0];
|
|
1115
|
-
return {
|
|
1116
|
-
description: `${amount} ${token} sent to ${counterparty.slice(0, 8)}... ${data.count} times`,
|
|
1117
|
-
severity: "MEDIUM",
|
|
1118
|
-
type: "amount",
|
|
1119
|
-
data: { amount: parseFloat(amount), token, count: data.count, counterparty }
|
|
1120
|
-
};
|
|
1121
|
-
});
|
|
1122
|
-
signals.push({
|
|
1123
|
-
id: "amount-reuse-counterparty",
|
|
1124
|
-
name: "Same Amount to Same Counterparty",
|
|
1125
|
-
severity: "MEDIUM",
|
|
1126
|
-
category: "behavioral",
|
|
1127
|
-
reason: `${suspiciousReuse.length} amount(s) repeatedly sent to the same counterparty.`,
|
|
1128
|
-
impact: "Sending the same amount to the same address multiple times creates a strong pattern. This is likely automated or habitual behavior.",
|
|
1129
|
-
mitigation: "Vary amounts when sending to the same address, or use privacy protocols.",
|
|
1130
|
-
evidence
|
|
1131
|
-
});
|
|
1132
|
-
}
|
|
1133
|
-
const signerReuse = reusedAmounts.filter(([_, data]) => {
|
|
1134
|
-
return data.signers.size <= 2 && data.count >= 3;
|
|
1135
|
-
});
|
|
1136
|
-
if (signerReuse.length > 0 && suspiciousReuse.length === 0) {
|
|
1137
|
-
const evidence = signerReuse.slice(0, 3).map(([amountKey, data]) => {
|
|
1138
|
-
const [amount, token] = amountKey.split("-");
|
|
1139
|
-
return {
|
|
1140
|
-
description: `${amount} ${token} used ${data.count} times with ${data.signers.size} signer(s)`,
|
|
1141
|
-
severity: "LOW",
|
|
1142
|
-
type: "amount",
|
|
1143
|
-
data: { amount: parseFloat(amount), token, count: data.count }
|
|
1144
|
-
};
|
|
1145
|
-
});
|
|
1146
|
-
signals.push({
|
|
1147
|
-
id: "amount-reuse-pattern",
|
|
1148
|
-
name: "Repeated Amount Pattern",
|
|
1149
|
-
severity: "LOW",
|
|
1150
|
-
category: "behavioral",
|
|
1151
|
-
reason: `${signerReuse.length} amount(s) are reused multiple times with consistent signers.`,
|
|
1152
|
-
impact: "Amount reuse alone is relatively weak, but combined with other signals it contributes to behavioral fingerprinting.",
|
|
1153
|
-
mitigation: "Vary transaction amounts to reduce pattern visibility.",
|
|
1154
|
-
evidence
|
|
1155
|
-
});
|
|
1156
|
-
}
|
|
1157
|
-
const veryReused = reusedAmounts.filter(([_, data]) => data.count >= 5);
|
|
1158
|
-
if (veryReused.length > 0 && suspiciousReuse.length === 0 && signerReuse.length === 0) {
|
|
1159
|
-
const evidence = veryReused.slice(0, 3).map(([amountKey, data]) => {
|
|
1160
|
-
const [amount, token] = amountKey.split("-");
|
|
1161
|
-
return {
|
|
1162
|
-
description: `${amount} ${token} used ${data.count} times across ${data.counterparties.size} counterparties`,
|
|
1163
|
-
severity: data.count > 10 ? "MEDIUM" : "LOW",
|
|
1164
|
-
type: "amount",
|
|
1165
|
-
data: { amount: parseFloat(amount), token, count: data.count }
|
|
1166
|
-
};
|
|
1167
|
-
});
|
|
1168
|
-
const maxCount = veryReused[0][1].count;
|
|
1169
|
-
const severity = maxCount > 10 ? "MEDIUM" : "LOW";
|
|
1170
|
-
signals.push({
|
|
1171
|
-
id: "amount-reuse-frequency",
|
|
1172
|
-
name: "High-Frequency Amount Reuse",
|
|
1173
|
-
severity,
|
|
1174
|
-
category: "behavioral",
|
|
1175
|
-
reason: `${veryReused.length} amount(s) are used very frequently (${maxCount} times for top amount).`,
|
|
1176
|
-
impact: "Extremely frequent reuse of specific amounts suggests automation or habitual behavior, creating a detectable pattern.",
|
|
1177
|
-
mitigation: "If running automated systems, add randomization to amounts.",
|
|
1178
|
-
evidence
|
|
1179
|
-
});
|
|
1180
|
-
}
|
|
1181
|
-
return signals;
|
|
1182
|
-
}
|
|
1183
|
-
function detectTimingPatterns(context) {
|
|
1184
|
-
const signals = [];
|
|
1185
|
-
if (!context.timeRange.earliest || !context.timeRange.latest) {
|
|
1186
|
-
return signals;
|
|
1187
|
-
}
|
|
1188
|
-
if (context.transactionCount < 3) {
|
|
1189
|
-
return signals;
|
|
1190
|
-
}
|
|
1191
|
-
const timeSpanSeconds = context.timeRange.latest - context.timeRange.earliest;
|
|
1192
|
-
const timeSpanHours = timeSpanSeconds / 3600;
|
|
1193
|
-
if (timeSpanHours === 0) {
|
|
1194
|
-
return signals;
|
|
1195
|
-
}
|
|
1196
|
-
const txRate = context.transactionCount / timeSpanHours;
|
|
1197
|
-
let isBurst = false;
|
|
1198
|
-
let burstSeverity = "LOW";
|
|
1199
|
-
if (txRate > 10) {
|
|
1200
|
-
burstSeverity = "HIGH";
|
|
1201
|
-
isBurst = true;
|
|
1202
|
-
} else if (txRate > 5) {
|
|
1203
|
-
burstSeverity = "MEDIUM";
|
|
1204
|
-
isBurst = true;
|
|
1205
|
-
} else if (timeSpanHours < 1 && context.transactionCount >= 3) {
|
|
1206
|
-
burstSeverity = "MEDIUM";
|
|
1207
|
-
isBurst = true;
|
|
1208
|
-
}
|
|
1209
|
-
if (isBurst) {
|
|
1210
|
-
signals.push({
|
|
1211
|
-
id: "timing-burst",
|
|
1212
|
-
name: "Transaction Burst Pattern",
|
|
1213
|
-
severity: burstSeverity,
|
|
1214
|
-
confidence: 0.8,
|
|
1215
|
-
category: "behavioral",
|
|
1216
|
-
reason: `Concentrated activity: ${context.transactionCount} transactions in ${timeSpanHours.toFixed(1)} hours`,
|
|
1217
|
-
impact: "Concentrated transaction activity creates timing fingerprints that can be used to correlate your transactions and link them to specific events or behaviors.",
|
|
1218
|
-
mitigation: "Spread transactions over longer time periods, use scheduled transactions, or batch operations to reduce timing correlation.",
|
|
1219
|
-
evidence: [{
|
|
1220
|
-
description: `${context.transactionCount} transactions in ${timeSpanHours.toFixed(1)} hours (${txRate.toFixed(2)} tx/hour)`,
|
|
1221
|
-
severity: burstSeverity,
|
|
1222
|
-
reference: void 0
|
|
1223
|
-
}]
|
|
1224
|
-
});
|
|
1225
|
-
}
|
|
1226
|
-
if (context.transactions && context.transactions.length >= 5) {
|
|
1227
|
-
const timestamps = context.transactions.map((tx) => tx.blockTime).filter((time) => time !== void 0).sort((a, b) => a - b);
|
|
1228
|
-
if (timestamps.length >= 5) {
|
|
1229
|
-
const gaps = [];
|
|
1230
|
-
for (let i = 1; i < timestamps.length; i++) {
|
|
1231
|
-
gaps.push(timestamps[i] - timestamps[i - 1]);
|
|
1232
|
-
}
|
|
1233
|
-
const avgGap = gaps.reduce((sum, gap) => sum + gap, 0) / gaps.length;
|
|
1234
|
-
const variance = gaps.reduce((sum, gap) => sum + Math.pow(gap - avgGap, 2), 0) / gaps.length;
|
|
1235
|
-
const stdDev = Math.sqrt(variance);
|
|
1236
|
-
const coefficientOfVariation = stdDev / avgGap;
|
|
1237
|
-
if (coefficientOfVariation < 0.3 && avgGap > 60) {
|
|
1238
|
-
const intervalMinutes = Math.round(avgGap / 60);
|
|
1239
|
-
const intervalHours = avgGap / 3600;
|
|
1240
|
-
let severity = "LOW";
|
|
1241
|
-
if (intervalHours >= 23 && intervalHours <= 25) {
|
|
1242
|
-
severity = "HIGH";
|
|
1243
|
-
} else if (intervalHours >= 0.9 && intervalHours <= 1.1) {
|
|
1244
|
-
severity = "HIGH";
|
|
1245
|
-
} else if (gaps.length >= 10) {
|
|
1246
|
-
severity = "MEDIUM";
|
|
1247
|
-
}
|
|
1248
|
-
signals.push({
|
|
1249
|
-
id: "timing-regular-interval",
|
|
1250
|
-
name: "Regular Transaction Interval",
|
|
1251
|
-
severity,
|
|
1252
|
-
confidence: 0.85,
|
|
1253
|
-
category: "behavioral",
|
|
1254
|
-
reason: `Transactions occur at regular ${intervalMinutes < 60 ? `${intervalMinutes}-minute` : `${intervalHours.toFixed(1)}-hour`} intervals.`,
|
|
1255
|
-
impact: "Regular timing patterns are highly distinctive fingerprints. They suggest automated behavior and can reveal timezone, schedule, or bot configuration.",
|
|
1256
|
-
mitigation: "Add random delays between transactions. Vary the timing to avoid predictable patterns.",
|
|
1257
|
-
evidence: [{
|
|
1258
|
-
description: `${gaps.length} transactions with average ${intervalMinutes}-minute intervals (${(coefficientOfVariation * 100).toFixed(1)}% variation)`,
|
|
1259
|
-
severity,
|
|
1260
|
-
reference: void 0
|
|
1261
|
-
}]
|
|
1262
|
-
});
|
|
1263
|
-
}
|
|
1264
|
-
}
|
|
1265
|
-
}
|
|
1266
|
-
if (context.transactions && context.transactions.length >= 10) {
|
|
1267
|
-
const timestamps = context.transactions.map((tx) => tx.blockTime).filter((time) => time !== void 0);
|
|
1268
|
-
if (timestamps.length >= 10) {
|
|
1269
|
-
const hours = timestamps.map((ts) => new Date(ts * 1e3).getUTCHours());
|
|
1270
|
-
const hourCounts = /* @__PURE__ */ new Map();
|
|
1271
|
-
hours.forEach((hour) => {
|
|
1272
|
-
hourCounts.set(hour, (hourCounts.get(hour) || 0) + 1);
|
|
1273
|
-
});
|
|
1274
|
-
const maxCount = Math.max(...Array.from(hourCounts.values()));
|
|
1275
|
-
const concentration = maxCount / hours.length;
|
|
1276
|
-
if (concentration > 0.4) {
|
|
1277
|
-
const mostActiveHours = Array.from(hourCounts.entries()).filter(([_, count]) => count >= maxCount * 0.8).map(([hour]) => hour).sort((a, b) => a - b);
|
|
1278
|
-
signals.push({
|
|
1279
|
-
id: "timing-timezone-pattern",
|
|
1280
|
-
name: "Consistent Time-of-Day Pattern",
|
|
1281
|
-
severity: "MEDIUM",
|
|
1282
|
-
confidence: 0.7,
|
|
1283
|
-
category: "behavioral",
|
|
1284
|
-
reason: `${Math.round(concentration * 100)}% of transactions occur during specific hours (${mostActiveHours.map((h) => `${h}:00`).join(", ")} UTC).`,
|
|
1285
|
-
impact: "Time-of-day patterns can reveal timezone or daily schedule, contributing to identity fingerprinting.",
|
|
1286
|
-
mitigation: "Vary transaction times across different hours of the day. Use scheduled transactions or automation to obscure your timezone.",
|
|
1287
|
-
evidence: [{
|
|
1288
|
-
description: `${maxCount}/${hours.length} transactions during ${mostActiveHours.length} hour(s)`,
|
|
1289
|
-
severity: "MEDIUM",
|
|
1290
|
-
reference: void 0
|
|
1291
|
-
}]
|
|
1292
|
-
});
|
|
1293
|
-
}
|
|
1294
|
-
}
|
|
1295
|
-
}
|
|
1296
|
-
return signals;
|
|
1297
|
-
}
|
|
1298
|
-
function detectKnownEntityInteraction(context) {
|
|
1299
|
-
const signals = [];
|
|
1300
|
-
if (context.labels.size === 0) {
|
|
1301
|
-
return signals;
|
|
1302
|
-
}
|
|
1303
|
-
const entityTypeGroups = /* @__PURE__ */ new Map();
|
|
1304
|
-
for (const [address, label] of context.labels.entries()) {
|
|
1305
|
-
let interactionCount = 0;
|
|
1306
|
-
const relatedTxs = [];
|
|
1307
|
-
for (const transfer of context.transfers) {
|
|
1308
|
-
if (transfer.from === address || transfer.to === address) {
|
|
1309
|
-
interactionCount++;
|
|
1310
|
-
if (relatedTxs.length < 3) {
|
|
1311
|
-
relatedTxs.push(transfer.signature);
|
|
1312
|
-
}
|
|
1313
|
-
}
|
|
1314
|
-
}
|
|
1315
|
-
if (interactionCount > 0) {
|
|
1316
|
-
if (!entityTypeGroups.has(label.type)) {
|
|
1317
|
-
entityTypeGroups.set(label.type, []);
|
|
1318
|
-
}
|
|
1319
|
-
entityTypeGroups.get(label.type).push({
|
|
1320
|
-
address,
|
|
1321
|
-
label,
|
|
1322
|
-
count: interactionCount,
|
|
1323
|
-
txs: relatedTxs
|
|
1324
|
-
});
|
|
1325
|
-
}
|
|
1326
|
-
}
|
|
1327
|
-
if (entityTypeGroups.size === 0) {
|
|
1328
|
-
return signals;
|
|
1329
|
-
}
|
|
1330
|
-
const exchanges = entityTypeGroups.get("exchange");
|
|
1331
|
-
if (exchanges && exchanges.length > 0) {
|
|
1332
|
-
const evidence = exchanges.map((entity) => ({
|
|
1333
|
-
description: `${entity.count} interaction(s) with ${entity.label.name}`,
|
|
1334
|
-
severity: "HIGH",
|
|
1335
|
-
reference: entity.address
|
|
1336
|
-
}));
|
|
1337
|
-
const totalExchangeTxs = exchanges.reduce((sum, e) => sum + e.count, 0);
|
|
1338
|
-
signals.push({
|
|
1339
|
-
id: "known-entity-exchange",
|
|
1340
|
-
name: "Centralized Exchange Interaction",
|
|
1341
|
-
severity: "HIGH",
|
|
1342
|
-
confidence: 0.95,
|
|
1343
|
-
category: "identity-linkage",
|
|
1344
|
-
reason: `Wallet interacted with ${exchanges.length} centralized exchange(s) in ${totalExchangeTxs} transaction(s).`,
|
|
1345
|
-
impact: "Centralized exchanges have KYC data. Direct interactions can link your on-chain address to your real-world identity through account records, IP addresses, and withdrawal/deposit patterns.",
|
|
1346
|
-
mitigation: "Use intermediate wallets to break the direct link. Deposit to privacy protocols before going to CEX. Consider DEXs for better privacy.",
|
|
1347
|
-
evidence
|
|
1348
|
-
});
|
|
1349
|
-
}
|
|
1350
|
-
const bridges = entityTypeGroups.get("bridge");
|
|
1351
|
-
if (bridges && bridges.length > 0) {
|
|
1352
|
-
const evidence = bridges.map((entity) => ({
|
|
1353
|
-
description: `${entity.count} interaction(s) with ${entity.label.name}`,
|
|
1354
|
-
severity: "MEDIUM",
|
|
1355
|
-
reference: entity.address
|
|
1356
|
-
}));
|
|
1357
|
-
signals.push({
|
|
1358
|
-
id: "known-entity-bridge",
|
|
1359
|
-
name: "Bridge Protocol Interaction",
|
|
1360
|
-
severity: "MEDIUM",
|
|
1361
|
-
confidence: 0.85,
|
|
1362
|
-
category: "identity-linkage",
|
|
1363
|
-
reason: `Wallet interacted with ${bridges.length} bridge protocol(s).`,
|
|
1364
|
-
impact: "Bridge transactions can link your Solana address to addresses on other chains, expanding the tracking surface.",
|
|
1365
|
-
mitigation: "Use privacy-preserving bridges when available. Create separate addresses for cross-chain activity.",
|
|
1366
|
-
evidence
|
|
1367
|
-
});
|
|
1368
|
-
}
|
|
1369
|
-
const others = Array.from(entityTypeGroups.entries()).filter(([type]) => type !== "exchange" && type !== "bridge");
|
|
1370
|
-
if (others.length > 0) {
|
|
1371
|
-
const allOtherEntities = others.flatMap(([_, entities]) => entities);
|
|
1372
|
-
const evidence = allOtherEntities.slice(0, 5).map((entity) => ({
|
|
1373
|
-
description: `${entity.count} interaction(s) with ${entity.label.name} (${entity.label.type})`,
|
|
1374
|
-
severity: "LOW",
|
|
1375
|
-
reference: entity.address
|
|
1376
|
-
}));
|
|
1377
|
-
const totalOtherTxs = allOtherEntities.reduce((sum, e) => sum + e.count, 0);
|
|
1378
|
-
signals.push({
|
|
1379
|
-
id: "known-entity-other",
|
|
1380
|
-
name: "Known Entity Interactions",
|
|
1381
|
-
severity: "LOW",
|
|
1382
|
-
confidence: 0.75,
|
|
1383
|
-
category: "behavioral",
|
|
1384
|
-
reason: `Wallet interacted with ${allOtherEntities.length} known entit${allOtherEntities.length === 1 ? "y" : "ies"} (${totalOtherTxs} transactions).`,
|
|
1385
|
-
impact: "Interactions with known entities create reference points in your transaction history. These can be used to correlate activity and build behavioral profiles.",
|
|
1386
|
-
mitigation: "While interacting with known protocols is often necessary, be aware it creates public association with those services.",
|
|
1387
|
-
evidence
|
|
1388
|
-
});
|
|
1389
|
-
}
|
|
1390
|
-
for (const [address, label] of context.labels.entries()) {
|
|
1391
|
-
let interactionCount = 0;
|
|
1392
|
-
for (const transfer of context.transfers) {
|
|
1393
|
-
if (transfer.from === address || transfer.to === address) {
|
|
1394
|
-
interactionCount++;
|
|
1395
|
-
}
|
|
1396
|
-
}
|
|
1397
|
-
const concentration = interactionCount / context.transfers.length;
|
|
1398
|
-
if (concentration > 0.3 && interactionCount >= 5) {
|
|
1399
|
-
signals.push({
|
|
1400
|
-
id: `known-entity-frequent-${address.slice(0, 8)}`,
|
|
1401
|
-
name: "Frequent Single Entity Interaction",
|
|
1402
|
-
severity: label.type === "exchange" ? "HIGH" : "MEDIUM",
|
|
1403
|
-
confidence: 0.85,
|
|
1404
|
-
category: "behavioral",
|
|
1405
|
-
reason: `${Math.round(concentration * 100)}% of transfers (${interactionCount}/${context.transfers.length}) involve ${label.name}.`,
|
|
1406
|
-
impact: "Heavy concentration of activity with one entity creates a strong link and behavioral dependency that is easily identified.",
|
|
1407
|
-
mitigation: "Diversify your interactions across multiple services. Use different addresses for different service providers.",
|
|
1408
|
-
evidence: [{
|
|
1409
|
-
description: `${interactionCount} transfers with ${label.name} (${label.type})`,
|
|
1410
|
-
severity: label.type === "exchange" ? "HIGH" : "MEDIUM",
|
|
1411
|
-
reference: address
|
|
1412
|
-
}]
|
|
1413
|
-
});
|
|
1414
|
-
}
|
|
1415
|
-
}
|
|
1416
|
-
return signals;
|
|
1417
|
-
}
|
|
1418
|
-
function detectBalanceTraceability(context) {
|
|
1419
|
-
const signals = [];
|
|
1420
|
-
if (context.targetType !== "wallet" || context.transfers.length < 2) {
|
|
1421
|
-
return signals;
|
|
1422
|
-
}
|
|
1423
|
-
const amountPairs = /* @__PURE__ */ new Map();
|
|
1424
|
-
for (const transfer of context.transfers) {
|
|
1425
|
-
const amountKey = transfer.amount.toFixed(6);
|
|
1426
|
-
amountPairs.set(amountKey, (amountPairs.get(amountKey) || 0) + 1);
|
|
1427
|
-
}
|
|
1428
|
-
const matchingPairs = Array.from(amountPairs.entries()).filter(([_, count]) => count >= 2).sort((a, b) => b[1] - a[1]);
|
|
1429
|
-
if (matchingPairs.length >= 2) {
|
|
1430
|
-
const evidence = matchingPairs.slice(0, 5).map(([amount, count]) => ({
|
|
1431
|
-
description: `Amount ${amount} appears in ${count} transfers`,
|
|
1432
|
-
severity: count >= 4 ? "HIGH" : "MEDIUM",
|
|
1433
|
-
reference: void 0
|
|
1434
|
-
}));
|
|
1435
|
-
const severity = matchingPairs.length >= 4 ? "HIGH" : matchingPairs.length >= 3 ? "MEDIUM" : "LOW";
|
|
1436
|
-
signals.push({
|
|
1437
|
-
id: "balance-matching-pairs",
|
|
1438
|
-
name: "Matching Send/Receive Amounts",
|
|
1439
|
-
severity,
|
|
1440
|
-
confidence: 0.7,
|
|
1441
|
-
category: "traceability",
|
|
1442
|
-
reason: `${matchingPairs.length} amount(s) appear in multiple transfers, suggesting balance movements.`,
|
|
1443
|
-
impact: "Matching amounts can be used to trace balance flows. If you receive X and later send X, observers can link these transactions.",
|
|
1444
|
-
mitigation: "Split large transfers into multiple smaller ones with varied amounts. Avoid sending exact amounts you received.",
|
|
1445
|
-
evidence
|
|
1446
|
-
});
|
|
1447
|
-
}
|
|
1448
|
-
const sequentialPairs = [];
|
|
1449
|
-
for (let i = 0; i < context.transfers.length - 1; i++) {
|
|
1450
|
-
const current = context.transfers[i];
|
|
1451
|
-
const next = context.transfers[i + 1];
|
|
1452
|
-
if (current.blockTime && next.blockTime) {
|
|
1453
|
-
const timeDiff = Math.abs(next.blockTime - current.blockTime);
|
|
1454
|
-
const amountDiff = Math.abs(current.amount - next.amount);
|
|
1455
|
-
const percentDiff = amountDiff / Math.max(current.amount, next.amount);
|
|
1456
|
-
if (timeDiff < 3600 && percentDiff < 0.1 && current.amount > 0.1) {
|
|
1457
|
-
sequentialPairs.push({
|
|
1458
|
-
index: i,
|
|
1459
|
-
amount1: current.amount,
|
|
1460
|
-
amount2: next.amount,
|
|
1461
|
-
timeDiff
|
|
1462
|
-
});
|
|
1463
|
-
}
|
|
1464
|
-
}
|
|
1465
|
-
}
|
|
1466
|
-
if (sequentialPairs.length >= 2) {
|
|
1467
|
-
const evidence = sequentialPairs.slice(0, 3).map((pair) => ({
|
|
1468
|
-
description: `${pair.amount1.toFixed(4)} \u2192 ${pair.amount2.toFixed(4)} (${Math.round(pair.timeDiff / 60)} minutes apart)`,
|
|
1469
|
-
severity: pair.timeDiff < 600 ? "HIGH" : "MEDIUM",
|
|
1470
|
-
reference: void 0
|
|
1471
|
-
}));
|
|
1472
|
-
signals.push({
|
|
1473
|
-
id: "balance-sequential-similar",
|
|
1474
|
-
name: "Sequential Similar Amount Transfers",
|
|
1475
|
-
severity: "MEDIUM",
|
|
1476
|
-
confidence: 0.65,
|
|
1477
|
-
category: "traceability",
|
|
1478
|
-
reason: `${sequentialPairs.length} instance(s) of similar amounts transferred in quick succession.`,
|
|
1479
|
-
impact: "Sequential similar amounts suggest balance movements and make it easy to trace funds through intermediate addresses.",
|
|
1480
|
-
mitigation: "Add random delays between transactions. Vary amounts to obscure the flow path.",
|
|
1481
|
-
evidence
|
|
1482
|
-
});
|
|
1483
|
-
}
|
|
1484
|
-
const roundNumbers = context.transfers.filter((t) => {
|
|
1485
|
-
const amount = t.amount;
|
|
1486
|
-
if (amount === 0) return false;
|
|
1487
|
-
return (amount === Math.floor(amount) || // Whole number
|
|
1488
|
-
amount * 10 === Math.floor(amount * 10) || // One decimal
|
|
1489
|
-
amount * 100 === Math.floor(amount * 100)) && (amount % 1 === 0 || // 1, 10, 100
|
|
1490
|
-
amount * 10 % 1 === 0 || // 0.1, 0.5
|
|
1491
|
-
amount * 100 % 1 === 0);
|
|
1492
|
-
});
|
|
1493
|
-
const roundNumberRatio = roundNumbers.length / context.transfers.length;
|
|
1494
|
-
if (roundNumberRatio > 0.7 && context.transfers.length >= 5) {
|
|
1495
|
-
signals.push({
|
|
1496
|
-
id: "balance-round-numbers",
|
|
1497
|
-
name: "High Proportion of Round Number Transfers",
|
|
1498
|
-
severity: "LOW",
|
|
1499
|
-
confidence: 0.6,
|
|
1500
|
-
category: "behavioral",
|
|
1501
|
-
reason: `${Math.round(roundNumberRatio * 100)}% of transfers use round numbers.`,
|
|
1502
|
-
impact: "Round numbers are easier to remember and track. They can contribute to balance traceability when combined with other patterns.",
|
|
1503
|
-
mitigation: "Use more varied amounts. Add small random values to make amounts less predictable.",
|
|
1504
|
-
evidence: [{
|
|
1505
|
-
description: `${roundNumbers.length}/${context.transfers.length} transfers are round numbers`,
|
|
1506
|
-
severity: "LOW",
|
|
1507
|
-
reference: void 0
|
|
1508
|
-
}]
|
|
1509
|
-
});
|
|
1510
|
-
}
|
|
1511
|
-
const tokenTransfers = /* @__PURE__ */ new Map();
|
|
1512
|
-
for (const transfer of context.transfers) {
|
|
1513
|
-
const token = transfer.token || "SOL";
|
|
1514
|
-
if (!tokenTransfers.has(token)) {
|
|
1515
|
-
tokenTransfers.set(token, []);
|
|
1516
|
-
}
|
|
1517
|
-
tokenTransfers.get(token).push(transfer);
|
|
1518
|
-
}
|
|
1519
|
-
for (const [token, transfers] of tokenTransfers) {
|
|
1520
|
-
if (transfers.length < 2) continue;
|
|
1521
|
-
const receives = transfers.filter((t) => t.to === context.target);
|
|
1522
|
-
const sends = transfers.filter((t) => t.from === context.target);
|
|
1523
|
-
for (const receive of receives) {
|
|
1524
|
-
for (const send of sends) {
|
|
1525
|
-
if (!receive.blockTime || !send.blockTime) continue;
|
|
1526
|
-
if (send.blockTime <= receive.blockTime) continue;
|
|
1527
|
-
const timeDiff = send.blockTime - receive.blockTime;
|
|
1528
|
-
const percentDiff = Math.abs(send.amount - receive.amount) / receive.amount;
|
|
1529
|
-
if (percentDiff < 0.05 && timeDiff < 86400 && receive.amount > 1) {
|
|
1530
|
-
signals.push({
|
|
1531
|
-
id: `balance-full-movement-${token}`,
|
|
1532
|
-
name: "Full Balance Movement Detected",
|
|
1533
|
-
severity: "HIGH",
|
|
1534
|
-
confidence: 0.8,
|
|
1535
|
-
category: "traceability",
|
|
1536
|
-
reason: `Received ${receive.amount.toFixed(4)} ${token}, then sent ${send.amount.toFixed(4)} ${token} shortly after.`,
|
|
1537
|
-
impact: "Moving entire received balances makes fund flow trivially traceable. The path from source to destination is clear.",
|
|
1538
|
-
mitigation: "Split received funds before sending. Mix with other funds. Add delays and intermediate steps.",
|
|
1539
|
-
evidence: [{
|
|
1540
|
-
description: `Received ${receive.amount.toFixed(4)} \u2192 Sent ${send.amount.toFixed(4)} (${Math.round(timeDiff / 60)} minutes later)`,
|
|
1541
|
-
severity: "HIGH",
|
|
1542
|
-
reference: void 0
|
|
1543
|
-
}]
|
|
1544
|
-
});
|
|
1545
|
-
break;
|
|
1546
|
-
}
|
|
1547
|
-
}
|
|
1548
|
-
}
|
|
1549
|
-
}
|
|
1550
|
-
return signals;
|
|
1551
|
-
}
|
|
1552
|
-
function detectFeePayerReuse(context) {
|
|
1553
|
-
const signals = [];
|
|
1554
|
-
if (context.targetType === "transaction") {
|
|
1555
|
-
return signals;
|
|
1556
|
-
}
|
|
1557
|
-
if (!context.feePayers || !context.transactions || context.transactions.length === 0) {
|
|
1558
|
-
return signals;
|
|
1559
|
-
}
|
|
1560
|
-
const feePayers = context.feePayers;
|
|
1561
|
-
const target = context.target;
|
|
1562
|
-
const targetIsFeePayer = feePayers.has(target);
|
|
1563
|
-
const onlyTargetPays = feePayers.size === 1 && targetIsFeePayer;
|
|
1564
|
-
if (onlyTargetPays) {
|
|
1565
|
-
return signals;
|
|
1566
|
-
}
|
|
1567
|
-
if (feePayers.size > 1 && targetIsFeePayer) {
|
|
1568
|
-
const externalFeePayers = Array.from(feePayers).filter((fp) => fp !== target);
|
|
1569
|
-
const feePayerCounts = /* @__PURE__ */ new Map();
|
|
1570
|
-
for (const tx of context.transactions) {
|
|
1571
|
-
if (tx.feePayer !== target) {
|
|
1572
|
-
feePayerCounts.set(tx.feePayer, (feePayerCounts.get(tx.feePayer) || 0) + 1);
|
|
1573
|
-
}
|
|
1574
|
-
}
|
|
1575
|
-
const evidence = [];
|
|
1576
|
-
for (const [feePayer, count] of feePayerCounts) {
|
|
1577
|
-
evidence.push({
|
|
1578
|
-
description: `${feePayer} paid fees for ${count} transaction(s)`,
|
|
1579
|
-
severity: count > 1 ? "HIGH" : "MEDIUM",
|
|
1580
|
-
reference: void 0
|
|
1581
|
-
});
|
|
1582
|
-
}
|
|
1583
|
-
const knownFeePayerLabel = externalFeePayers.find((fp) => context.labels.has(fp));
|
|
1584
|
-
const knownLabel = knownFeePayerLabel ? context.labels.get(knownFeePayerLabel) : null;
|
|
1585
|
-
signals.push({
|
|
1586
|
-
id: "fee-payer-external",
|
|
1587
|
-
name: "External Fee Payer Detected",
|
|
1588
|
-
severity: knownLabel ? "HIGH" : "MEDIUM",
|
|
1589
|
-
category: "linkability",
|
|
1590
|
-
reason: `${externalFeePayers.length} external wallet(s) paid fees for transactions involving this address${knownLabel ? `, including known entity: ${knownLabel.name}` : ""}.`,
|
|
1591
|
-
impact: "This address is linked to the fee payer(s). Anyone observing the blockchain can see this relationship. If the fee payer is identified, this address is also compromised.",
|
|
1592
|
-
mitigation: "Always pay your own transaction fees. Never allow third parties to pay fees for your transactions unless absolutely necessary. If using a relayer, understand that this creates a permanent on-chain link.",
|
|
1593
|
-
evidence
|
|
1594
|
-
});
|
|
1595
|
-
}
|
|
1596
|
-
if (!targetIsFeePayer && feePayers.size > 0) {
|
|
1597
|
-
const allFeePayers = Array.from(feePayers);
|
|
1598
|
-
const feePayerCounts = /* @__PURE__ */ new Map();
|
|
1599
|
-
for (const tx of context.transactions) {
|
|
1600
|
-
feePayerCounts.set(tx.feePayer, (feePayerCounts.get(tx.feePayer) || 0) + 1);
|
|
1601
|
-
}
|
|
1602
|
-
const evidence = [];
|
|
1603
|
-
for (const [feePayer, count] of feePayerCounts) {
|
|
1604
|
-
const label = context.labels.get(feePayer);
|
|
1605
|
-
evidence.push({
|
|
1606
|
-
description: `${feePayer}${label ? ` (${label.name})` : ""} paid fees for ${count} transaction(s)`,
|
|
1607
|
-
severity: "HIGH",
|
|
1608
|
-
reference: void 0
|
|
1609
|
-
});
|
|
1610
|
-
}
|
|
1611
|
-
const maxCount = Math.max(...Array.from(feePayerCounts.values()));
|
|
1612
|
-
const repeatedFeePayer = maxCount > 1;
|
|
1613
|
-
signals.push({
|
|
1614
|
-
id: "fee-payer-never-self",
|
|
1615
|
-
name: "Never Self-Pays Transaction Fees",
|
|
1616
|
-
severity: repeatedFeePayer ? "HIGH" : "HIGH",
|
|
1617
|
-
// Always HIGH - this is critical
|
|
1618
|
-
category: "linkability",
|
|
1619
|
-
reason: `This address has NEVER paid its own transaction fees. All ${context.transactionCount} transaction(s) were paid by ${allFeePayers.length} external wallet(s).`,
|
|
1620
|
-
impact: "This is a CRITICAL privacy leak. This address is trivially linked to all fee payer(s). This pattern suggests a managed account, hot wallet, or program-controlled address. The controlling entity is fully exposed.",
|
|
1621
|
-
mitigation: "This account model fundamentally compromises privacy. To improve: (1) Fund this address with SOL and pay your own fees, or (2) Use a fresh address for each operation, or (3) Accept that this address is permanently linked to its fee payer(s).",
|
|
1622
|
-
evidence
|
|
1623
|
-
});
|
|
1624
|
-
}
|
|
1625
|
-
if (context.targetType === "program") {
|
|
1626
|
-
const feePayerCounts = /* @__PURE__ */ new Map();
|
|
1627
|
-
for (const tx of context.transactions) {
|
|
1628
|
-
if (!feePayerCounts.has(tx.feePayer)) {
|
|
1629
|
-
feePayerCounts.set(tx.feePayer, /* @__PURE__ */ new Set());
|
|
1630
|
-
}
|
|
1631
|
-
for (const signer of tx.signers) {
|
|
1632
|
-
feePayerCounts.get(tx.feePayer).add(signer);
|
|
1633
|
-
}
|
|
1634
|
-
}
|
|
1635
|
-
const multiFeePayerOperators = [];
|
|
1636
|
-
for (const [feePayer, signers] of feePayerCounts) {
|
|
1637
|
-
if (signers.size > 1) {
|
|
1638
|
-
const txCount = context.transactions.filter((tx) => tx.feePayer === feePayer).length;
|
|
1639
|
-
multiFeePayerOperators.push({
|
|
1640
|
-
feePayer,
|
|
1641
|
-
signerCount: signers.size,
|
|
1642
|
-
txCount
|
|
1643
|
-
});
|
|
1644
|
-
}
|
|
1645
|
-
}
|
|
1646
|
-
if (multiFeePayerOperators.length > 0) {
|
|
1647
|
-
const evidence = multiFeePayerOperators.map((op) => ({
|
|
1648
|
-
description: `${op.feePayer} paid fees for ${op.txCount} transaction(s) involving ${op.signerCount} different signer(s)`,
|
|
1649
|
-
severity: "HIGH",
|
|
1650
|
-
reference: void 0
|
|
1651
|
-
}));
|
|
1652
|
-
signals.push({
|
|
1653
|
-
id: "fee-payer-multi-signer",
|
|
1654
|
-
name: "Fee Payer Controls Multiple Signers",
|
|
1655
|
-
severity: "HIGH",
|
|
1656
|
-
category: "linkability",
|
|
1657
|
-
reason: `${multiFeePayerOperators.length} fee payer(s) are paying fees for multiple different signers, suggesting centralized control or bot operation.`,
|
|
1658
|
-
impact: "All addresses funded by the same fee payer are linkable. This pattern exposes operational infrastructure.",
|
|
1659
|
-
mitigation: "If running bots or managing multiple accounts, use a unique fee payer for each to avoid linking them on-chain.",
|
|
1660
|
-
evidence
|
|
1661
|
-
});
|
|
1662
|
-
}
|
|
1663
|
-
}
|
|
1664
|
-
return signals;
|
|
1665
|
-
}
|
|
1666
|
-
function detectSignerOverlap(context) {
|
|
1667
|
-
const signals = [];
|
|
1668
|
-
if (context.transactionCount < 2) {
|
|
1669
|
-
return signals;
|
|
1670
|
-
}
|
|
1671
|
-
if (!context.transactions || context.transactions.length === 0) {
|
|
1672
|
-
return signals;
|
|
1673
|
-
}
|
|
1674
|
-
const signerFrequency = /* @__PURE__ */ new Map();
|
|
1675
|
-
const signerTransactions = /* @__PURE__ */ new Map();
|
|
1676
|
-
for (const tx of context.transactions) {
|
|
1677
|
-
for (const signer of tx.signers) {
|
|
1678
|
-
signerFrequency.set(signer, (signerFrequency.get(signer) || 0) + 1);
|
|
1679
|
-
if (!signerTransactions.has(signer)) {
|
|
1680
|
-
signerTransactions.set(signer, []);
|
|
1681
|
-
}
|
|
1682
|
-
signerTransactions.get(signer).push(tx.signature);
|
|
1683
|
-
}
|
|
1684
|
-
}
|
|
1685
|
-
const target = context.target;
|
|
1686
|
-
const frequentSigners = Array.from(signerFrequency.entries()).filter(([signer]) => signer !== target).filter(([_, count]) => count >= Math.min(3, Math.ceil(context.transactionCount * 0.3))).sort((a, b) => b[1] - a[1]);
|
|
1687
|
-
if (frequentSigners.length > 0) {
|
|
1688
|
-
const evidence = frequentSigners.map(([signer, count]) => {
|
|
1689
|
-
const label = context.labels.get(signer);
|
|
1690
|
-
return {
|
|
1691
|
-
description: `${signer}${label ? ` (${label.name})` : ""} signed ${count}/${context.transactionCount} transactions`,
|
|
1692
|
-
severity: count > context.transactionCount * 0.7 ? "HIGH" : "MEDIUM",
|
|
1693
|
-
reference: void 0
|
|
1694
|
-
};
|
|
1695
|
-
});
|
|
1696
|
-
const topSignerCount = frequentSigners[0][1];
|
|
1697
|
-
const severity = topSignerCount > context.transactionCount * 0.7 ? "HIGH" : "MEDIUM";
|
|
1698
|
-
signals.push({
|
|
1699
|
-
id: "signer-repeated",
|
|
1700
|
-
name: "Repeated Signer Across Transactions",
|
|
1701
|
-
severity,
|
|
1702
|
-
category: "linkability",
|
|
1703
|
-
reason: `${frequentSigners.length} address(es) repeatedly sign transactions involving the target. The most frequent signer appears in ${topSignerCount}/${context.transactionCount} transactions.`,
|
|
1704
|
-
impact: "Repeated signers create hard links between transactions. All transactions signed by the same address are trivially linkable.",
|
|
1705
|
-
mitigation: "If you control multiple addresses that sign together, they are permanently linked. Use separate signing keys for unrelated activities.",
|
|
1706
|
-
evidence
|
|
1707
|
-
});
|
|
1708
|
-
}
|
|
1709
|
-
const signerSets = /* @__PURE__ */ new Map();
|
|
1710
|
-
const signerSetExamples = /* @__PURE__ */ new Map();
|
|
1711
|
-
for (const tx of context.transactions) {
|
|
1712
|
-
const sortedSigners = [...tx.signers].sort();
|
|
1713
|
-
const setKey = JSON.stringify(sortedSigners);
|
|
1714
|
-
signerSets.set(setKey, (signerSets.get(setKey) || 0) + 1);
|
|
1715
|
-
if (!signerSetExamples.has(setKey)) {
|
|
1716
|
-
signerSetExamples.set(setKey, tx.signature);
|
|
1717
|
-
}
|
|
1718
|
-
}
|
|
1719
|
-
const repeatedSets = Array.from(signerSets.entries()).filter(([_, count]) => count > 1).sort((a, b) => b[1] - a[1]);
|
|
1720
|
-
if (repeatedSets.length > 0) {
|
|
1721
|
-
const evidence = repeatedSets.map(([setKey, count]) => {
|
|
1722
|
-
const signers = JSON.parse(setKey);
|
|
1723
|
-
const exampleSig = signerSetExamples.get(setKey);
|
|
1724
|
-
return {
|
|
1725
|
-
description: `${count} transactions with identical signer set: [${signers.map((s) => s.slice(0, 8)).join(", ")}...]`,
|
|
1726
|
-
severity: count > 2 ? "MEDIUM" : "LOW",
|
|
1727
|
-
reference: `https://solscan.io/tx/${exampleSig}`
|
|
1728
|
-
};
|
|
1729
|
-
});
|
|
1730
|
-
signals.push({
|
|
1731
|
-
id: "signer-set-reuse",
|
|
1732
|
-
name: "Repeated Multi-Signature Pattern",
|
|
1733
|
-
severity: "MEDIUM",
|
|
1734
|
-
category: "linkability",
|
|
1735
|
-
reason: `${repeatedSets.length} distinct signer set(s) are reused multiple times. This creates a unique fingerprint.`,
|
|
1736
|
-
impact: "Reused multi-sig patterns are highly unique and easily linkable. Even if addresses differ, the signer set pattern can identify related activity.",
|
|
1737
|
-
mitigation: "If using multi-sig for multiple transactions, rotate signing keys or use threshold signatures to vary the signer set.",
|
|
1738
|
-
evidence
|
|
1739
|
-
});
|
|
1740
|
-
}
|
|
1741
|
-
if (context.targetType === "program" || context.transactionCount > 10) {
|
|
1742
|
-
const signerCoSigners = /* @__PURE__ */ new Map();
|
|
1743
|
-
for (const tx of context.transactions) {
|
|
1744
|
-
for (const signer of tx.signers) {
|
|
1745
|
-
if (!signerCoSigners.has(signer)) {
|
|
1746
|
-
signerCoSigners.set(signer, /* @__PURE__ */ new Set());
|
|
1747
|
-
}
|
|
1748
|
-
for (const otherSigner of tx.signers) {
|
|
1749
|
-
if (otherSigner !== signer) {
|
|
1750
|
-
signerCoSigners.get(signer).add(otherSigner);
|
|
1751
|
-
}
|
|
1752
|
-
}
|
|
1753
|
-
}
|
|
1754
|
-
}
|
|
1755
|
-
const authorityCandidates = Array.from(signerCoSigners.entries()).filter(([_, coSigners]) => coSigners.size >= 3).sort((a, b) => b[1].size - a[1].size);
|
|
1756
|
-
if (authorityCandidates.length > 0) {
|
|
1757
|
-
const evidence = authorityCandidates.slice(0, 3).map(([signer, coSigners]) => {
|
|
1758
|
-
const label = context.labels.get(signer);
|
|
1759
|
-
const txCount = signerFrequency.get(signer) || 0;
|
|
1760
|
-
return {
|
|
1761
|
-
description: `${signer}${label ? ` (${label.name})` : ""} co-signed with ${coSigners.size} different addresses across ${txCount} transactions`,
|
|
1762
|
-
severity: "HIGH",
|
|
1763
|
-
reference: void 0
|
|
1764
|
-
};
|
|
1765
|
-
});
|
|
1766
|
-
signals.push({
|
|
1767
|
-
id: "signer-authority-hub",
|
|
1768
|
-
name: "Authority Signer Detected",
|
|
1769
|
-
severity: "HIGH",
|
|
1770
|
-
category: "linkability",
|
|
1771
|
-
reason: `${authorityCandidates.length} address(es) act as an authority, co-signing with multiple different wallets. This exposes a control hub.`,
|
|
1772
|
-
impact: "An authority signer links all accounts it co-signs with. This reveals organizational structure or bot infrastructure.",
|
|
1773
|
-
mitigation: 'Use unique authority keys for each logical group of accounts. Avoid having a single "master" signer.',
|
|
1774
|
-
evidence
|
|
1775
|
-
});
|
|
1776
|
-
}
|
|
1777
|
-
}
|
|
1778
|
-
return signals;
|
|
1779
|
-
}
|
|
1780
|
-
function detectInstructionFingerprinting(context) {
|
|
1781
|
-
const signals = [];
|
|
1782
|
-
if (context.transactionCount < 3) {
|
|
1783
|
-
return signals;
|
|
1784
|
-
}
|
|
1785
|
-
if (!context.transactions || context.transactions.length === 0) {
|
|
1786
|
-
return signals;
|
|
1787
|
-
}
|
|
1788
|
-
const sequenceFingerprints = /* @__PURE__ */ new Map();
|
|
1789
|
-
const sequenceExamples = /* @__PURE__ */ new Map();
|
|
1790
|
-
for (const tx of context.transactions) {
|
|
1791
|
-
const txInstructions = context.instructions.filter((inst) => inst.signature === tx.signature).map((inst) => inst.programId);
|
|
1792
|
-
if (txInstructions.length === 0) continue;
|
|
1793
|
-
const sequence = txInstructions.join("->");
|
|
1794
|
-
sequenceFingerprints.set(sequence, (sequenceFingerprints.get(sequence) || 0) + 1);
|
|
1795
|
-
if (!sequenceExamples.has(sequence)) {
|
|
1796
|
-
sequenceExamples.set(sequence, []);
|
|
1797
|
-
}
|
|
1798
|
-
sequenceExamples.get(sequence).push(tx.signature);
|
|
1799
|
-
}
|
|
1800
|
-
const repeatedSequences = Array.from(sequenceFingerprints.entries()).filter(([_, count]) => count >= Math.min(3, Math.ceil(context.transactionCount * 0.2))).sort((a, b) => b[1] - a[1]);
|
|
1801
|
-
if (repeatedSequences.length > 0) {
|
|
1802
|
-
const evidence = repeatedSequences.slice(0, 5).map(([sequence, count]) => {
|
|
1803
|
-
const exampleSigs = sequenceExamples.get(sequence).slice(0, 2);
|
|
1804
|
-
const programs = sequence.split("->").map((p) => p.slice(0, 8) + "...").join(" \u2192 ");
|
|
1805
|
-
return {
|
|
1806
|
-
description: `Instruction sequence repeated ${count} times: ${programs}`,
|
|
1807
|
-
severity: count > context.transactionCount * 0.5 ? "MEDIUM" : "LOW",
|
|
1808
|
-
reference: `https://solscan.io/tx/${exampleSigs[0]}`
|
|
1809
|
-
};
|
|
1810
|
-
});
|
|
1811
|
-
const topSequenceCount = repeatedSequences[0][1];
|
|
1812
|
-
const severity = topSequenceCount > context.transactionCount * 0.5 ? "MEDIUM" : "LOW";
|
|
1813
|
-
signals.push({
|
|
1814
|
-
id: "instruction-sequence-pattern",
|
|
1815
|
-
name: "Repeated Instruction Sequence Pattern",
|
|
1816
|
-
severity,
|
|
1817
|
-
category: "behavioral",
|
|
1818
|
-
reason: `${repeatedSequences.length} distinct instruction sequence(s) are repeated multiple times. The most common pattern appears in ${topSequenceCount}/${context.transactionCount} transactions.`,
|
|
1819
|
-
impact: "Repeated instruction patterns create a behavioral fingerprint. Even with different addresses, these patterns can link related activity.",
|
|
1820
|
-
mitigation: "Vary the order or combination of operations. Add dummy instructions or randomize transaction structure where possible.",
|
|
1821
|
-
evidence
|
|
1822
|
-
});
|
|
1823
|
-
}
|
|
1824
|
-
const programUsage = /* @__PURE__ */ new Map();
|
|
1825
|
-
for (const inst of context.instructions) {
|
|
1826
|
-
programUsage.set(inst.programId, (programUsage.get(inst.programId) || 0) + 1);
|
|
1827
|
-
}
|
|
1828
|
-
const COMMON_PROGRAMS = [
|
|
1829
|
-
"11111111111111111111111111111111",
|
|
1830
|
-
// System
|
|
1831
|
-
"TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA",
|
|
1832
|
-
// SPL Token
|
|
1833
|
-
"ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL",
|
|
1834
|
-
// Associated Token
|
|
1835
|
-
"MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr",
|
|
1836
|
-
// Memo
|
|
1837
|
-
"ComputeBudget111111111111111111111111111111"
|
|
1838
|
-
// Compute Budget
|
|
1839
|
-
];
|
|
1840
|
-
const uniquePrograms = Array.from(programUsage.entries()).filter(([programId]) => !COMMON_PROGRAMS.includes(programId)).filter(([_, count]) => count >= Math.min(2, Math.ceil(context.transactionCount * 0.15))).sort((a, b) => b[1] - a[1]);
|
|
1841
|
-
if (uniquePrograms.length >= 2) {
|
|
1842
|
-
const evidence = uniquePrograms.slice(0, 5).map(([programId, count]) => {
|
|
1843
|
-
const label = context.labels.get(programId);
|
|
1844
|
-
return {
|
|
1845
|
-
description: `${programId.slice(0, 8)}...${label ? ` (${label.name})` : ""} used in ${count} transactions`,
|
|
1846
|
-
severity: "LOW",
|
|
1847
|
-
reference: `https://solscan.io/account/${programId}`
|
|
1848
|
-
};
|
|
1849
|
-
});
|
|
1850
|
-
signals.push({
|
|
1851
|
-
id: "program-usage-profile",
|
|
1852
|
-
name: "Distinctive Program Usage Profile",
|
|
1853
|
-
severity: "LOW",
|
|
1854
|
-
category: "behavioral",
|
|
1855
|
-
reason: `This address uses ${uniquePrograms.length} less-common programs repeatedly. This creates a unique usage profile.`,
|
|
1856
|
-
impact: "Program usage patterns can fingerprint wallet behavior. Addresses with similar program usage profiles are likely related.",
|
|
1857
|
-
mitigation: "Using niche protocols creates a fingerprint. This is difficult to mitigate without changing your DeFi strategy.",
|
|
1858
|
-
evidence
|
|
1859
|
-
});
|
|
1860
|
-
}
|
|
1861
|
-
if (context.pdaInteractions.length > 0) {
|
|
1862
|
-
const pdaUsage = /* @__PURE__ */ new Map();
|
|
1863
|
-
for (const pda of context.pdaInteractions) {
|
|
1864
|
-
if (!pdaUsage.has(pda.pda)) {
|
|
1865
|
-
pdaUsage.set(pda.pda, { count: 0, programId: pda.programId });
|
|
1866
|
-
}
|
|
1867
|
-
pdaUsage.get(pda.pda).count++;
|
|
1868
|
-
}
|
|
1869
|
-
const repeatedPDAs = Array.from(pdaUsage.entries()).filter(([_, { count }]) => count > 1).sort((a, b) => b[1].count - a[1].count);
|
|
1870
|
-
if (repeatedPDAs.length > 0) {
|
|
1871
|
-
const evidence = repeatedPDAs.slice(0, 5).map(([pda, { count, programId }]) => ({
|
|
1872
|
-
description: `PDA ${pda.slice(0, 8)}... used ${count} times (program: ${programId.slice(0, 8)}...)`,
|
|
1873
|
-
severity: count > 3 ? "MEDIUM" : "LOW",
|
|
1874
|
-
reference: `https://solscan.io/account/${pda}`
|
|
1875
|
-
}));
|
|
1876
|
-
const maxPDAUsage = repeatedPDAs[0][1].count;
|
|
1877
|
-
const severity = maxPDAUsage > 3 ? "MEDIUM" : "LOW";
|
|
1878
|
-
signals.push({
|
|
1879
|
-
id: "pda-reuse-pattern",
|
|
1880
|
-
name: "Repeated PDA Interaction",
|
|
1881
|
-
severity,
|
|
1882
|
-
category: "behavioral",
|
|
1883
|
-
reason: `${repeatedPDAs.length} Program-Derived Address(es) are used repeatedly. The most common PDA appears in ${maxPDAUsage} transactions.`,
|
|
1884
|
-
impact: "Repeated PDA usage links transactions. If the PDA is specific to you (e.g., a user account), all interactions with it are linked.",
|
|
1885
|
-
mitigation: "Some PDA reuse is unavoidable (e.g., your DEX pool position). For sensitive operations, consider using fresh accounts or different protocols.",
|
|
1886
|
-
evidence
|
|
1887
|
-
});
|
|
1888
|
-
}
|
|
1889
|
-
}
|
|
1890
|
-
const programInstructions = /* @__PURE__ */ new Map();
|
|
1891
|
-
for (const inst of context.instructions) {
|
|
1892
|
-
if (!programInstructions.has(inst.programId)) {
|
|
1893
|
-
programInstructions.set(inst.programId, []);
|
|
1894
|
-
}
|
|
1895
|
-
if (inst.data) {
|
|
1896
|
-
programInstructions.get(inst.programId).push(inst.data);
|
|
1897
|
-
}
|
|
1898
|
-
}
|
|
1899
|
-
for (const [programId, dataList] of programInstructions) {
|
|
1900
|
-
if (dataList.length < 2) continue;
|
|
1901
|
-
const typeMap = /* @__PURE__ */ new Map();
|
|
1902
|
-
for (const data of dataList) {
|
|
1903
|
-
if (data && typeof data === "object" && "type" in data) {
|
|
1904
|
-
const type = String(data.type);
|
|
1905
|
-
typeMap.set(type, (typeMap.get(type) || 0) + 1);
|
|
1906
|
-
}
|
|
1907
|
-
}
|
|
1908
|
-
const repeatedTypes = Array.from(typeMap.entries()).filter(([_, count]) => count >= 2).sort((a, b) => b[1] - a[1]);
|
|
1909
|
-
if (repeatedTypes.length > 0 && repeatedTypes[0][1] >= 3) {
|
|
1910
|
-
const [instructionType, count] = repeatedTypes[0];
|
|
1911
|
-
const label = context.labels.get(programId);
|
|
1912
|
-
signals.push({
|
|
1913
|
-
id: `instruction-type-${programId.slice(0, 8)}`,
|
|
1914
|
-
name: "Repeated Instruction Type",
|
|
1915
|
-
severity: "LOW",
|
|
1916
|
-
category: "behavioral",
|
|
1917
|
-
reason: `The instruction type "${instructionType}" on program ${programId.slice(0, 8)}...${label ? ` (${label.name})` : ""} is used ${count} times.`,
|
|
1918
|
-
impact: "Repeated instruction types on the same program suggest automated behavior or specific strategy execution.",
|
|
1919
|
-
mitigation: "This is generally low-risk but contributes to behavioral fingerprinting. Diversify your transaction types if possible.",
|
|
1920
|
-
evidence: [{
|
|
1921
|
-
description: `"${instructionType}" instruction used ${count} times`,
|
|
1922
|
-
severity: "LOW",
|
|
1923
|
-
reference: void 0
|
|
1924
|
-
}]
|
|
1925
|
-
});
|
|
1926
|
-
}
|
|
1927
|
-
}
|
|
1928
|
-
return signals;
|
|
1929
|
-
}
|
|
1930
|
-
function detectTokenAccountLifecycle(context) {
|
|
1931
|
-
const signals = [];
|
|
1932
|
-
if (!context.tokenAccountEvents || context.tokenAccountEvents.length === 0) {
|
|
1933
|
-
return signals;
|
|
1934
|
-
}
|
|
1935
|
-
const accountEvents = /* @__PURE__ */ new Map();
|
|
1936
|
-
for (const event of context.tokenAccountEvents) {
|
|
1937
|
-
if (!accountEvents.has(event.tokenAccount)) {
|
|
1938
|
-
accountEvents.set(event.tokenAccount, []);
|
|
1939
|
-
}
|
|
1940
|
-
accountEvents.get(event.tokenAccount).push(event);
|
|
1941
|
-
}
|
|
1942
|
-
const createEvents = context.tokenAccountEvents.filter((e) => e.type === "create");
|
|
1943
|
-
const closeEvents = context.tokenAccountEvents.filter((e) => e.type === "close");
|
|
1944
|
-
if (createEvents.length >= 2 && closeEvents.length >= 2) {
|
|
1945
|
-
const refundDestinations = /* @__PURE__ */ new Map();
|
|
1946
|
-
const totalRefunded = closeEvents.reduce((sum, event) => {
|
|
1947
|
-
if (event.rentRefund) {
|
|
1948
|
-
refundDestinations.set(event.owner, (refundDestinations.get(event.owner) || 0) + event.rentRefund);
|
|
1949
|
-
return sum + event.rentRefund;
|
|
1950
|
-
}
|
|
1951
|
-
return sum;
|
|
1952
|
-
}, 0);
|
|
1953
|
-
if (refundDestinations.size > 0) {
|
|
1954
|
-
const evidence = Array.from(refundDestinations.entries()).map(([owner, amount]) => ({
|
|
1955
|
-
description: `${amount.toFixed(4)} SOL refunded to ${owner.slice(0, 8)}... from ${closeEvents.filter((e) => e.owner === owner).length} closed account(s)`,
|
|
1956
|
-
severity: "MEDIUM",
|
|
1957
|
-
reference: void 0
|
|
1958
|
-
}));
|
|
1959
|
-
signals.push({
|
|
1960
|
-
id: "token-account-churn",
|
|
1961
|
-
name: "Frequent Token Account Creation/Closure",
|
|
1962
|
-
severity: "MEDIUM",
|
|
1963
|
-
category: "behavioral",
|
|
1964
|
-
reason: `${createEvents.length} token account(s) created and ${closeEvents.length} closed. Rent refunds totaling ${totalRefunded.toFixed(4)} SOL expose ownership.`,
|
|
1965
|
-
impact: 'Rent refunds link temporary token accounts back to the owner wallet. This pattern defeats the purpose of using "burner" accounts.',
|
|
1966
|
-
mitigation: "If using temporary token accounts for privacy, leave them open (accept the small rent cost) rather than closing and refunding to your main wallet.",
|
|
1967
|
-
evidence
|
|
1968
|
-
});
|
|
1969
|
-
}
|
|
1970
|
-
}
|
|
1971
|
-
const completeLifecycles = [];
|
|
1972
|
-
for (const [tokenAccount, events] of accountEvents) {
|
|
1973
|
-
const creates = events.filter((e) => e.type === "create");
|
|
1974
|
-
const closes = events.filter((e) => e.type === "close");
|
|
1975
|
-
if (creates.length > 0 && closes.length > 0) {
|
|
1976
|
-
const createTime = creates[0].blockTime;
|
|
1977
|
-
const closeTime = closes[closes.length - 1].blockTime;
|
|
1978
|
-
if (createTime && closeTime) {
|
|
1979
|
-
const duration = closeTime - createTime;
|
|
1980
|
-
completeLifecycles.push({ tokenAccount, events, duration });
|
|
1981
|
-
}
|
|
1982
|
-
}
|
|
1983
|
-
}
|
|
1984
|
-
const shortLived = completeLifecycles.filter((lc) => lc.duration < 3600);
|
|
1985
|
-
if (shortLived.length >= 2) {
|
|
1986
|
-
const evidence = shortLived.slice(0, 5).map((lc) => {
|
|
1987
|
-
const durationMin = Math.floor(lc.duration / 60);
|
|
1988
|
-
const closeEvent = lc.events.find((e) => e.type === "close");
|
|
1989
|
-
return {
|
|
1990
|
-
description: `${lc.tokenAccount.slice(0, 8)}... lived for ${durationMin} minute(s)${closeEvent?.rentRefund ? `, refunded ${closeEvent.rentRefund.toFixed(4)} SOL` : ""}`,
|
|
1991
|
-
severity: "LOW",
|
|
1992
|
-
reference: void 0
|
|
1993
|
-
};
|
|
1994
|
-
});
|
|
1995
|
-
signals.push({
|
|
1996
|
-
id: "token-account-short-lived",
|
|
1997
|
-
name: "Short-Lived Token Accounts",
|
|
1998
|
-
severity: "LOW",
|
|
1999
|
-
category: "behavioral",
|
|
2000
|
-
reason: `${shortLived.length} token account(s) were created and closed within an hour, suggesting burner account usage.`,
|
|
2001
|
-
impact: "Short-lived accounts suggest privacy-conscious behavior, but rent refunds still create linkage.",
|
|
2002
|
-
mitigation: "For true privacy, do not close accounts immediately. The rent refund links the burner back to you.",
|
|
2003
|
-
evidence
|
|
2004
|
-
});
|
|
2005
|
-
}
|
|
2006
|
-
const ownerAccounts = /* @__PURE__ */ new Map();
|
|
2007
|
-
for (const event of context.tokenAccountEvents) {
|
|
2008
|
-
if (event.type === "create") {
|
|
2009
|
-
if (!ownerAccounts.has(event.owner)) {
|
|
2010
|
-
ownerAccounts.set(event.owner, /* @__PURE__ */ new Set());
|
|
2011
|
-
}
|
|
2012
|
-
ownerAccounts.get(event.owner).add(event.tokenAccount);
|
|
2013
|
-
}
|
|
2014
|
-
}
|
|
2015
|
-
const multiAccountOwners = Array.from(ownerAccounts.entries()).filter(([_, accounts]) => accounts.size >= 2).sort((a, b) => b[1].size - a[1].size);
|
|
2016
|
-
if (multiAccountOwners.length > 0) {
|
|
2017
|
-
const [owner, accounts] = multiAccountOwners[0];
|
|
2018
|
-
const isTarget = owner === context.target;
|
|
2019
|
-
if (!isTarget || multiAccountOwners.length > 1) {
|
|
2020
|
-
const evidence = multiAccountOwners.slice(0, 3).map(([own, accs]) => {
|
|
2021
|
-
const label = context.labels.get(own);
|
|
2022
|
-
return {
|
|
2023
|
-
description: `${own.slice(0, 8)}...${label ? ` (${label.name})` : ""} owns ${accs.size} token account(s)`,
|
|
2024
|
-
severity: "LOW",
|
|
2025
|
-
reference: void 0
|
|
2026
|
-
};
|
|
2027
|
-
});
|
|
2028
|
-
signals.push({
|
|
2029
|
-
id: "token-account-common-owner",
|
|
2030
|
-
name: "Common Owner Across Token Accounts",
|
|
2031
|
-
severity: "LOW",
|
|
2032
|
-
category: "linkability",
|
|
2033
|
-
reason: `${multiAccountOwners.length} wallet(s) control multiple token accounts. The top owner controls ${accounts.size} accounts.`,
|
|
2034
|
-
impact: "All token accounts with the same owner are trivially linked.",
|
|
2035
|
-
mitigation: "This is inherent to Solana's token account model and cannot be avoided.",
|
|
2036
|
-
evidence
|
|
2037
|
-
});
|
|
2038
|
-
}
|
|
2039
|
-
}
|
|
2040
|
-
const rentRefundReceivers = /* @__PURE__ */ new Map();
|
|
2041
|
-
for (const event of context.tokenAccountEvents) {
|
|
2042
|
-
if (event.type === "close" && event.rentRefund) {
|
|
2043
|
-
const current = rentRefundReceivers.get(event.owner) || { count: 0, total: 0 };
|
|
2044
|
-
current.count++;
|
|
2045
|
-
current.total += event.rentRefund;
|
|
2046
|
-
rentRefundReceivers.set(event.owner, current);
|
|
2047
|
-
}
|
|
2048
|
-
}
|
|
2049
|
-
const significantRefunds = Array.from(rentRefundReceivers.entries()).filter(([_, { count }]) => count >= 3).sort((a, b) => b[1].count - a[1].count);
|
|
2050
|
-
if (significantRefunds.length > 0) {
|
|
2051
|
-
const evidence = significantRefunds.slice(0, 3).map(([owner, { count, total }]) => ({
|
|
2052
|
-
description: `${owner.slice(0, 8)}... received ${count} rent refunds totaling ${total.toFixed(4)} SOL`,
|
|
2053
|
-
severity: "MEDIUM",
|
|
2054
|
-
reference: void 0
|
|
2055
|
-
}));
|
|
2056
|
-
const [topOwner, topData] = significantRefunds[0];
|
|
2057
|
-
signals.push({
|
|
2058
|
-
id: "rent-refund-clustering",
|
|
2059
|
-
name: "Rent Refund Clustering",
|
|
2060
|
-
severity: "MEDIUM",
|
|
2061
|
-
category: "linkability",
|
|
2062
|
-
reason: `${significantRefunds.length} address(es) receive multiple rent refunds. ${topOwner.slice(0, 8)}... received ${topData.count} refunds.`,
|
|
2063
|
-
impact: "Rent refunds link closed token accounts back to a central wallet. This exposes the control structure.",
|
|
2064
|
-
mitigation: "Do not close token accounts if privacy is important. The small rent cost (~0.002 SOL) is cheaper than the privacy loss.",
|
|
2065
|
-
evidence
|
|
2066
|
-
});
|
|
2067
|
-
}
|
|
2068
|
-
return signals;
|
|
2069
|
-
}
|
|
2070
|
-
function detectMemoExposure(context) {
|
|
2071
|
-
const signals = [];
|
|
2072
|
-
if (context.transactionCount === 0) {
|
|
2073
|
-
return signals;
|
|
2074
|
-
}
|
|
2075
|
-
if (!context.transactions || context.transactions.length === 0) {
|
|
2076
|
-
return signals;
|
|
2077
|
-
}
|
|
2078
|
-
const MEMO_PROGRAM = "MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr";
|
|
2079
|
-
const MEMO_PROGRAM_V1 = "Memo1UhkJRfHyvLMcVucJwxXeuD728EqVDDwQDxFMNo";
|
|
2080
|
-
const memoInstructions = context.instructions.filter(
|
|
2081
|
-
(inst) => inst.programId === MEMO_PROGRAM || inst.programId === MEMO_PROGRAM_V1
|
|
2082
|
-
);
|
|
2083
|
-
if (memoInstructions.length === 0) {
|
|
2084
|
-
return signals;
|
|
2085
|
-
}
|
|
2086
|
-
const suspiciousMemos = [];
|
|
2087
|
-
for (const inst of memoInstructions) {
|
|
2088
|
-
if (!inst.data || typeof inst.data !== "string") continue;
|
|
2089
|
-
const memoText = inst.data.trim();
|
|
2090
|
-
if (memoText.length === 0) continue;
|
|
2091
|
-
let severity = "LOW";
|
|
2092
|
-
const patterns = [];
|
|
2093
|
-
if (/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/.test(memoText)) {
|
|
2094
|
-
patterns.push("email address");
|
|
2095
|
-
severity = "HIGH";
|
|
2096
|
-
}
|
|
2097
|
-
if (/https?:\/\/[^\s]+/.test(memoText)) {
|
|
2098
|
-
patterns.push("URL");
|
|
2099
|
-
severity = severity === "HIGH" ? "HIGH" : "MEDIUM";
|
|
2100
|
-
}
|
|
2101
|
-
if (/(\+\d{1,3}[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}/.test(memoText)) {
|
|
2102
|
-
patterns.push("phone number");
|
|
2103
|
-
severity = "HIGH";
|
|
2104
|
-
}
|
|
2105
|
-
const capitalizedWords = memoText.match(/\b[A-Z][a-z]+(\s+[A-Z][a-z]+)+\b/g);
|
|
2106
|
-
if (capitalizedWords && capitalizedWords.length > 0) {
|
|
2107
|
-
patterns.push("likely name(s)");
|
|
2108
|
-
severity = severity === "HIGH" ? "HIGH" : "MEDIUM";
|
|
2109
|
-
}
|
|
2110
|
-
if (memoText.length > 50 && !patterns.length) {
|
|
2111
|
-
patterns.push("long descriptive text");
|
|
2112
|
-
severity = "MEDIUM";
|
|
2113
|
-
}
|
|
2114
|
-
if (/invoice|payment|order|transaction|ref|reference|id|bill/i.test(memoText)) {
|
|
2115
|
-
patterns.push("payment reference");
|
|
2116
|
-
severity = severity === "HIGH" ? "HIGH" : "MEDIUM";
|
|
2117
|
-
}
|
|
2118
|
-
if (patterns.length > 0) {
|
|
2119
|
-
suspiciousMemos.push({
|
|
2120
|
-
content: memoText.length > 100 ? memoText.slice(0, 100) + "..." : memoText,
|
|
2121
|
-
signature: inst.signature,
|
|
2122
|
-
severity
|
|
2123
|
-
});
|
|
2124
|
-
}
|
|
2125
|
-
}
|
|
2126
|
-
if (suspiciousMemos.length === 0) {
|
|
2127
|
-
signals.push({
|
|
2128
|
-
id: "memo-usage",
|
|
2129
|
-
name: "Memo Program Usage",
|
|
2130
|
-
severity: "LOW",
|
|
2131
|
-
confidence: 0.6,
|
|
2132
|
-
category: "information-leak",
|
|
2133
|
-
reason: `${memoInstructions.length} transaction(s) use memo program. Memos are permanently visible on-chain.`,
|
|
2134
|
-
impact: "Memo data is public and permanent. Even non-sensitive memos can contribute to behavioral fingerprinting.",
|
|
2135
|
-
mitigation: "Avoid using memos unless necessary. Never include personal information.",
|
|
2136
|
-
evidence: [{
|
|
2137
|
-
description: `${memoInstructions.length} transaction(s) with memos`,
|
|
2138
|
-
severity: "LOW",
|
|
2139
|
-
reference: void 0
|
|
2140
|
-
}]
|
|
2141
|
-
});
|
|
2142
|
-
} else {
|
|
2143
|
-
const highSeverity = suspiciousMemos.filter((m) => m.severity === "HIGH");
|
|
2144
|
-
const mediumSeverity = suspiciousMemos.filter((m) => m.severity === "MEDIUM");
|
|
2145
|
-
if (highSeverity.length > 0) {
|
|
2146
|
-
const evidence = highSeverity.map((memo) => ({
|
|
2147
|
-
description: `"${memo.content}"`,
|
|
2148
|
-
severity: "HIGH",
|
|
2149
|
-
reference: `https://solscan.io/tx/${memo.signature}`
|
|
2150
|
-
}));
|
|
2151
|
-
signals.push({
|
|
2152
|
-
id: "memo-pii-exposure",
|
|
2153
|
-
name: "Personal Information in Memo",
|
|
2154
|
-
severity: "HIGH",
|
|
2155
|
-
confidence: 0.9,
|
|
2156
|
-
category: "information-leak",
|
|
2157
|
-
reason: `${highSeverity.length} memo(s) contain personal identifying information (email, phone, name).`,
|
|
2158
|
-
impact: "CRITICAL: Personal information in memos is permanently public. This can directly link your wallet to your real-world identity.",
|
|
2159
|
-
mitigation: "Never put personal information in memos. Contact addresses involved to stop this practice if possible.",
|
|
2160
|
-
evidence
|
|
2161
|
-
});
|
|
2162
|
-
}
|
|
2163
|
-
if (mediumSeverity.length > 0) {
|
|
2164
|
-
const evidence = mediumSeverity.slice(0, 5).map((memo) => ({
|
|
2165
|
-
description: `"${memo.content}"`,
|
|
2166
|
-
severity: "MEDIUM",
|
|
2167
|
-
reference: `https://solscan.io/tx/${memo.signature}`
|
|
2168
|
-
}));
|
|
2169
|
-
signals.push({
|
|
2170
|
-
id: "memo-descriptive-content",
|
|
2171
|
-
name: "Descriptive Content in Memo",
|
|
2172
|
-
severity: "MEDIUM",
|
|
2173
|
-
confidence: 0.7,
|
|
2174
|
-
category: "information-leak",
|
|
2175
|
-
reason: `${mediumSeverity.length} memo(s) contain descriptive or identifying content.`,
|
|
2176
|
-
impact: "Descriptive memos create behavioral fingerprints and can indirectly reveal identity or transaction purpose.",
|
|
2177
|
-
mitigation: "Minimize memo usage. Use generic or coded references instead of descriptive text.",
|
|
2178
|
-
evidence
|
|
2179
|
-
});
|
|
2180
|
-
}
|
|
2181
|
-
}
|
|
2182
|
-
return signals;
|
|
2183
|
-
}
|
|
2184
|
-
function detectAddressReuse(context) {
|
|
2185
|
-
const signals = [];
|
|
2186
|
-
if (context.targetType !== "wallet" || context.transactionCount < 5) {
|
|
2187
|
-
return signals;
|
|
2188
|
-
}
|
|
2189
|
-
const activityTypes = /* @__PURE__ */ new Set();
|
|
2190
|
-
const activityDetails = /* @__PURE__ */ new Map();
|
|
2191
|
-
const DEFI_PROGRAMS = /* @__PURE__ */ new Set([
|
|
2192
|
-
"JUP",
|
|
2193
|
-
"Raydium",
|
|
2194
|
-
"Orca",
|
|
2195
|
-
"Marinade",
|
|
2196
|
-
"Lido",
|
|
2197
|
-
"Lifinity",
|
|
2198
|
-
"Serum"
|
|
2199
|
-
// Add more as needed
|
|
2200
|
-
]);
|
|
2201
|
-
const NFT_PROGRAMS = /* @__PURE__ */ new Set([
|
|
2202
|
-
"Magic Eden",
|
|
2203
|
-
"Tensor",
|
|
2204
|
-
"OpenSea",
|
|
2205
|
-
"Metaplex"
|
|
2206
|
-
]);
|
|
2207
|
-
const GAMING_PROGRAMS = /* @__PURE__ */ new Set([
|
|
2208
|
-
"Star Atlas",
|
|
2209
|
-
"Genopets",
|
|
2210
|
-
"Aurory"
|
|
2211
|
-
]);
|
|
2212
|
-
const DAO_PROGRAMS = /* @__PURE__ */ new Set([
|
|
2213
|
-
"Realms",
|
|
2214
|
-
"Squads",
|
|
2215
|
-
"Tribeca"
|
|
2216
|
-
]);
|
|
2217
|
-
for (const inst of context.instructions) {
|
|
2218
|
-
const label = context.labels.get(inst.programId);
|
|
2219
|
-
const programName = label?.name || "";
|
|
2220
|
-
if (DEFI_PROGRAMS.has(programName) || /swap|pool|stake|lend|borrow/i.test(programName)) {
|
|
2221
|
-
activityTypes.add("DeFi");
|
|
2222
|
-
if (!activityDetails.has("DeFi")) {
|
|
2223
|
-
activityDetails.set("DeFi", { count: 0, programs: /* @__PURE__ */ new Set() });
|
|
2224
|
-
}
|
|
2225
|
-
const details = activityDetails.get("DeFi");
|
|
2226
|
-
details.count++;
|
|
2227
|
-
details.programs.add(programName || inst.programId.slice(0, 8));
|
|
2228
|
-
}
|
|
2229
|
-
if (NFT_PROGRAMS.has(programName) || /nft|marketplace|mint/i.test(programName)) {
|
|
2230
|
-
activityTypes.add("NFT");
|
|
2231
|
-
if (!activityDetails.has("NFT")) {
|
|
2232
|
-
activityDetails.set("NFT", { count: 0, programs: /* @__PURE__ */ new Set() });
|
|
2233
|
-
}
|
|
2234
|
-
const details = activityDetails.get("NFT");
|
|
2235
|
-
details.count++;
|
|
2236
|
-
details.programs.add(programName || inst.programId.slice(0, 8));
|
|
2237
|
-
}
|
|
2238
|
-
if (GAMING_PROGRAMS.has(programName) || /game|play/i.test(programName)) {
|
|
2239
|
-
activityTypes.add("Gaming");
|
|
2240
|
-
if (!activityDetails.has("Gaming")) {
|
|
2241
|
-
activityDetails.set("Gaming", { count: 0, programs: /* @__PURE__ */ new Set() });
|
|
2242
|
-
}
|
|
2243
|
-
const details = activityDetails.get("Gaming");
|
|
2244
|
-
details.count++;
|
|
2245
|
-
details.programs.add(programName || inst.programId.slice(0, 8));
|
|
2246
|
-
}
|
|
2247
|
-
if (DAO_PROGRAMS.has(programName) || /dao|governance|vote/i.test(programName)) {
|
|
2248
|
-
activityTypes.add("DAO");
|
|
2249
|
-
if (!activityDetails.has("DAO")) {
|
|
2250
|
-
activityDetails.set("DAO", { count: 0, programs: /* @__PURE__ */ new Set() });
|
|
2251
|
-
}
|
|
2252
|
-
const details = activityDetails.get("DAO");
|
|
2253
|
-
details.count++;
|
|
2254
|
-
details.programs.add(programName || inst.programId.slice(0, 8));
|
|
2255
|
-
}
|
|
2256
|
-
}
|
|
2257
|
-
const hasExchangeInteraction = Array.from(context.labels.values()).some((label) => label.type === "exchange");
|
|
2258
|
-
if (hasExchangeInteraction) {
|
|
2259
|
-
activityTypes.add("Exchange");
|
|
2260
|
-
activityDetails.set("Exchange", {
|
|
2261
|
-
count: context.transfers.filter(
|
|
2262
|
-
(t) => context.labels.has(t.from) || context.labels.has(t.to)
|
|
2263
|
-
).length,
|
|
2264
|
-
programs: /* @__PURE__ */ new Set(["CEX"])
|
|
2265
|
-
});
|
|
2266
|
-
}
|
|
2267
|
-
const simpleTransfers = context.transfers.filter((t) => {
|
|
2268
|
-
const tx = context.transactions?.find((tx2) => tx2.signature === t.signature);
|
|
2269
|
-
return tx && tx.programs && tx.programs.length <= 2;
|
|
2270
|
-
});
|
|
2271
|
-
if (simpleTransfers.length >= 3) {
|
|
2272
|
-
activityTypes.add("P2P Transfers");
|
|
2273
|
-
activityDetails.set("P2P Transfers", {
|
|
2274
|
-
count: simpleTransfers.length,
|
|
2275
|
-
programs: /* @__PURE__ */ new Set(["Direct"])
|
|
2276
|
-
});
|
|
2277
|
-
}
|
|
2278
|
-
const diversityCount = activityTypes.size;
|
|
2279
|
-
if (diversityCount >= 4) {
|
|
2280
|
-
const evidence = Array.from(activityDetails.entries()).map(([type, details]) => ({
|
|
2281
|
-
description: `${type}: ${details.count} transaction(s) across ${details.programs.size} program(s)`,
|
|
2282
|
-
severity: "HIGH",
|
|
2283
|
-
reference: void 0
|
|
2284
|
-
}));
|
|
2285
|
-
signals.push({
|
|
2286
|
-
id: "address-high-diversity",
|
|
2287
|
-
name: "High Activity Diversity on Single Address",
|
|
2288
|
-
severity: "HIGH",
|
|
2289
|
-
confidence: 0.85,
|
|
2290
|
-
category: "linkability",
|
|
2291
|
-
reason: `This address is used for ${diversityCount} distinct activity types: ${Array.from(activityTypes).join(", ")}.`,
|
|
2292
|
-
impact: "Using one address for multiple unrelated activities links them all together. This creates a comprehensive behavioral profile.",
|
|
2293
|
-
mitigation: "Use separate addresses for different purposes: one for DeFi, one for NFTs, one for DAO participation, etc. This isolates activities and prevents cross-linkage.",
|
|
2294
|
-
evidence
|
|
2295
|
-
});
|
|
2296
|
-
} else if (diversityCount === 3) {
|
|
2297
|
-
const evidence = Array.from(activityDetails.entries()).map(([type, details]) => ({
|
|
2298
|
-
description: `${type}: ${details.count} transaction(s)`,
|
|
2299
|
-
severity: "MEDIUM",
|
|
2300
|
-
reference: void 0
|
|
2301
|
-
}));
|
|
2302
|
-
signals.push({
|
|
2303
|
-
id: "address-moderate-diversity",
|
|
2304
|
-
name: "Moderate Activity Diversity on Single Address",
|
|
2305
|
-
severity: "MEDIUM",
|
|
2306
|
-
confidence: 0.7,
|
|
2307
|
-
category: "linkability",
|
|
2308
|
-
reason: `This address is used for ${diversityCount} activity types: ${Array.from(activityTypes).join(", ")}.`,
|
|
2309
|
-
impact: "Multiple activity types on one address create linkage between otherwise separate behaviors.",
|
|
2310
|
-
mitigation: "Consider using separate addresses for different activities to improve privacy compartmentalization.",
|
|
2311
|
-
evidence
|
|
2312
|
-
});
|
|
2313
|
-
}
|
|
2314
|
-
if (context.timeRange.earliest && context.timeRange.latest) {
|
|
2315
|
-
const timeSpanDays = (context.timeRange.latest - context.timeRange.earliest) / (60 * 60 * 24);
|
|
2316
|
-
if (timeSpanDays > 180 && context.transactionCount > 50) {
|
|
2317
|
-
signals.push({
|
|
2318
|
-
id: "address-long-term-usage",
|
|
2319
|
-
name: "Long-Term Single Address Usage",
|
|
2320
|
-
severity: "MEDIUM",
|
|
2321
|
-
confidence: 0.75,
|
|
2322
|
-
category: "behavioral",
|
|
2323
|
-
reason: `This address has been actively used for ${Math.round(timeSpanDays)} days with ${context.transactionCount} transactions.`,
|
|
2324
|
-
impact: "Long-term address usage accumulates a rich behavioral history. All activities over time are permanently linked.",
|
|
2325
|
-
mitigation: "Periodically rotate to new addresses to compartmentalize different time periods of activity.",
|
|
2326
|
-
evidence: [{
|
|
2327
|
-
description: `${context.transactionCount} transactions over ${Math.round(timeSpanDays)} days`,
|
|
2328
|
-
severity: "MEDIUM",
|
|
2329
|
-
reference: void 0
|
|
2330
|
-
}]
|
|
2331
|
-
});
|
|
2332
|
-
}
|
|
2333
|
-
}
|
|
2334
|
-
return signals;
|
|
2335
|
-
}
|
|
2336
|
-
var REPORT_VERSION = "1.0.0";
|
|
2337
|
-
var HEURISTICS = [
|
|
2338
|
-
// Solana-specific (highest priority)
|
|
2339
|
-
detectFeePayerReuse,
|
|
2340
|
-
detectSignerOverlap,
|
|
2341
|
-
detectMemoExposure,
|
|
2342
|
-
detectAddressReuse,
|
|
2343
|
-
detectKnownEntityInteraction,
|
|
2344
|
-
detectCounterpartyReuse,
|
|
2345
|
-
detectInstructionFingerprinting,
|
|
2346
|
-
detectTokenAccountLifecycle,
|
|
2347
|
-
// Traditional heuristics
|
|
2348
|
-
detectTimingPatterns,
|
|
2349
|
-
detectAmountReuse,
|
|
2350
|
-
detectBalanceTraceability
|
|
2351
|
-
];
|
|
2352
|
-
function calculateOverallRisk(signals) {
|
|
2353
|
-
if (signals.length === 0) {
|
|
2354
|
-
return "LOW";
|
|
2355
|
-
}
|
|
2356
|
-
const highCount = signals.filter((s) => s.severity === "HIGH").length;
|
|
2357
|
-
const mediumCount = signals.filter((s) => s.severity === "MEDIUM").length;
|
|
2358
|
-
const lowCount = signals.filter((s) => s.severity === "LOW").length;
|
|
2359
|
-
if (highCount >= 2 || highCount >= 1 && mediumCount >= 2) {
|
|
2360
|
-
return "HIGH";
|
|
2361
|
-
}
|
|
2362
|
-
if (highCount >= 1 || mediumCount >= 2 || mediumCount >= 1 && lowCount >= 2) {
|
|
2363
|
-
return "MEDIUM";
|
|
2364
|
-
}
|
|
2365
|
-
return "LOW";
|
|
2366
|
-
}
|
|
2367
|
-
function generateMitigations(signals) {
|
|
2368
|
-
const mitigations = /* @__PURE__ */ new Set();
|
|
2369
|
-
if (signals.length === 0) {
|
|
2370
|
-
return ["Continue practicing good privacy hygiene to maintain low exposure."];
|
|
2371
|
-
}
|
|
2372
|
-
mitigations.add("Consider using multiple wallets to compartmentalize different activities.");
|
|
2373
|
-
const signalIds = new Set(signals.map((s) => s.id));
|
|
2374
|
-
if (signalIds.has("fee-payer-never-self") || signalIds.has("fee-payer-external")) {
|
|
2375
|
-
mitigations.add("Always pay your own transaction fees to avoid linkage.");
|
|
2376
|
-
}
|
|
2377
|
-
if (signalIds.has("signer-repeated") || signalIds.has("signer-set-reuse")) {
|
|
2378
|
-
mitigations.add("Use separate signing keys for unrelated activities.");
|
|
2379
|
-
}
|
|
2380
|
-
if (signalIds.has("instruction-sequence-pattern") || signalIds.has("program-usage-profile")) {
|
|
2381
|
-
mitigations.add("Diversify transaction patterns and protocols to reduce behavioral fingerprinting.");
|
|
2382
|
-
}
|
|
2383
|
-
if (signalIds.has("token-account-churn") || signalIds.has("rent-refund-clustering")) {
|
|
2384
|
-
mitigations.add("Avoid closing token accounts if privacy is important - the rent refund creates linkage.");
|
|
2385
|
-
}
|
|
2386
|
-
if (signalIds.has("memo-pii-exposure") || signalIds.has("memo-descriptive-content") || signalIds.has("memo-usage")) {
|
|
2387
|
-
mitigations.add("Never include personal information in transaction memos - they are permanently public.");
|
|
2388
|
-
}
|
|
2389
|
-
if (signalIds.has("address-high-diversity") || signalIds.has("address-moderate-diversity") || signalIds.has("address-long-term-usage")) {
|
|
2390
|
-
mitigations.add("Use separate addresses for different activity types to compartmentalize your behavior.");
|
|
2391
|
-
}
|
|
2392
|
-
if (signalIds.has("known-entity-exchange") || signalIds.has("known-entity-bridge") || signalIds.has("known-entity-other") || signalIds.has("known-entity-interaction")) {
|
|
2393
|
-
mitigations.add("Avoid direct interactions between privacy-sensitive wallets and KYC services.");
|
|
2394
|
-
}
|
|
2395
|
-
if (signalIds.has("counterparty-reuse") || signalIds.has("pda-reuse")) {
|
|
2396
|
-
mitigations.add("Use different addresses for different counterparties or contexts.");
|
|
2397
|
-
}
|
|
2398
|
-
if (signalIds.has("timing-burst") || signalIds.has("timing-regular-interval") || signalIds.has("timing-timezone-pattern") || signalIds.has("timing-correlation")) {
|
|
2399
|
-
mitigations.add("Introduce timing delays and vary transaction patterns to reduce correlation.");
|
|
2400
|
-
}
|
|
2401
|
-
if (signalIds.has("balance-matching-pairs") || signalIds.has("balance-sequential-similar") || signalIds.has("balance-full-movement") || signalIds.has("balance-traceability")) {
|
|
2402
|
-
mitigations.add("Vary transfer amounts and add delays to reduce balance traceability.");
|
|
2403
|
-
}
|
|
2404
|
-
if (signalIds.has("amount-reuse")) {
|
|
2405
|
-
mitigations.add("Vary transaction amounts to avoid creating fingerprints.");
|
|
2406
|
-
}
|
|
2407
|
-
mitigations.add("Research and consider privacy-preserving protocols when available.");
|
|
2408
|
-
return Array.from(mitigations);
|
|
2409
|
-
}
|
|
2410
|
-
function evaluateHeuristics(context) {
|
|
2411
|
-
const signals = [];
|
|
2412
|
-
for (const heuristic of HEURISTICS) {
|
|
2413
|
-
try {
|
|
2414
|
-
const result = heuristic(context);
|
|
2415
|
-
if (Array.isArray(result)) {
|
|
2416
|
-
signals.push(...result);
|
|
2417
|
-
} else if (result) {
|
|
2418
|
-
signals.push(result);
|
|
2419
|
-
}
|
|
2420
|
-
} catch (error) {
|
|
2421
|
-
console.warn(`Heuristic evaluation failed:`, error);
|
|
2422
|
-
}
|
|
2423
|
-
}
|
|
2424
|
-
signals.sort((a, b) => {
|
|
2425
|
-
const severityOrder = { HIGH: 0, MEDIUM: 1, LOW: 2 };
|
|
2426
|
-
return severityOrder[a.severity] - severityOrder[b.severity];
|
|
2427
|
-
});
|
|
2428
|
-
return signals;
|
|
2429
|
-
}
|
|
2430
|
-
function generateReport(context) {
|
|
2431
|
-
const signals = evaluateHeuristics(context);
|
|
2432
|
-
const overallRisk = calculateOverallRisk(signals);
|
|
2433
|
-
const highRiskSignals = signals.filter((s) => s.severity === "HIGH").length;
|
|
2434
|
-
const mediumRiskSignals = signals.filter((s) => s.severity === "MEDIUM").length;
|
|
2435
|
-
const lowRiskSignals = signals.filter((s) => s.severity === "LOW").length;
|
|
2436
|
-
const mitigations = generateMitigations(signals);
|
|
2437
|
-
const knownEntities = Array.from(context.labels.values());
|
|
2438
|
-
return {
|
|
2439
|
-
version: REPORT_VERSION,
|
|
2440
|
-
timestamp: Date.now(),
|
|
2441
|
-
targetType: context.targetType,
|
|
2442
|
-
target: context.target,
|
|
2443
|
-
overallRisk,
|
|
2444
|
-
signals,
|
|
2445
|
-
summary: {
|
|
2446
|
-
totalSignals: signals.length,
|
|
2447
|
-
highRiskSignals,
|
|
2448
|
-
mediumRiskSignals,
|
|
2449
|
-
lowRiskSignals,
|
|
2450
|
-
transactionsAnalyzed: context.transactionCount
|
|
2451
|
-
},
|
|
2452
|
-
mitigations,
|
|
2453
|
-
knownEntities
|
|
2454
|
-
};
|
|
2455
|
-
}
|
|
2456
|
-
var __filename = fileURLToPath(import.meta.url);
|
|
2457
|
-
var __dirname = dirname(__filename);
|
|
2458
|
-
var StaticLabelProvider = class {
|
|
2459
|
-
labels;
|
|
2460
|
-
constructor(labelsPath) {
|
|
2461
|
-
this.labels = /* @__PURE__ */ new Map();
|
|
2462
|
-
this.loadLabels(labelsPath);
|
|
2463
|
-
}
|
|
2464
|
-
/**
|
|
2465
|
-
* Load labels from JSON file
|
|
2466
|
-
*/
|
|
2467
|
-
loadLabels(customPath) {
|
|
2468
|
-
try {
|
|
2469
|
-
let path;
|
|
2470
|
-
if (customPath) {
|
|
2471
|
-
path = customPath;
|
|
2472
|
-
} else {
|
|
2473
|
-
const locations = [
|
|
2474
|
-
join(__dirname, "known-addresses.json"),
|
|
2475
|
-
join(__dirname, "../../..", "known-addresses.json")
|
|
2476
|
-
// From src/labels to repo root
|
|
2477
|
-
];
|
|
2478
|
-
path = locations.find((loc) => {
|
|
2479
|
-
try {
|
|
2480
|
-
readFileSync(loc, "utf-8");
|
|
2481
|
-
return true;
|
|
2482
|
-
} catch {
|
|
2483
|
-
return false;
|
|
2484
|
-
}
|
|
2485
|
-
}) || locations[0];
|
|
2486
|
-
}
|
|
2487
|
-
const data = readFileSync(path, "utf-8");
|
|
2488
|
-
const parsed = JSON.parse(data);
|
|
2489
|
-
if (!parsed.labels || !Array.isArray(parsed.labels)) {
|
|
2490
|
-
console.warn("Invalid labels file format");
|
|
2491
|
-
return;
|
|
2492
|
-
}
|
|
2493
|
-
for (const label of parsed.labels) {
|
|
2494
|
-
if (label.address && label.name && label.type) {
|
|
2495
|
-
this.labels.set(label.address, {
|
|
2496
|
-
address: label.address,
|
|
2497
|
-
name: label.name,
|
|
2498
|
-
type: label.type,
|
|
2499
|
-
description: label.description,
|
|
2500
|
-
relatedAddresses: label.relatedAddresses
|
|
2501
|
-
});
|
|
2502
|
-
}
|
|
2503
|
-
}
|
|
2504
|
-
console.debug(`Loaded ${this.labels.size} address labels`);
|
|
2505
|
-
} catch (error) {
|
|
2506
|
-
console.warn("Failed to load labels file:", error);
|
|
2507
|
-
}
|
|
2508
|
-
}
|
|
2509
|
-
/**
|
|
2510
|
-
* Look up a label for an address
|
|
2511
|
-
*/
|
|
2512
|
-
lookup(address) {
|
|
2513
|
-
return this.labels.get(address) || null;
|
|
2514
|
-
}
|
|
2515
|
-
/**
|
|
2516
|
-
* Look up multiple addresses at once
|
|
2517
|
-
*/
|
|
2518
|
-
lookupMany(addresses) {
|
|
2519
|
-
const results = /* @__PURE__ */ new Map();
|
|
2520
|
-
for (const address of addresses) {
|
|
2521
|
-
const label = this.lookup(address);
|
|
2522
|
-
if (label) {
|
|
2523
|
-
results.set(address, label);
|
|
2524
|
-
}
|
|
2525
|
-
}
|
|
2526
|
-
return results;
|
|
2527
|
-
}
|
|
2528
|
-
/**
|
|
2529
|
-
* Get all loaded labels
|
|
2530
|
-
*/
|
|
2531
|
-
getAllLabels() {
|
|
2532
|
-
return Array.from(this.labels.values());
|
|
2533
|
-
}
|
|
2534
|
-
/**
|
|
2535
|
-
* Get count of loaded labels
|
|
2536
|
-
*/
|
|
2537
|
-
getCount() {
|
|
2538
|
-
return this.labels.size;
|
|
2539
|
-
}
|
|
2540
|
-
};
|
|
2541
|
-
function createDefaultLabelProvider() {
|
|
2542
|
-
return new StaticLabelProvider();
|
|
2543
|
-
}
|
|
9
|
+
import {
|
|
10
|
+
RPCClient,
|
|
11
|
+
collectWalletData,
|
|
12
|
+
normalizeWalletData,
|
|
13
|
+
generateReport,
|
|
14
|
+
createDefaultLabelProvider
|
|
15
|
+
} from "solana-privacy-scanner-core";
|
|
2544
16
|
|
|
2545
17
|
// src/formatter.js
|
|
2546
18
|
import chalk from "chalk";
|
|
@@ -2737,11 +209,18 @@ async function scanWallet(address, options) {
|
|
|
2737
209
|
|
|
2738
210
|
// src/commands/transaction.ts
|
|
2739
211
|
import { writeFileSync as writeFileSync2 } from "fs";
|
|
212
|
+
import {
|
|
213
|
+
RPCClient as RPCClient2,
|
|
214
|
+
collectTransactionData,
|
|
215
|
+
normalizeTransactionData,
|
|
216
|
+
generateReport as generateReport2,
|
|
217
|
+
createDefaultLabelProvider as createDefaultLabelProvider2
|
|
218
|
+
} from "solana-privacy-scanner-core";
|
|
2740
219
|
async function scanTransaction(signature, options) {
|
|
2741
220
|
try {
|
|
2742
221
|
console.error(`Scanning transaction: ${signature}`);
|
|
2743
222
|
console.error("");
|
|
2744
|
-
const client = new
|
|
223
|
+
const client = new RPCClient2(
|
|
2745
224
|
options.rpc ? {
|
|
2746
225
|
rpcUrl: options.rpc,
|
|
2747
226
|
maxConcurrency: 5,
|
|
@@ -2759,9 +238,9 @@ async function scanTransaction(signature, options) {
|
|
|
2759
238
|
}
|
|
2760
239
|
console.error("Analyzing transaction...");
|
|
2761
240
|
console.error("");
|
|
2762
|
-
const labelProvider =
|
|
241
|
+
const labelProvider = createDefaultLabelProvider2();
|
|
2763
242
|
const context = normalizeTransactionData(rawData, labelProvider);
|
|
2764
|
-
const report =
|
|
243
|
+
const report = generateReport2(context);
|
|
2765
244
|
if (options.json) {
|
|
2766
245
|
const output = JSON.stringify(report, null, 2);
|
|
2767
246
|
if (options.output) {
|
|
@@ -2788,11 +267,18 @@ async function scanTransaction(signature, options) {
|
|
|
2788
267
|
|
|
2789
268
|
// src/commands/program.ts
|
|
2790
269
|
import { writeFileSync as writeFileSync3 } from "fs";
|
|
270
|
+
import {
|
|
271
|
+
RPCClient as RPCClient3,
|
|
272
|
+
collectProgramData,
|
|
273
|
+
normalizeProgramData,
|
|
274
|
+
generateReport as generateReport3,
|
|
275
|
+
createDefaultLabelProvider as createDefaultLabelProvider3
|
|
276
|
+
} from "solana-privacy-scanner-core";
|
|
2791
277
|
async function scanProgram(programId, options) {
|
|
2792
278
|
try {
|
|
2793
279
|
console.error(`Scanning program: ${programId}`);
|
|
2794
280
|
console.error("");
|
|
2795
|
-
const client = new
|
|
281
|
+
const client = new RPCClient3(
|
|
2796
282
|
options.rpc ? {
|
|
2797
283
|
rpcUrl: options.rpc,
|
|
2798
284
|
maxConcurrency: 5,
|
|
@@ -2812,9 +298,9 @@ async function scanProgram(programId, options) {
|
|
|
2812
298
|
console.error(`Found ${rawData.accounts.length} accounts, ${rawData.relatedTransactions.length} transactions`);
|
|
2813
299
|
console.error("");
|
|
2814
300
|
console.error("Analyzing program activity...");
|
|
2815
|
-
const labelProvider =
|
|
301
|
+
const labelProvider = createDefaultLabelProvider3();
|
|
2816
302
|
const context = normalizeProgramData(rawData, labelProvider);
|
|
2817
|
-
const report =
|
|
303
|
+
const report = generateReport3(context);
|
|
2818
304
|
if (options.json) {
|
|
2819
305
|
const output = JSON.stringify(report, null, 2);
|
|
2820
306
|
if (options.output) {
|
|
@@ -2840,6 +326,7 @@ async function scanProgram(programId, options) {
|
|
|
2840
326
|
}
|
|
2841
327
|
|
|
2842
328
|
// src/index.ts
|
|
329
|
+
import { VERSION } from "solana-privacy-scanner-core";
|
|
2843
330
|
dotenv.config({ path: ".env.local" });
|
|
2844
331
|
dotenv.config();
|
|
2845
332
|
var program = new Command();
|