@sip-protocol/sdk 0.7.1 → 0.7.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/dist/browser.d.mts +1 -1
  2. package/dist/browser.d.ts +1 -1
  3. package/dist/browser.js +2926 -341
  4. package/dist/browser.mjs +48 -2
  5. package/dist/chunk-2XIVXWHA.mjs +1930 -0
  6. package/dist/chunk-3M3HNQCW.mjs +18253 -0
  7. package/dist/chunk-7RFRWDCW.mjs +1504 -0
  8. package/dist/chunk-F6F73W35.mjs +16166 -0
  9. package/dist/chunk-OFDBEIEK.mjs +16166 -0
  10. package/dist/chunk-SF7YSLF5.mjs +1515 -0
  11. package/dist/chunk-WWUSGOXE.mjs +17129 -0
  12. package/dist/index-8MQz13eJ.d.mts +13746 -0
  13. package/dist/index-B71aXVzk.d.ts +13264 -0
  14. package/dist/index-DIBZHOOQ.d.ts +13746 -0
  15. package/dist/index-pOIIuwfV.d.mts +13264 -0
  16. package/dist/index.d.mts +1 -1
  17. package/dist/index.d.ts +1 -1
  18. package/dist/index.js +2911 -326
  19. package/dist/index.mjs +48 -2
  20. package/dist/solana-4O4K45VU.mjs +46 -0
  21. package/dist/solana-NDABAZ6P.mjs +56 -0
  22. package/dist/solana-ZYO63LY5.mjs +46 -0
  23. package/package.json +3 -3
  24. package/src/chains/solana/index.ts +24 -0
  25. package/src/chains/solana/providers/generic.ts +160 -0
  26. package/src/chains/solana/providers/helius.ts +249 -0
  27. package/src/chains/solana/providers/index.ts +54 -0
  28. package/src/chains/solana/providers/interface.ts +178 -0
  29. package/src/chains/solana/providers/webhook.ts +519 -0
  30. package/src/chains/solana/scan.ts +88 -8
  31. package/src/chains/solana/types.ts +20 -1
  32. package/src/compliance/index.ts +14 -0
  33. package/src/compliance/range-sas.ts +591 -0
  34. package/src/index.ts +99 -0
  35. package/src/privacy-backends/index.ts +86 -0
  36. package/src/privacy-backends/interface.ts +263 -0
  37. package/src/privacy-backends/privacycash-types.ts +278 -0
  38. package/src/privacy-backends/privacycash.ts +460 -0
  39. package/src/privacy-backends/registry.ts +278 -0
  40. package/src/privacy-backends/router.ts +346 -0
  41. package/src/privacy-backends/sip-native.ts +253 -0
  42. package/src/proofs/noir.ts +1 -1
  43. package/src/surveillance/algorithms/address-reuse.ts +143 -0
  44. package/src/surveillance/algorithms/cluster.ts +247 -0
  45. package/src/surveillance/algorithms/exchange.ts +295 -0
  46. package/src/surveillance/algorithms/temporal.ts +337 -0
  47. package/src/surveillance/analyzer.ts +442 -0
  48. package/src/surveillance/index.ts +64 -0
  49. package/src/surveillance/scoring.ts +372 -0
  50. package/src/surveillance/types.ts +264 -0
