@sip-protocol/sdk 0.7.2 → 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.
- package/dist/browser.d.mts +1 -1
- package/dist/browser.d.ts +1 -1
- package/dist/browser.js +2926 -341
- package/dist/browser.mjs +48 -2
- package/dist/chunk-2XIVXWHA.mjs +1930 -0
- package/dist/chunk-3M3HNQCW.mjs +18253 -0
- package/dist/chunk-7RFRWDCW.mjs +1504 -0
- package/dist/chunk-F6F73W35.mjs +16166 -0
- package/dist/chunk-OFDBEIEK.mjs +16166 -0
- package/dist/chunk-SF7YSLF5.mjs +1515 -0
- package/dist/chunk-WWUSGOXE.mjs +17129 -0
- package/dist/index-8MQz13eJ.d.mts +13746 -0
- package/dist/index-B71aXVzk.d.ts +13264 -0
- package/dist/index-DIBZHOOQ.d.ts +13746 -0
- package/dist/index-pOIIuwfV.d.mts +13264 -0
- package/dist/index.d.mts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +2911 -326
- package/dist/index.mjs +48 -2
- package/dist/solana-4O4K45VU.mjs +46 -0
- package/dist/solana-NDABAZ6P.mjs +56 -0
- package/dist/solana-ZYO63LY5.mjs +46 -0
- package/package.json +2 -2
- package/src/chains/solana/index.ts +24 -0
- package/src/chains/solana/providers/generic.ts +160 -0
- package/src/chains/solana/providers/helius.ts +249 -0
- package/src/chains/solana/providers/index.ts +54 -0
- package/src/chains/solana/providers/interface.ts +178 -0
- package/src/chains/solana/providers/webhook.ts +519 -0
- package/src/chains/solana/scan.ts +88 -8
- package/src/chains/solana/types.ts +20 -1
- package/src/compliance/index.ts +14 -0
- package/src/compliance/range-sas.ts +591 -0
- package/src/index.ts +99 -0
- package/src/privacy-backends/index.ts +86 -0
- package/src/privacy-backends/interface.ts +263 -0
- package/src/privacy-backends/privacycash-types.ts +278 -0
- package/src/privacy-backends/privacycash.ts +460 -0
- package/src/privacy-backends/registry.ts +278 -0
- package/src/privacy-backends/router.ts +346 -0
- package/src/privacy-backends/sip-native.ts +253 -0
- package/src/proofs/noir.ts +1 -1
- package/src/surveillance/algorithms/address-reuse.ts +143 -0
- package/src/surveillance/algorithms/cluster.ts +247 -0
- package/src/surveillance/algorithms/exchange.ts +295 -0
- package/src/surveillance/algorithms/temporal.ts +337 -0
- package/src/surveillance/analyzer.ts +442 -0
- package/src/surveillance/index.ts +64 -0
- package/src/surveillance/scoring.ts +372 -0
- 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
|
+
}
|
package/src/proofs/noir.ts
CHANGED
|
@@ -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
|
+
}
|