@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,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Exchange Exposure Detection Algorithm
|
|
3
|
+
*
|
|
4
|
+
* Detects interactions with known exchange addresses to identify
|
|
5
|
+
* KYC exposure points. Deposits to centralized exchanges are
|
|
6
|
+
* particularly privacy-degrading as they link on-chain activity
|
|
7
|
+
* to verified identities.
|
|
8
|
+
*
|
|
9
|
+
* @packageDocumentation
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type {
|
|
13
|
+
AnalyzableTransaction,
|
|
14
|
+
ExchangeExposureResult,
|
|
15
|
+
KnownExchange,
|
|
16
|
+
} from '../types'
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Maximum score deduction for exchange exposure (out of 20)
|
|
20
|
+
*/
|
|
21
|
+
const MAX_DEDUCTION = 20
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Deduction per unique CEX (KYC required)
|
|
25
|
+
*/
|
|
26
|
+
const DEDUCTION_PER_CEX = 8
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Deduction per unique DEX (no KYC but still traceable)
|
|
30
|
+
*/
|
|
31
|
+
const DEDUCTION_PER_DEX = 2
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Known Solana exchange addresses
|
|
35
|
+
* Sources: Arkham Intelligence, public documentation
|
|
36
|
+
*/
|
|
37
|
+
export const KNOWN_EXCHANGES: KnownExchange[] = [
|
|
38
|
+
// Centralized Exchanges (KYC Required)
|
|
39
|
+
{
|
|
40
|
+
name: 'Binance',
|
|
41
|
+
addresses: [
|
|
42
|
+
'5tzFkiKscXHK5ZXCGbXZxdw7gTjjD1mBwuoFbhUvuAi9',
|
|
43
|
+
'9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM',
|
|
44
|
+
'AC5RDfQFmDS1deWZos921JfqscXdByf8BKHs5ACWjtW2',
|
|
45
|
+
],
|
|
46
|
+
type: 'cex',
|
|
47
|
+
kycRequired: true,
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
name: 'Coinbase',
|
|
51
|
+
addresses: [
|
|
52
|
+
'H8sMJSCQxfKiFTCfDR3DUMLPwcRbM61LGFJ8N4dK3WjS',
|
|
53
|
+
'2ojv9BAiHUrvsm9gxDe7fJSzbNZSJcxZvf8dqmWGHG8S',
|
|
54
|
+
'GJRs4FwHtemZ5ZE9x3FNvJ8TMwitKTh21yxdRPqn7npE',
|
|
55
|
+
],
|
|
56
|
+
type: 'cex',
|
|
57
|
+
kycRequired: true,
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
name: 'Kraken',
|
|
61
|
+
addresses: [
|
|
62
|
+
'krakenmRKej41L9sX8N8Z2mhjZ8UpVHHBMzkKzfBh54',
|
|
63
|
+
],
|
|
64
|
+
type: 'cex',
|
|
65
|
+
kycRequired: true,
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
name: 'FTX (Defunct)',
|
|
69
|
+
addresses: [
|
|
70
|
+
'FTXkd8cjuYGRLzPVdvqxNxNNNYBfFPPjrF3vW2Yq8p7',
|
|
71
|
+
],
|
|
72
|
+
type: 'cex',
|
|
73
|
+
kycRequired: true,
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
name: 'KuCoin',
|
|
77
|
+
addresses: [
|
|
78
|
+
'BmFdpraQhkiDQE6SnfG5omcA1VwzqfXrwtNYBwWTymy6',
|
|
79
|
+
],
|
|
80
|
+
type: 'cex',
|
|
81
|
+
kycRequired: true,
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
name: 'OKX',
|
|
85
|
+
addresses: [
|
|
86
|
+
'GGztQqQ6pCPaJQnNpXBgELr5cs3WwDakRbh1iEMzjgSJ',
|
|
87
|
+
],
|
|
88
|
+
type: 'cex',
|
|
89
|
+
kycRequired: true,
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
name: 'Bybit',
|
|
93
|
+
addresses: [
|
|
94
|
+
'AC5RDfQFmDS1deWZos921JfqscXdByf8BKHs5ACWjtW3',
|
|
95
|
+
],
|
|
96
|
+
type: 'cex',
|
|
97
|
+
kycRequired: true,
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
name: 'Gate.io',
|
|
101
|
+
addresses: [
|
|
102
|
+
'u6PJ8DtQuPFnfmwHbGFULQ4u4EgjDiyYKjVEsynXq2w',
|
|
103
|
+
],
|
|
104
|
+
type: 'cex',
|
|
105
|
+
kycRequired: true,
|
|
106
|
+
},
|
|
107
|
+
|
|
108
|
+
// Decentralized Exchanges (No KYC but traceable)
|
|
109
|
+
{
|
|
110
|
+
name: 'Jupiter',
|
|
111
|
+
addresses: [
|
|
112
|
+
'JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4',
|
|
113
|
+
'JUP4Fb2cqiRUcaTHdrPC8h2gNsA2ETXiPDD33WcGuJB',
|
|
114
|
+
],
|
|
115
|
+
type: 'dex',
|
|
116
|
+
kycRequired: false,
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
name: 'Raydium',
|
|
120
|
+
addresses: [
|
|
121
|
+
'675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8',
|
|
122
|
+
'5Q544fKrFoe6tsEbD7S8EmxGTJYAKtTVhAW5Q5pge4j1',
|
|
123
|
+
],
|
|
124
|
+
type: 'dex',
|
|
125
|
+
kycRequired: false,
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
name: 'Orca',
|
|
129
|
+
addresses: [
|
|
130
|
+
'9W959DqEETiGZocYWCQPaJ6sBmUzgfxXfqGeTEdp3aQP',
|
|
131
|
+
'whirLbMiicVdio4qvUfM5KAg6Ct8VwpYzGff3uctyCc',
|
|
132
|
+
],
|
|
133
|
+
type: 'dex',
|
|
134
|
+
kycRequired: false,
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
name: 'Marinade',
|
|
138
|
+
addresses: [
|
|
139
|
+
'MarBmsSgKXdrN1egZf5sqe1TMai9K1rChYNDJgjq7aD',
|
|
140
|
+
],
|
|
141
|
+
type: 'dex',
|
|
142
|
+
kycRequired: false,
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
name: 'Phantom Swap',
|
|
146
|
+
addresses: [
|
|
147
|
+
'PhoeNiXZ8ByJGLkxNfZRnkUfjvmuYqLR89jjFHGqdXY',
|
|
148
|
+
],
|
|
149
|
+
type: 'dex',
|
|
150
|
+
kycRequired: false,
|
|
151
|
+
},
|
|
152
|
+
]
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Build address lookup map for efficient detection
|
|
156
|
+
*/
|
|
157
|
+
function buildExchangeLookup(
|
|
158
|
+
exchanges: KnownExchange[]
|
|
159
|
+
): Map<string, KnownExchange> {
|
|
160
|
+
const lookup = new Map<string, KnownExchange>()
|
|
161
|
+
|
|
162
|
+
for (const exchange of exchanges) {
|
|
163
|
+
for (const address of exchange.addresses) {
|
|
164
|
+
lookup.set(address, exchange)
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return lookup
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Detect exchange interactions in transaction history
|
|
173
|
+
*
|
|
174
|
+
* @param transactions - Transaction history to analyze
|
|
175
|
+
* @param walletAddress - The wallet being analyzed
|
|
176
|
+
* @param customExchanges - Optional custom exchange list
|
|
177
|
+
* @returns Exchange exposure analysis result
|
|
178
|
+
*
|
|
179
|
+
* @example
|
|
180
|
+
* ```typescript
|
|
181
|
+
* const result = detectExchangeExposure(transactions, 'abc123...')
|
|
182
|
+
* console.log(result.exchangeCount) // 2
|
|
183
|
+
* console.log(result.exchanges[0].name) // 'Binance'
|
|
184
|
+
* ```
|
|
185
|
+
*/
|
|
186
|
+
export function detectExchangeExposure(
|
|
187
|
+
transactions: AnalyzableTransaction[],
|
|
188
|
+
walletAddress: string,
|
|
189
|
+
customExchanges?: KnownExchange[]
|
|
190
|
+
): ExchangeExposureResult {
|
|
191
|
+
const exchanges = customExchanges
|
|
192
|
+
? [...KNOWN_EXCHANGES, ...customExchanges]
|
|
193
|
+
: KNOWN_EXCHANGES
|
|
194
|
+
|
|
195
|
+
const lookup = buildExchangeLookup(exchanges)
|
|
196
|
+
|
|
197
|
+
// Track interactions per exchange
|
|
198
|
+
const exchangeStats = new Map<
|
|
199
|
+
string,
|
|
200
|
+
{
|
|
201
|
+
exchange: KnownExchange
|
|
202
|
+
deposits: number
|
|
203
|
+
withdrawals: number
|
|
204
|
+
firstInteraction: number
|
|
205
|
+
lastInteraction: number
|
|
206
|
+
}
|
|
207
|
+
>()
|
|
208
|
+
|
|
209
|
+
for (const tx of transactions) {
|
|
210
|
+
if (!tx.success) continue
|
|
211
|
+
|
|
212
|
+
// Check all involved addresses
|
|
213
|
+
for (const addr of tx.involvedAddresses) {
|
|
214
|
+
const exchange = lookup.get(addr)
|
|
215
|
+
if (!exchange) continue
|
|
216
|
+
|
|
217
|
+
// Initialize stats if first interaction
|
|
218
|
+
if (!exchangeStats.has(exchange.name)) {
|
|
219
|
+
exchangeStats.set(exchange.name, {
|
|
220
|
+
exchange,
|
|
221
|
+
deposits: 0,
|
|
222
|
+
withdrawals: 0,
|
|
223
|
+
firstInteraction: tx.timestamp,
|
|
224
|
+
lastInteraction: tx.timestamp,
|
|
225
|
+
})
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const stats = exchangeStats.get(exchange.name)!
|
|
229
|
+
|
|
230
|
+
// Update timestamps
|
|
231
|
+
stats.firstInteraction = Math.min(stats.firstInteraction, tx.timestamp)
|
|
232
|
+
stats.lastInteraction = Math.max(stats.lastInteraction, tx.timestamp)
|
|
233
|
+
|
|
234
|
+
// Determine deposit vs withdrawal
|
|
235
|
+
if (tx.sender === walletAddress && tx.recipient === addr) {
|
|
236
|
+
// Wallet sent TO exchange = deposit
|
|
237
|
+
stats.deposits++
|
|
238
|
+
} else if (tx.sender === addr && tx.recipient === walletAddress) {
|
|
239
|
+
// Exchange sent TO wallet = withdrawal
|
|
240
|
+
stats.withdrawals++
|
|
241
|
+
} else if (tx.sender === walletAddress) {
|
|
242
|
+
// Wallet interacted with exchange (could be swap)
|
|
243
|
+
stats.deposits++
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Calculate totals
|
|
249
|
+
let totalDeposits = 0
|
|
250
|
+
let totalWithdrawals = 0
|
|
251
|
+
let cexCount = 0
|
|
252
|
+
let dexCount = 0
|
|
253
|
+
|
|
254
|
+
const exchangeResults: ExchangeExposureResult['exchanges'] = []
|
|
255
|
+
|
|
256
|
+
for (const [name, stats] of Array.from(exchangeStats.entries())) {
|
|
257
|
+
totalDeposits += stats.deposits
|
|
258
|
+
totalWithdrawals += stats.withdrawals
|
|
259
|
+
|
|
260
|
+
if (stats.exchange.type === 'cex') {
|
|
261
|
+
cexCount++
|
|
262
|
+
} else {
|
|
263
|
+
dexCount++
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
exchangeResults.push({
|
|
267
|
+
name,
|
|
268
|
+
type: stats.exchange.type,
|
|
269
|
+
kycRequired: stats.exchange.kycRequired,
|
|
270
|
+
deposits: stats.deposits,
|
|
271
|
+
withdrawals: stats.withdrawals,
|
|
272
|
+
firstInteraction: stats.firstInteraction,
|
|
273
|
+
lastInteraction: stats.lastInteraction,
|
|
274
|
+
})
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Sort by interaction count (most active first)
|
|
278
|
+
exchangeResults.sort(
|
|
279
|
+
(a, b) => b.deposits + b.withdrawals - (a.deposits + a.withdrawals)
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
// Calculate score deduction
|
|
283
|
+
const cexDeduction = cexCount * DEDUCTION_PER_CEX
|
|
284
|
+
const dexDeduction = dexCount * DEDUCTION_PER_DEX
|
|
285
|
+
const rawDeduction = cexDeduction + dexDeduction
|
|
286
|
+
const scoreDeduction = Math.min(rawDeduction, MAX_DEDUCTION)
|
|
287
|
+
|
|
288
|
+
return {
|
|
289
|
+
exchangeCount: exchangeStats.size,
|
|
290
|
+
depositCount: totalDeposits,
|
|
291
|
+
withdrawalCount: totalWithdrawals,
|
|
292
|
+
scoreDeduction,
|
|
293
|
+
exchanges: exchangeResults,
|
|
294
|
+
}
|
|
295
|
+
}
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Temporal Pattern Detection Algorithm
|
|
3
|
+
*
|
|
4
|
+
* Analyzes transaction timing patterns to detect:
|
|
5
|
+
* - Regular schedules (e.g., weekly DCA, monthly payments)
|
|
6
|
+
* - Timezone inference from activity hours
|
|
7
|
+
* - Activity bursts that might indicate specific events
|
|
8
|
+
*
|
|
9
|
+
* These patterns can be used to de-anonymize users.
|
|
10
|
+
*
|
|
11
|
+
* @packageDocumentation
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { AnalyzableTransaction, TemporalPatternResult } from '../types'
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Maximum score deduction for temporal patterns (out of 15)
|
|
18
|
+
*/
|
|
19
|
+
const MAX_DEDUCTION = 15
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Deduction per detected pattern
|
|
23
|
+
*/
|
|
24
|
+
const DEDUCTION_PER_PATTERN = 5
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Minimum transactions to detect patterns
|
|
28
|
+
*/
|
|
29
|
+
const MIN_TRANSACTIONS_FOR_PATTERN = 5
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Threshold for considering a day-of-week as "regular"
|
|
33
|
+
* (percentage of transactions on that day)
|
|
34
|
+
*/
|
|
35
|
+
const DAY_REGULARITY_THRESHOLD = 0.3
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Threshold for considering an hour as "regular"
|
|
39
|
+
*/
|
|
40
|
+
const HOUR_REGULARITY_THRESHOLD = 0.25
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Timezone offset possibilities (UTC offsets)
|
|
44
|
+
*/
|
|
45
|
+
const TIMEZONES: Record<string, number> = {
|
|
46
|
+
'UTC-12': -12,
|
|
47
|
+
'UTC-11': -11,
|
|
48
|
+
'HST': -10,
|
|
49
|
+
'AKST': -9,
|
|
50
|
+
'PST': -8,
|
|
51
|
+
'MST': -7,
|
|
52
|
+
'CST': -6,
|
|
53
|
+
'EST': -5,
|
|
54
|
+
'AST': -4,
|
|
55
|
+
'BRT': -3,
|
|
56
|
+
'UTC-2': -2,
|
|
57
|
+
'UTC-1': -1,
|
|
58
|
+
'UTC': 0,
|
|
59
|
+
'CET': 1,
|
|
60
|
+
'EET': 2,
|
|
61
|
+
'MSK': 3,
|
|
62
|
+
'GST': 4,
|
|
63
|
+
'PKT': 5,
|
|
64
|
+
'BST': 6,
|
|
65
|
+
'ICT': 7,
|
|
66
|
+
'CST_ASIA': 8,
|
|
67
|
+
'JST': 9,
|
|
68
|
+
'AEST': 10,
|
|
69
|
+
'AEDT': 11,
|
|
70
|
+
'NZST': 12,
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Analyze temporal patterns in transaction history
|
|
75
|
+
*
|
|
76
|
+
* @param transactions - Transaction history to analyze
|
|
77
|
+
* @returns Temporal pattern analysis result
|
|
78
|
+
*
|
|
79
|
+
* @example
|
|
80
|
+
* ```typescript
|
|
81
|
+
* const result = analyzeTemporalPatterns(transactions)
|
|
82
|
+
* console.log(result.patterns[0].type) // 'regular-schedule'
|
|
83
|
+
* console.log(result.inferredTimezone) // 'EST'
|
|
84
|
+
* ```
|
|
85
|
+
*/
|
|
86
|
+
export function analyzeTemporalPatterns(
|
|
87
|
+
transactions: AnalyzableTransaction[]
|
|
88
|
+
): TemporalPatternResult {
|
|
89
|
+
const patterns: TemporalPatternResult['patterns'] = []
|
|
90
|
+
|
|
91
|
+
if (transactions.length < MIN_TRANSACTIONS_FOR_PATTERN) {
|
|
92
|
+
return {
|
|
93
|
+
patterns: [],
|
|
94
|
+
scoreDeduction: 0,
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Extract timing data
|
|
99
|
+
const txTimes = transactions
|
|
100
|
+
.filter((tx) => tx.success && tx.timestamp > 0)
|
|
101
|
+
.map((tx) => new Date(tx.timestamp * 1000))
|
|
102
|
+
|
|
103
|
+
if (txTimes.length < MIN_TRANSACTIONS_FOR_PATTERN) {
|
|
104
|
+
return {
|
|
105
|
+
patterns: [],
|
|
106
|
+
scoreDeduction: 0,
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Analyze day-of-week distribution
|
|
111
|
+
const dayOfWeekPattern = analyzeDayOfWeekPattern(txTimes)
|
|
112
|
+
if (dayOfWeekPattern) {
|
|
113
|
+
patterns.push(dayOfWeekPattern)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Analyze hour-of-day distribution
|
|
117
|
+
const hourPattern = analyzeHourPattern(txTimes)
|
|
118
|
+
if (hourPattern) {
|
|
119
|
+
patterns.push(hourPattern)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Detect timezone from activity hours
|
|
123
|
+
const inferredTimezone = inferTimezone(txTimes)
|
|
124
|
+
|
|
125
|
+
// Detect activity bursts
|
|
126
|
+
const burstPattern = detectActivityBursts(transactions)
|
|
127
|
+
if (burstPattern) {
|
|
128
|
+
patterns.push(burstPattern)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Calculate score deduction
|
|
132
|
+
const rawDeduction = patterns.length * DEDUCTION_PER_PATTERN
|
|
133
|
+
const scoreDeduction = Math.min(rawDeduction, MAX_DEDUCTION)
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
patterns,
|
|
137
|
+
inferredTimezone,
|
|
138
|
+
scoreDeduction,
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Analyze day-of-week transaction patterns
|
|
144
|
+
*/
|
|
145
|
+
function analyzeDayOfWeekPattern(
|
|
146
|
+
times: Date[]
|
|
147
|
+
): TemporalPatternResult['patterns'][0] | null {
|
|
148
|
+
const dayCount = new Array(7).fill(0)
|
|
149
|
+
|
|
150
|
+
for (const time of times) {
|
|
151
|
+
dayCount[time.getUTCDay()]++
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Find dominant days
|
|
155
|
+
const total = times.length
|
|
156
|
+
const dominantDays: number[] = []
|
|
157
|
+
|
|
158
|
+
for (let day = 0; day < 7; day++) {
|
|
159
|
+
const percentage = dayCount[day] / total
|
|
160
|
+
if (percentage >= DAY_REGULARITY_THRESHOLD) {
|
|
161
|
+
dominantDays.push(day)
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (dominantDays.length === 0 || dominantDays.length > 3) {
|
|
166
|
+
return null // No clear pattern or too spread out
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const dayNames = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']
|
|
170
|
+
const dominantDayNames = dominantDays.map((d) => dayNames[d]).join(', ')
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
type: 'regular-schedule',
|
|
174
|
+
description: `Most transactions occur on ${dominantDayNames}`,
|
|
175
|
+
confidence: Math.max(...dominantDays.map((d) => dayCount[d] / total)),
|
|
176
|
+
evidence: {
|
|
177
|
+
dayOfWeek: dominantDays,
|
|
178
|
+
},
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Analyze hour-of-day transaction patterns
|
|
184
|
+
*/
|
|
185
|
+
function analyzeHourPattern(
|
|
186
|
+
times: Date[]
|
|
187
|
+
): TemporalPatternResult['patterns'][0] | null {
|
|
188
|
+
const hourCount = new Array(24).fill(0)
|
|
189
|
+
|
|
190
|
+
for (const time of times) {
|
|
191
|
+
hourCount[time.getUTCHours()]++
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Find active hours (group into 3-hour windows)
|
|
195
|
+
const total = times.length
|
|
196
|
+
const activeHours: number[] = []
|
|
197
|
+
|
|
198
|
+
for (let hour = 0; hour < 24; hour++) {
|
|
199
|
+
// Consider 3-hour windows
|
|
200
|
+
const windowCount =
|
|
201
|
+
hourCount[hour] +
|
|
202
|
+
hourCount[(hour + 1) % 24] +
|
|
203
|
+
hourCount[(hour + 2) % 24]
|
|
204
|
+
const windowPercentage = windowCount / total
|
|
205
|
+
|
|
206
|
+
if (windowPercentage >= HOUR_REGULARITY_THRESHOLD) {
|
|
207
|
+
if (!activeHours.includes(hour)) {
|
|
208
|
+
activeHours.push(hour)
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (activeHours.length === 0 || activeHours.length > 8) {
|
|
214
|
+
return null // No clear pattern
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Check if activity is concentrated (privacy risk)
|
|
218
|
+
const activeHourRange = Math.max(...activeHours) - Math.min(...activeHours)
|
|
219
|
+
|
|
220
|
+
if (activeHourRange <= 6) {
|
|
221
|
+
const startHour = Math.min(...activeHours)
|
|
222
|
+
const endHour = Math.max(...activeHours) + 2
|
|
223
|
+
|
|
224
|
+
return {
|
|
225
|
+
type: 'timezone-inference',
|
|
226
|
+
description: `Activity concentrated between ${startHour}:00-${endHour}:00 UTC`,
|
|
227
|
+
confidence: 0.7,
|
|
228
|
+
evidence: {
|
|
229
|
+
hourOfDay: activeHours,
|
|
230
|
+
},
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return null
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Infer timezone from activity patterns
|
|
239
|
+
* Assumes user is active during waking hours (8am-11pm local time)
|
|
240
|
+
*/
|
|
241
|
+
function inferTimezone(times: Date[]): string | undefined {
|
|
242
|
+
const hourCount = new Array(24).fill(0)
|
|
243
|
+
|
|
244
|
+
for (const time of times) {
|
|
245
|
+
hourCount[time.getUTCHours()]++
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Find the 8-hour window with most activity
|
|
249
|
+
let maxActivity = 0
|
|
250
|
+
let bestStartHour = 0
|
|
251
|
+
|
|
252
|
+
for (let start = 0; start < 24; start++) {
|
|
253
|
+
let windowActivity = 0
|
|
254
|
+
for (let i = 0; i < 8; i++) {
|
|
255
|
+
windowActivity += hourCount[(start + i) % 24]
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (windowActivity > maxActivity) {
|
|
259
|
+
maxActivity = windowActivity
|
|
260
|
+
bestStartHour = start
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// If activity is concentrated, infer timezone
|
|
265
|
+
const totalActivity = times.length
|
|
266
|
+
if (maxActivity / totalActivity < 0.6) {
|
|
267
|
+
return undefined // Activity too spread out
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Assume peak activity is around 10am-6pm local time
|
|
271
|
+
// So if peak starts at hour X UTC, user is probably at UTC+(14-X) or similar
|
|
272
|
+
const assumedLocalNoon = 12
|
|
273
|
+
const peakMidpoint = (bestStartHour + 4) % 24
|
|
274
|
+
const inferredOffset = (assumedLocalNoon - peakMidpoint + 24) % 24
|
|
275
|
+
|
|
276
|
+
// Find closest timezone
|
|
277
|
+
const normalizedOffset = inferredOffset > 12 ? inferredOffset - 24 : inferredOffset
|
|
278
|
+
|
|
279
|
+
for (const [name, offset] of Object.entries(TIMEZONES)) {
|
|
280
|
+
if (Math.abs(offset - normalizedOffset) <= 1) {
|
|
281
|
+
return name
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return `UTC${normalizedOffset >= 0 ? '+' : ''}${normalizedOffset}`
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Detect unusual activity bursts
|
|
290
|
+
*/
|
|
291
|
+
function detectActivityBursts(
|
|
292
|
+
transactions: AnalyzableTransaction[]
|
|
293
|
+
): TemporalPatternResult['patterns'][0] | null {
|
|
294
|
+
if (transactions.length < 10) {
|
|
295
|
+
return null
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Sort by timestamp
|
|
299
|
+
const sorted = [...transactions]
|
|
300
|
+
.filter((tx) => tx.timestamp > 0)
|
|
301
|
+
.sort((a, b) => a.timestamp - b.timestamp)
|
|
302
|
+
|
|
303
|
+
// Calculate gaps between transactions
|
|
304
|
+
const gaps: number[] = []
|
|
305
|
+
for (let i = 1; i < sorted.length; i++) {
|
|
306
|
+
gaps.push(sorted[i].timestamp - sorted[i - 1].timestamp)
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Calculate average gap
|
|
310
|
+
const avgGap = gaps.reduce((a, b) => a + b, 0) / gaps.length
|
|
311
|
+
|
|
312
|
+
// Find bursts (gaps < 10% of average)
|
|
313
|
+
const burstThreshold = avgGap * 0.1
|
|
314
|
+
let burstCount = 0
|
|
315
|
+
|
|
316
|
+
for (const gap of gaps) {
|
|
317
|
+
if (gap < burstThreshold && gap < 3600) {
|
|
318
|
+
// Less than 1 hour
|
|
319
|
+
burstCount++
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const burstPercentage = burstCount / gaps.length
|
|
324
|
+
|
|
325
|
+
if (burstPercentage > 0.2) {
|
|
326
|
+
return {
|
|
327
|
+
type: 'activity-burst',
|
|
328
|
+
description: `${Math.round(burstPercentage * 100)}% of transactions occur in rapid succession`,
|
|
329
|
+
confidence: burstPercentage,
|
|
330
|
+
evidence: {
|
|
331
|
+
frequency: `${burstCount} bursts detected`,
|
|
332
|
+
},
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
return null
|
|
337
|
+
}
|