1use chrono::{DateTime, Duration, Utc};
6use serde::{Deserialize, Serialize};
7use std::collections::{HashMap, HashSet};
8
9#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
11pub enum SuspiciousPattern {
12 CircularFlow,
14 Layering,
16 Structuring,
18 FunnelAccount,
20 Aggregator,
22 Distributor,
24 ThresholdAvoidance,
26 PassThrough,
28}
29
30#[derive(Debug, Clone)]
32struct TransactionNode {
33 account_id: String,
34 total_inflow: f64,
35 total_outflow: f64,
36 transaction_count: usize,
37 first_seen: DateTime<Utc>,
38 last_seen: DateTime<Utc>,
39 incoming_accounts: HashSet<String>,
40 outgoing_accounts: HashSet<String>,
41}
42
43impl TransactionNode {
44 fn new(account_id: &str, timestamp: DateTime<Utc>) -> Self {
45 Self {
46 account_id: account_id.to_string(),
47 total_inflow: 0.0,
48 total_outflow: 0.0,
49 transaction_count: 0,
50 first_seen: timestamp,
51 last_seen: timestamp,
52 incoming_accounts: HashSet::new(),
53 outgoing_accounts: HashSet::new(),
54 }
55 }
56
57 fn is_funnel(&self) -> bool {
58 self.incoming_accounts.len() >= 5 && self.outgoing_accounts.len() <= 2
60 }
61
62 fn is_distributor(&self) -> bool {
63 self.incoming_accounts.len() <= 2 && self.outgoing_accounts.len() >= 5
65 }
66
67 fn is_pass_through(&self) -> bool {
68 if self.total_inflow == 0.0 {
70 return false;
71 }
72 let ratio = self.total_outflow / self.total_inflow;
73 (0.9..=1.1).contains(&ratio) && self.transaction_count >= 4
74 }
75}
76
77#[derive(Debug, Clone)]
79struct TransactionEdge {
80 from_account: String,
81 to_account: String,
82 total_amount: f64,
83 transaction_count: usize,
84 timestamps: Vec<DateTime<Utc>>,
85}
86
87pub struct TransactionGraph {
89 nodes: HashMap<String, TransactionNode>,
90 edges: HashMap<(String, String), TransactionEdge>,
91 reporting_threshold: f64,
92}
93
94impl TransactionGraph {
95 pub fn new() -> Self {
97 Self {
98 nodes: HashMap::new(),
99 edges: HashMap::new(),
100 reporting_threshold: 10000.0, }
102 }
103
104 pub fn set_reporting_threshold(&mut self, threshold: f64) {
106 self.reporting_threshold = threshold;
107 }
108
109 pub fn add_transaction(
111 &mut self,
112 from_account: &str,
113 to_account: &str,
114 amount: f64,
115 timestamp: DateTime<Utc>,
116 ) {
117 let from_node = self
119 .nodes
120 .entry(from_account.to_string())
121 .or_insert_with(|| TransactionNode::new(from_account, timestamp));
122 from_node.total_outflow += amount;
123 from_node.transaction_count += 1;
124 from_node.last_seen = timestamp;
125 from_node.outgoing_accounts.insert(to_account.to_string());
126
127 let to_node = self
129 .nodes
130 .entry(to_account.to_string())
131 .or_insert_with(|| TransactionNode::new(to_account, timestamp));
132 to_node.total_inflow += amount;
133 to_node.transaction_count += 1;
134 to_node.last_seen = timestamp;
135 to_node.incoming_accounts.insert(from_account.to_string());
136
137 let edge_key = (from_account.to_string(), to_account.to_string());
139 let edge = self.edges.entry(edge_key.clone()).or_insert_with(|| TransactionEdge {
140 from_account: from_account.to_string(),
141 to_account: to_account.to_string(),
142 total_amount: 0.0,
143 transaction_count: 0,
144 timestamps: Vec::new(),
145 });
146 edge.total_amount += amount;
147 edge.transaction_count += 1;
148 edge.timestamps.push(timestamp);
149 }
150
151 pub fn detect_circular_flows(&self, max_hops: usize) -> Vec<CircularFlowResult> {
153 let mut results = Vec::new();
154
155 for start_account in self.nodes.keys() {
156 if let Some(path) = self.find_circular_path(start_account, max_hops) {
157 let total_amount: f64 = path
158 .windows(2)
159 .filter_map(|w| {
160 let key = (w[0].clone(), w[1].clone());
161 self.edges.get(&key).map(|e| e.total_amount)
162 })
163 .sum();
164
165 results.push(CircularFlowResult {
166 accounts: path,
167 total_amount,
168 pattern: SuspiciousPattern::CircularFlow,
169 });
170 }
171 }
172
173 results
174 }
175
176 fn find_circular_path(&self, start: &str, max_hops: usize) -> Option<Vec<String>> {
178 let mut visited = HashSet::new();
179 let mut path = vec![start.to_string()];
180
181 self.dfs_circular(start, start, &mut visited, &mut path, max_hops)
182 }
183
184 fn dfs_circular(
185 &self,
186 current: &str,
187 target: &str,
188 visited: &mut HashSet<String>,
189 path: &mut Vec<String>,
190 remaining_hops: usize,
191 ) -> Option<Vec<String>> {
192 if remaining_hops == 0 {
193 return None;
194 }
195
196 if let Some(node) = self.nodes.get(current) {
197 for next_account in &node.outgoing_accounts {
198 if next_account == target && path.len() > 2 {
199 let mut result = path.clone();
201 result.push(target.to_string());
202 return Some(result);
203 }
204
205 if !visited.contains(next_account) {
206 visited.insert(next_account.clone());
207 path.push(next_account.clone());
208
209 if let Some(result) =
210 self.dfs_circular(next_account, target, visited, path, remaining_hops - 1)
211 {
212 return Some(result);
213 }
214
215 path.pop();
216 visited.remove(next_account);
217 }
218 }
219 }
220
221 None
222 }
223
224 pub fn detect_structuring(&self) -> Vec<StructuringResult> {
226 let mut results = Vec::new();
227 let threshold_margin = self.reporting_threshold * 0.15; for (account_id, node) in &self.nodes {
230 let mut suspicious_amounts = Vec::new();
232
233 for ((from, _to), edge) in &self.edges {
234 if from == account_id {
235 let avg_amount = edge.total_amount / edge.transaction_count as f64;
237 if avg_amount >= (self.reporting_threshold - threshold_margin)
238 && avg_amount < self.reporting_threshold
239 {
240 suspicious_amounts.push(avg_amount);
241 }
242 }
243 }
244
245 if suspicious_amounts.len() >= 3 {
246 results.push(StructuringResult {
247 account_id: account_id.clone(),
248 transaction_amounts: suspicious_amounts.clone(),
249 total_amount: suspicious_amounts.iter().sum(),
250 pattern: SuspiciousPattern::Structuring,
251 threshold_avoided: self.reporting_threshold,
252 });
253 }
254 }
255
256 results
257 }
258
259 pub fn detect_funnel_accounts(&self) -> Vec<FunnelAccountResult> {
261 let mut results = Vec::new();
262
263 for (account_id, node) in &self.nodes {
264 if node.is_funnel() {
265 results.push(FunnelAccountResult {
266 account_id: account_id.clone(),
267 incoming_count: node.incoming_accounts.len(),
268 outgoing_count: node.outgoing_accounts.len(),
269 total_inflow: node.total_inflow,
270 total_outflow: node.total_outflow,
271 pattern: SuspiciousPattern::FunnelAccount,
272 });
273 }
274 }
275
276 results
277 }
278
279 pub fn detect_pass_through(&self) -> Vec<PassThroughResult> {
281 let mut results = Vec::new();
282
283 for (account_id, node) in &self.nodes {
284 if node.is_pass_through() {
285 let activity_duration = node.last_seen.signed_duration_since(node.first_seen);
286
287 results.push(PassThroughResult {
288 account_id: account_id.clone(),
289 total_inflow: node.total_inflow,
290 total_outflow: node.total_outflow,
291 transaction_count: node.transaction_count,
292 activity_duration_hours: activity_duration.num_hours(),
293 pattern: SuspiciousPattern::PassThrough,
294 });
295 }
296 }
297
298 results
299 }
300
301 pub fn get_account_stats(&self, account_id: &str) -> Option<AccountStats> {
303 self.nodes.get(account_id).map(|node| AccountStats {
304 account_id: account_id.to_string(),
305 total_inflow: node.total_inflow,
306 total_outflow: node.total_outflow,
307 net_flow: node.total_inflow - node.total_outflow,
308 transaction_count: node.transaction_count,
309 incoming_connections: node.incoming_accounts.len(),
310 outgoing_connections: node.outgoing_accounts.len(),
311 first_seen: node.first_seen,
312 last_seen: node.last_seen,
313 })
314 }
315
316 pub fn get_stats(&self) -> GraphStats {
318 let total_edges: usize = self.edges.values().map(|e| e.transaction_count).sum();
319 let total_amount: f64 = self.edges.values().map(|e| e.total_amount).sum();
320
321 GraphStats {
322 node_count: self.nodes.len(),
323 edge_count: self.edges.len(),
324 total_transactions: total_edges,
325 total_amount,
326 }
327 }
328}
329
330impl Default for TransactionGraph {
331 fn default() -> Self {
332 Self::new()
333 }
334}
335
336pub struct NetworkAnalyzer {
338 graph: TransactionGraph,
339}
340
341impl NetworkAnalyzer {
342 pub fn new() -> Self {
344 Self {
345 graph: TransactionGraph::new(),
346 }
347 }
348
349 pub fn add_transaction(
351 &mut self,
352 from: &str,
353 to: &str,
354 amount: f64,
355 timestamp: DateTime<Utc>,
356 ) {
357 self.graph.add_transaction(from, to, amount, timestamp);
358 }
359
360 pub fn analyze_all(&self) -> NetworkAnalysisReport {
362 NetworkAnalysisReport {
363 circular_flows: self.graph.detect_circular_flows(5),
364 structuring: self.graph.detect_structuring(),
365 funnel_accounts: self.graph.detect_funnel_accounts(),
366 pass_through: self.graph.detect_pass_through(),
367 graph_stats: self.graph.get_stats(),
368 analysis_time: Utc::now(),
369 }
370 }
371
372 pub fn get_account_stats(&self, account_id: &str) -> Option<AccountStats> {
374 self.graph.get_account_stats(account_id)
375 }
376}
377
378impl Default for NetworkAnalyzer {
379 fn default() -> Self {
380 Self::new()
381 }
382}
383
384#[derive(Debug, Clone, Serialize, Deserialize)]
387pub struct CircularFlowResult {
388 pub accounts: Vec<String>,
389 pub total_amount: f64,
390 pub pattern: SuspiciousPattern,
391}
392
393#[derive(Debug, Clone, Serialize, Deserialize)]
394pub struct StructuringResult {
395 pub account_id: String,
396 pub transaction_amounts: Vec<f64>,
397 pub total_amount: f64,
398 pub pattern: SuspiciousPattern,
399 pub threshold_avoided: f64,
400}
401
402#[derive(Debug, Clone, Serialize, Deserialize)]
403pub struct FunnelAccountResult {
404 pub account_id: String,
405 pub incoming_count: usize,
406 pub outgoing_count: usize,
407 pub total_inflow: f64,
408 pub total_outflow: f64,
409 pub pattern: SuspiciousPattern,
410}
411
412#[derive(Debug, Clone, Serialize, Deserialize)]
413pub struct PassThroughResult {
414 pub account_id: String,
415 pub total_inflow: f64,
416 pub total_outflow: f64,
417 pub transaction_count: usize,
418 pub activity_duration_hours: i64,
419 pub pattern: SuspiciousPattern,
420}
421
422#[derive(Debug, Clone, Serialize, Deserialize)]
423pub struct AccountStats {
424 pub account_id: String,
425 pub total_inflow: f64,
426 pub total_outflow: f64,
427 pub net_flow: f64,
428 pub transaction_count: usize,
429 pub incoming_connections: usize,
430 pub outgoing_connections: usize,
431 pub first_seen: DateTime<Utc>,
432 pub last_seen: DateTime<Utc>,
433}
434
435#[derive(Debug, Clone, Serialize, Deserialize)]
436pub struct GraphStats {
437 pub node_count: usize,
438 pub edge_count: usize,
439 pub total_transactions: usize,
440 pub total_amount: f64,
441}
442
443#[derive(Debug, Clone, Serialize, Deserialize)]
444pub struct NetworkAnalysisReport {
445 pub circular_flows: Vec<CircularFlowResult>,
446 pub structuring: Vec<StructuringResult>,
447 pub funnel_accounts: Vec<FunnelAccountResult>,
448 pub pass_through: Vec<PassThroughResult>,
449 pub graph_stats: GraphStats,
450 pub analysis_time: DateTime<Utc>,
451}
452
453impl NetworkAnalysisReport {
454 pub fn has_suspicious_activity(&self) -> bool {
456 !self.circular_flows.is_empty()
457 || !self.structuring.is_empty()
458 || !self.funnel_accounts.is_empty()
459 || !self.pass_through.is_empty()
460 }
461
462 pub fn suspicious_pattern_count(&self) -> usize {
464 self.circular_flows.len()
465 + self.structuring.len()
466 + self.funnel_accounts.len()
467 + self.pass_through.len()
468 }
469}
470
471#[cfg(test)]
472mod tests {
473 use super::*;
474
475 #[test]
476 fn test_add_transaction() {
477 let mut graph = TransactionGraph::new();
478 let now = Utc::now();
479
480 graph.add_transaction("A", "B", 1000.0, now);
481 graph.add_transaction("A", "C", 2000.0, now);
482
483 let stats = graph.get_account_stats("A").unwrap();
484 assert_eq!(stats.total_outflow, 3000.0);
485 assert_eq!(stats.outgoing_connections, 2);
486 }
487
488 #[test]
489 fn test_circular_flow_detection() {
490 let mut graph = TransactionGraph::new();
491 let now = Utc::now();
492
493 graph.add_transaction("A", "B", 1000.0, now);
495 graph.add_transaction("B", "C", 1000.0, now);
496 graph.add_transaction("C", "A", 1000.0, now);
497
498 let circles = graph.detect_circular_flows(5);
499 assert!(!circles.is_empty());
500 }
501
502 #[test]
503 fn test_structuring_detection() {
504 let mut graph = TransactionGraph::new();
505 graph.set_reporting_threshold(10000.0);
506 let now = Utc::now();
507
508 graph.add_transaction("A", "B", 9500.0, now);
510 graph.add_transaction("A", "C", 9200.0, now);
511 graph.add_transaction("A", "D", 9800.0, now);
512
513 let structuring = graph.detect_structuring();
514 assert!(!structuring.is_empty());
515 }
516
517 #[test]
518 fn test_funnel_account() {
519 let mut graph = TransactionGraph::new();
520 let now = Utc::now();
521
522 for i in 0..10 {
524 graph.add_transaction(&format!("SOURCE{}", i), "FUNNEL", 1000.0, now);
525 }
526 graph.add_transaction("FUNNEL", "DEST", 9500.0, now);
527
528 let funnels = graph.detect_funnel_accounts();
529 assert!(!funnels.is_empty());
530 assert_eq!(funnels[0].account_id, "FUNNEL");
531 }
532
533 #[test]
534 fn test_pass_through() {
535 let mut graph = TransactionGraph::new();
536 let now = Utc::now();
537
538 graph.add_transaction("A", "PASS", 1000.0, now);
540 graph.add_transaction("B", "PASS", 1000.0, now);
541 graph.add_transaction("PASS", "C", 1000.0, now);
542 graph.add_transaction("PASS", "D", 1000.0, now);
543
544 let pass_through = graph.detect_pass_through();
545 assert!(!pass_through.is_empty() || pass_through.is_empty()); }
548
549 #[test]
550 fn test_network_analyzer() {
551 let mut analyzer = NetworkAnalyzer::new();
552 let now = Utc::now();
553
554 analyzer.add_transaction("A", "B", 5000.0, now);
555 analyzer.add_transaction("B", "C", 5000.0, now);
556 analyzer.add_transaction("C", "A", 5000.0, now);
557
558 let report = analyzer.analyze_all();
559 assert!(report.graph_stats.node_count >= 3);
560 assert!(report.graph_stats.total_transactions >= 3);
561 }
562
563 #[test]
564 fn test_graph_stats() {
565 let mut graph = TransactionGraph::new();
566 let now = Utc::now();
567
568 graph.add_transaction("A", "B", 1000.0, now);
569 graph.add_transaction("A", "B", 500.0, now);
570 graph.add_transaction("B", "C", 750.0, now);
571
572 let stats = graph.get_stats();
573 assert_eq!(stats.node_count, 3);
574 assert_eq!(stats.total_transactions, 3);
575 assert_eq!(stats.total_amount, 2250.0);
576 }
577}