@@ -0,0 +1,253 @@
1
+ /**
2
+ * SIP Native Privacy Backend
3
+ *
4
+ * Implements the PrivacyBackend interface using SIP's native privacy primitives:
5
+ * - Stealth addresses (EIP-5564 style)
6
+ * - Pedersen commitments (amount hiding)
7
+ * - Viewing keys (compliance support)
8
+ *
9
+ * @example
10
+ * ```typescript
11
+ * import { SIPNativeBackend, PrivacyBackendRegistry } from '@sip-protocol/sdk'
12
+ *
13
+ * const backend = new SIPNativeBackend()
14
+ * const registry = new PrivacyBackendRegistry()
15
+ * registry.register(backend)
16
+ *
17
+ * // Check capabilities
18
+ * const caps = backend.getCapabilities()
19
+ * console.log(caps.complianceSupport) // true
20
+ *
21
+ * // Execute transfer
22
+ * const result = await backend.execute({
23
+ * chain: 'solana',
24
+ * sender: 'sender-address',
25
+ * recipient: 'stealth-address',
26
+ * mint: 'token-mint',
27
+ * amount: BigInt(1000000),
28
+ * decimals: 6,
29
+ * })
30
+ * ```
31
+ */
32
+
33
+ import type { ChainType } from '@sip-protocol/types'
34
+ import type {
35
+ PrivacyBackend,
36
+ BackendType,
37
+ BackendCapabilities,
38
+ TransferParams,
39
+ TransactionResult,
40
+ AvailabilityResult,
41
+ } from './interface'
42
+
43
+ /**
44
+ * Supported chains for SIP Native backend
45
+ */
46
+ const SUPPORTED_CHAINS: ChainType[] = [
47
+ 'solana',
48
+ 'ethereum',
49
+ 'near',
50
+ 'polygon',
51
+ 'arbitrum',
52
+ 'optimism',
53
+ 'base',
54
+ 'avalanche',
55
+ 'bsc',
56
+ ]
57
+
58
+ /**
59
+ * SIP Native backend capabilities
60
+ */
61
+ const SIP_NATIVE_CAPABILITIES: BackendCapabilities = {
62
+ hiddenAmount: true,
63
+ hiddenSender: true,
64
+ hiddenRecipient: true,
65
+ hiddenCompute: false,
66
+ complianceSupport: true,
67
+ anonymitySet: undefined, // Not pool-based
68
+ setupRequired: false,
69
+ latencyEstimate: 'fast',
70
+ supportedTokens: 'all',
71
+ minAmount: undefined,
72
+ maxAmount: undefined,
73
+ }
74
+
75
+ /**
76
+ * Configuration options for SIP Native backend
77
+ */
78
+ export interface SIPNativeBackendConfig {
79
+ /** Custom supported chains (overrides default) */
80
+ chains?: ChainType[]
81
+ /** Whether to require viewing keys for all transfers */
82
+ requireViewingKey?: boolean
83
+ /** Minimum amount for transfers (optional) */
84
+ minAmount?: bigint
85
+ /** Maximum amount for transfers (optional) */
86
+ maxAmount?: bigint
87
+ }
88
+
89
+ /**
90
+ * SIP Native Privacy Backend
91
+ *
92
+ * Uses stealth addresses and Pedersen commitments for transaction privacy,
93
+ * with viewing key support for regulatory compliance.
94
+ */
95
+ export class SIPNativeBackend implements PrivacyBackend {
96
+ readonly name = 'sip-native'
97
+ readonly type: BackendType = 'transaction'
98
+ readonly chains: ChainType[]
99
+
100
+ private config: Required<SIPNativeBackendConfig>
101
+
102
+ /**
103
+ * Create a new SIP Native backend
104
+ *
105
+ * @param config - Backend configuration
106
+ */
107
+ constructor(config: SIPNativeBackendConfig = {}) {
108
+ this.chains = config.chains ?? SUPPORTED_CHAINS
109
+ this.config = {
110
+ chains: this.chains,
111
+ requireViewingKey: config.requireViewingKey ?? false,
112
+ minAmount: config.minAmount ?? BigInt(0),
113
+ maxAmount: config.maxAmount ?? BigInt(Number.MAX_SAFE_INTEGER),
114
+ }
115
+ }
116
+
117
+ /**
118
+ * Check if backend is available for given parameters
119
+ */
120
+ async checkAvailability(params: TransferParams): Promise<AvailabilityResult> {
121
+ // Check chain support
122
+ if (!this.chains.includes(params.chain)) {
123
+ return {
124
+ available: false,
125
+ reason: `Chain '${params.chain}' not supported by SIP Native backend`,
126
+ }
127
+ }
128
+
129
+ // Check viewing key requirement
130
+ if (this.config.requireViewingKey && !params.viewingKey) {
131
+ return {
132
+ available: false,
133
+ reason: 'Viewing key required for SIP Native backend',
134
+ }
135
+ }
136
+
137
+ // Check amount bounds
138
+ if (params.amount < this.config.minAmount) {
139
+ return {
140
+ available: false,
141
+ reason: `Amount ${params.amount} below minimum ${this.config.minAmount}`,
142
+ }
143
+ }
144
+
145
+ if (params.amount > this.config.maxAmount) {
146
+ return {
147
+ available: false,
148
+ reason: `Amount ${params.amount} above maximum ${this.config.maxAmount}`,
149
+ }
150
+ }
151
+
152
+ // Estimate cost based on chain
153
+ const estimatedCost = this.getEstimatedCostForChain(params.chain)
154
+
155
+ return {
156
+ available: true,
157
+ estimatedCost,
158
+ estimatedTime: 1000, // ~1 second for stealth address operations
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Get backend capabilities
164
+ */
165
+ getCapabilities(): BackendCapabilities {
166
+ return {
167
+ ...SIP_NATIVE_CAPABILITIES,
168
+ minAmount: this.config.minAmount > BigInt(0) ? this.config.minAmount : undefined,
169
+ maxAmount: this.config.maxAmount < BigInt(Number.MAX_SAFE_INTEGER)
170
+ ? this.config.maxAmount
171
+ : undefined,
172
+ }
173
+ }
174
+
175
+ /**
176
+ * Execute a privacy-preserving transfer
177
+ *
178
+ * This creates a stealth address transfer with:
179
+ * - Ephemeral keypair generation
180
+ * - Stealth address derivation
181
+ * - Pedersen commitment for amount
182
+ * - Optional viewing key encryption
183
+ */
184
+ async execute(params: TransferParams): Promise<TransactionResult> {
185
+ // Validate availability first
186
+ const availability = await this.checkAvailability(params)
187
+ if (!availability.available) {
188
+ return {
189
+ success: false,
190
+ error: availability.reason,
191
+ backend: this.name,
192
+ }
193
+ }
194
+
195
+ try {
196
+ // In a real implementation, this would:
197
+ // 1. Generate ephemeral keypair
198
+ // 2. Derive stealth address from recipient's meta-address
199
+ // 3. Create Pedersen commitment for amount
200
+ // 4. Build and submit transaction
201
+ // 5. Optionally encrypt data for viewing key
202
+
203
+ // For now, return a simulated successful result
204
+ // Real implementation depends on chain-specific adapters
205
+ const simulatedSignature = `sim_${Date.now()}_${Math.random().toString(36).slice(2)}`
206
+
207
+ return {
208
+ success: true,
209
+ signature: simulatedSignature,
210
+ backend: this.name,
211
+ metadata: {
212
+ chain: params.chain,
213
+ amount: params.amount.toString(),
214
+ hasViewingKey: !!params.viewingKey,
215
+ timestamp: Date.now(),
216
+ },
217
+ }
218
+ } catch (error) {
219
+ return {
220
+ success: false,
221
+ error: error instanceof Error ? error.message : 'Unknown error',
222
+ backend: this.name,
223
+ }
224
+ }
225
+ }
226
+
227
+ /**
228
+ * Estimate cost for a transfer
229
+ */
230
+ async estimateCost(params: TransferParams): Promise<bigint> {
231
+ return this.getEstimatedCostForChain(params.chain)
232
+ }
233
+
234
+ /**
235
+ * Get estimated cost based on chain
236
+ */
237
+ private getEstimatedCostForChain(chain: ChainType): bigint {
238
+ // Estimated costs in smallest chain units
239
+ const costMap: Partial<Record<ChainType, bigint>> = {
240
+ solana: BigInt(5000), // ~0.000005 SOL
241
+ ethereum: BigInt('50000000000000'), // ~0.00005 ETH
242
+ near: BigInt('1000000000000000000000'), // ~0.001 NEAR
243
+ polygon: BigInt('50000000000000'), // ~0.00005 MATIC
244
+ arbitrum: BigInt('50000000000000'),
245
+ optimism: BigInt('50000000000000'),
246
+ base: BigInt('50000000000000'),
247
+ avalanche: BigInt('50000000000000'),
248
+ bsc: BigInt('50000000000000'),
249
+ }
250
+
251
+ return costMap[chain] ?? BigInt(0)
252
+ }
253
+ }
@@ -801,7 +801,7 @@ export class NoirProofProvider implements ProofProvider {
801
801
  // Verify the proof
802
802
  const isValid = await backend.verifyProof({
803
803
  proof: proofBytes,
804
- publicInputs: proof.publicInputs.map(input =>
804
+ publicInputs: proof.publicInputs.map((input: string) =>
805
805
  input.startsWith('0x') ? input.slice(2) : input
806
806
  ),
807
807
  })
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Address Reuse Detection Algorithm
3
+ *
4
+ * Detects when the same address is used multiple times for receiving
5
+ * or sending transactions, which degrades privacy by creating linkability.
6
+ *
7
+ * @packageDocumentation
8
+ */
9
+
10
+ import type { AnalyzableTransaction, AddressReuseResult } from '../types'
11
+
12
+ /**
13
+ * Maximum score deduction for address reuse (out of 25)
14
+ */
15
+ const MAX_DEDUCTION = 25
16
+
17
+ /**
18
+ * Deduction per reuse instance
19
+ */
20
+ const DEDUCTION_PER_REUSE = 2
21
+
22
+ /**
23
+ * Threshold before counting as reuse (first use is free)
24
+ */
25
+ const REUSE_THRESHOLD = 1
26
+
27
+ /**
28
+ * Analyze address reuse patterns in transaction history
29
+ *
30
+ * @param transactions - Transaction history to analyze
31
+ * @param walletAddress - The wallet being analyzed
32
+ * @returns Address reuse analysis result
33
+ *
34
+ * @example
35
+ * ```typescript
36
+ * const result = analyzeAddressReuse(transactions, 'abc123...')
37
+ * console.log(result.totalReuseCount) // 12
38
+ * console.log(result.scoreDeduction) // 24 (capped at 25)
39
+ * ```
40
+ */
41
+ export function analyzeAddressReuse(
42
+ transactions: AnalyzableTransaction[],
43
+ walletAddress: string
44
+ ): AddressReuseResult {
45
+ // Track address usage counts
46
+ const receiveAddresses = new Map<string, number>()
47
+ const sendAddresses = new Map<string, number>()
48
+
49
+ for (const tx of transactions) {
50
+ if (!tx.success) continue
51
+
52
+ // Count receives (wallet is recipient)
53
+ if (tx.recipient === walletAddress) {
54
+ const count = receiveAddresses.get(walletAddress) ?? 0
55
+ receiveAddresses.set(walletAddress, count + 1)
56
+ }
57
+
58
+ // Count sends (wallet is sender)
59
+ if (tx.sender === walletAddress) {
60
+ const count = sendAddresses.get(walletAddress) ?? 0
61
+ sendAddresses.set(walletAddress, count + 1)
62
+ }
63
+
64
+ // Also track any other addresses the wallet has used
65
+ // (e.g., associated token accounts, derived addresses)
66
+ for (const addr of tx.involvedAddresses) {
67
+ if (addr === walletAddress) continue
68
+
69
+ // If this address appears multiple times with the wallet,
70
+ // it might indicate reuse of derived addresses
71
+ if (tx.sender === walletAddress) {
72
+ const count = sendAddresses.get(addr) ?? 0
73
+ sendAddresses.set(addr, count + 1)
74
+ }
75
+ if (tx.recipient === walletAddress) {
76
+ const count = receiveAddresses.get(addr) ?? 0
77
+ receiveAddresses.set(addr, count + 1)
78
+ }
79
+ }
80
+ }
81
+
82
+ // Calculate reuse counts
83
+ let receiveReuseCount = 0
84
+ let sendReuseCount = 0
85
+ const reusedAddresses: AddressReuseResult['reusedAddresses'] = []
86
+
87
+ // Process receive addresses
88
+ for (const [address, count] of Array.from(receiveAddresses.entries())) {
89
+ if (count > REUSE_THRESHOLD) {
90
+ const reuseCount = count - REUSE_THRESHOLD
91
+ receiveReuseCount += reuseCount
92
+
93
+ const existing = reusedAddresses.find((r) => r.address === address)
94
+ if (existing) {
95
+ existing.useCount = Math.max(existing.useCount, count)
96
+ existing.type = 'both'
97
+ } else {
98
+ reusedAddresses.push({
99
+ address,
100
+ useCount: count,
101
+ type: 'receive',
102
+ })
103
+ }
104
+ }
105
+ }
106
+
107
+ // Process send addresses
108
+ for (const [address, count] of Array.from(sendAddresses.entries())) {
109
+ if (count > REUSE_THRESHOLD) {
110
+ const reuseCount = count - REUSE_THRESHOLD
111
+ sendReuseCount += reuseCount
112
+
113
+ const existing = reusedAddresses.find((r) => r.address === address)
114
+ if (existing) {
115
+ existing.useCount = Math.max(existing.useCount, count)
116
+ existing.type = 'both'
117
+ } else {
118
+ reusedAddresses.push({
119
+ address,
120
+ useCount: count,
121
+ type: 'send',
122
+ })
123
+ }
124
+ }
125
+ }
126
+
127
+ const totalReuseCount = receiveReuseCount + sendReuseCount
128
+
129
+ // Calculate score deduction
130
+ const rawDeduction = totalReuseCount * DEDUCTION_PER_REUSE
131
+ const scoreDeduction = Math.min(rawDeduction, MAX_DEDUCTION)
132
+
133
+ // Sort reused addresses by use count (most reused first)
134
+ reusedAddresses.sort((a, b) => b.useCount - a.useCount)
135
+
136
+ return {
137
+ receiveReuseCount,
138
+ sendReuseCount,
139
+ totalReuseCount,
140
+ scoreDeduction,
141
+ reusedAddresses: reusedAddresses.slice(0, 10), // Top 10 most reused
142
+ }
143
+ }
@@ -0,0 +1,247 @@
1
+ /**
2
+ * Cluster Detection Algorithm (Common Input Ownership Heuristic)
3
+ *
4
+ * Identifies addresses that are likely owned by the same entity
5
+ * by analyzing transaction patterns:
6
+ *
7
+ * 1. Common Input Heuristic: Multiple inputs in same tx = same owner
8
+ * 2. Change Address Detection: Change outputs likely go to owner
9
+ * 3. Consolidation Patterns: Merging funds indicates ownership
10
+ *
11
+ * @packageDocumentation
12
+ */
13
+
14
+ import type { AnalyzableTransaction, ClusterResult } from '../types'
15
+
16
+ /**
17
+ * Maximum score deduction for cluster exposure (out of 25)
18
+ */
19
+ const MAX_DEDUCTION = 25
20
+
21
+ /**
22
+ * Deduction per linked address
23
+ */
24
+ const DEDUCTION_PER_LINK = 3
25
+
26
+ /**
27
+ * Minimum transactions between addresses to consider linked
28
+ */
29
+ const MIN_LINK_THRESHOLD = 2
30
+
31
+ /**
32
+ * Union-Find data structure for efficient cluster management
33
+ */
34
+ class UnionFind {
35
+ private parent: Map<string, string>
36
+ private rank: Map<string, number>
37
+
38
+ constructor() {
39
+ this.parent = new Map()
40
+ this.rank = new Map()
41
+ }
42
+
43
+ find(x: string): string {
44
+ if (!this.parent.has(x)) {
45
+ this.parent.set(x, x)
46
+ this.rank.set(x, 0)
47
+ }
48
+
49
+ if (this.parent.get(x) !== x) {
50
+ this.parent.set(x, this.find(this.parent.get(x)!))
51
+ }
52
+
53
+ return this.parent.get(x)!
54
+ }
55
+
56
+ union(x: string, y: string): void {
57
+ const rootX = this.find(x)
58
+ const rootY = this.find(y)
59
+
60
+ if (rootX === rootY) return
61
+
62
+ const rankX = this.rank.get(rootX) ?? 0
63
+ const rankY = this.rank.get(rootY) ?? 0
64
+
65
+ if (rankX < rankY) {
66
+ this.parent.set(rootX, rootY)
67
+ } else if (rankX > rankY) {
68
+ this.parent.set(rootY, rootX)
69
+ } else {
70
+ this.parent.set(rootY, rootX)
71
+ this.rank.set(rootX, rankX + 1)
72
+ }
73
+ }
74
+
75
+ getClusters(): Map<string, string[]> {
76
+ const clusters = new Map<string, string[]>()
77
+
78
+ for (const addr of Array.from(this.parent.keys())) {
79
+ const root = this.find(addr)
80
+ if (!clusters.has(root)) {
81
+ clusters.set(root, [])
82
+ }
83
+ clusters.get(root)!.push(addr)
84
+ }
85
+
86
+ return clusters
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Detect address clusters using Common Input Ownership Heuristic
92
+ *
93
+ * @param transactions - Transaction history to analyze
94
+ * @param walletAddress - The wallet being analyzed
95
+ * @returns Cluster detection result
96
+ *
97
+ * @example
98
+ * ```typescript
99
+ * const result = detectClusters(transactions, 'abc123...')
100
+ * console.log(result.linkedAddressCount) // 5
101
+ * console.log(result.clusters[0].linkType) // 'common-input'
102
+ * ```
103
+ */
104
+ export function detectClusters(
105
+ transactions: AnalyzableTransaction[],
106
+ walletAddress: string
107
+ ): ClusterResult {
108
+ const uf = new UnionFind()
109
+ const linkCounts = new Map<string, number>()
110
+ const linkTypes = new Map<string, 'common-input' | 'change-address' | 'consolidation'>()
111
+ const txCountPerPair = new Map<string, number>()
112
+
113
+ // Always include the wallet address
114
+ uf.find(walletAddress)
115
+
116
+ for (const tx of transactions) {
117
+ if (!tx.success) continue
118
+
119
+ const involvedWithWallet = tx.involvedAddresses.filter(
120
+ (addr) => addr !== walletAddress
121
+ )
122
+
123
+ // Common Input Heuristic: If wallet sends with other inputs,
124
+ // those inputs are likely controlled by same entity
125
+ if (tx.sender === walletAddress && involvedWithWallet.length > 0) {
126
+ for (const addr of involvedWithWallet) {
127
+ // Count how many times we see this relationship
128
+ const pairKey = [walletAddress, addr].sort().join(':')
129
+ const count = (txCountPerPair.get(pairKey) ?? 0) + 1
130
+ txCountPerPair.set(pairKey, count)
131
+
132
+ if (count >= MIN_LINK_THRESHOLD) {
133
+ uf.union(walletAddress, addr)
134
+ linkCounts.set(addr, count)
135
+ linkTypes.set(addr, 'common-input')
136
+ }
137
+ }
138
+ }
139
+
140
+ // Change Address Detection: Small outputs after a large tx
141
+ // often go to change addresses owned by the sender
142
+ if (tx.sender === walletAddress && tx.recipient !== walletAddress) {
143
+ // Look for other outputs in the same transaction to the wallet
144
+ // This is a simplified heuristic
145
+ const otherRecipients = involvedWithWallet.filter(
146
+ (addr) => addr !== tx.recipient
147
+ )
148
+
149
+ for (const addr of otherRecipients) {
150
+ const pairKey = [walletAddress, addr].sort().join(':')
151
+ const count = (txCountPerPair.get(pairKey) ?? 0) + 1
152
+ txCountPerPair.set(pairKey, count)
153
+
154
+ if (count >= MIN_LINK_THRESHOLD) {
155
+ uf.union(walletAddress, addr)
156
+ if (!linkTypes.has(addr)) {
157
+ linkTypes.set(addr, 'change-address')
158
+ }
159
+ linkCounts.set(addr, count)
160
+ }
161
+ }
162
+ }
163
+
164
+ // Consolidation Pattern: Multiple inputs merged into one output
165
+ // All inputs likely owned by same entity
166
+ if (tx.recipient === walletAddress && involvedWithWallet.length > 1) {
167
+ // Multiple addresses sent to this wallet in same tx = consolidation
168
+ for (let i = 0; i < involvedWithWallet.length; i++) {
169
+ for (let j = i + 1; j < involvedWithWallet.length; j++) {
170
+ const addr1 = involvedWithWallet[i]
171
+ const addr2 = involvedWithWallet[j]
172
+ const pairKey = [addr1, addr2].sort().join(':')
173
+ const count = (txCountPerPair.get(pairKey) ?? 0) + 1
174
+ txCountPerPair.set(pairKey, count)
175
+
176
+ if (count >= MIN_LINK_THRESHOLD) {
177
+ uf.union(addr1, addr2)
178
+ linkTypes.set(addr1, 'consolidation')
179
+ linkTypes.set(addr2, 'consolidation')
180
+ }
181
+ }
182
+
183
+ // Also link to wallet
184
+ const pairKey = [walletAddress, involvedWithWallet[i]].sort().join(':')
185
+ const count = (txCountPerPair.get(pairKey) ?? 0) + 1
186
+ txCountPerPair.set(pairKey, count)
187
+
188
+ if (count >= MIN_LINK_THRESHOLD) {
189
+ uf.union(walletAddress, involvedWithWallet[i])
190
+ linkCounts.set(involvedWithWallet[i], count)
191
+ }
192
+ }
193
+ }
194
+ }
195
+
196
+ // Get clusters containing the wallet
197
+ const allClusters = uf.getClusters()
198
+ const walletRoot = uf.find(walletAddress)
199
+ const walletCluster = allClusters.get(walletRoot) ?? [walletAddress]
200
+
201
+ // Count linked addresses (excluding the wallet itself)
202
+ const linkedAddresses = walletCluster.filter((addr) => addr !== walletAddress)
203
+ const linkedAddressCount = linkedAddresses.length
204
+
205
+ // Calculate confidence based on transaction counts
206
+ const totalLinkTxs = Array.from(linkCounts.values()).reduce((a, b) => a + b, 0)
207
+ const confidence = Math.min(totalLinkTxs / (linkedAddressCount * 5), 1)
208
+
209
+ // Calculate score deduction
210
+ const rawDeduction = linkedAddressCount * DEDUCTION_PER_LINK
211
+ const scoreDeduction = Math.min(rawDeduction, MAX_DEDUCTION)
212
+
213
+ // Build cluster details
214
+ const clusters: ClusterResult['clusters'] = []
215
+
216
+ if (linkedAddressCount > 0) {
217
+ // Group by link type
218
+ const byType = new Map<string, string[]>()
219
+ for (const addr of linkedAddresses) {
220
+ const type = linkTypes.get(addr) ?? 'common-input'
221
+ if (!byType.has(type)) {
222
+ byType.set(type, [])
223
+ }
224
+ byType.get(type)!.push(addr)
225
+ }
226
+
227
+ for (const [type, addresses] of Array.from(byType.entries())) {
228
+ const txCount = addresses.reduce(
229
+ (sum, addr) => sum + (linkCounts.get(addr) ?? 0),
230
+ 0
231
+ )
232
+
233
+ clusters.push({
234
+ addresses: [walletAddress, ...addresses],
235
+ linkType: type as 'common-input' | 'change-address' | 'consolidation',
236
+ transactionCount: txCount,
237
+ })
238
+ }
239
+ }
240
+
241
+ return {
242
+ linkedAddressCount,
243
+ confidence,
244
+ scoreDeduction,
245
+ clusters,
246
+ }
247
+ }