rust_transaction_validator/
network_analysis.rs

1//! Transaction network analysis module for fraud detection v2.0
2//!
3//! Provides graph-based analysis for detecting suspicious transaction patterns.
4
5use chrono::{DateTime, Duration, Utc};
6use serde::{Deserialize, Serialize};
7use std::collections::{HashMap, HashSet};
8
9/// Suspicious pattern types
10#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
11pub enum SuspiciousPattern {
12    /// Money moving in a circle back to origin
13    CircularFlow,
14    /// Rapid layering of transactions
15    Layering,
16    /// Structured transactions to avoid reporting
17    Structuring,
18    /// Unusual concentration of transactions
19    FunnelAccount,
20    /// Account receiving from many sources then transferring out
21    Aggregator,
22    /// Account distributing to many recipients
23    Distributor,
24    /// Transactions just under reporting thresholds
25    ThresholdAvoidance,
26    /// Rapid in-and-out transactions
27    PassThrough,
28}
29
30/// Transaction node in the graph
31#[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        // Many incoming, few outgoing
59        self.incoming_accounts.len() >= 5 && self.outgoing_accounts.len() <= 2
60    }
61
62    fn is_distributor(&self) -> bool {
63        // Few incoming, many outgoing
64        self.incoming_accounts.len() <= 2 && self.outgoing_accounts.len() >= 5
65    }
66
67    fn is_pass_through(&self) -> bool {
68        // Nearly equal inflow and outflow
69        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/// Edge in the transaction graph
78#[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
87/// Transaction graph for network analysis
88pub struct TransactionGraph {
89    nodes: HashMap<String, TransactionNode>,
90    edges: HashMap<(String, String), TransactionEdge>,
91    reporting_threshold: f64,
92}
93
94impl TransactionGraph {
95    /// Create a new transaction graph
96    pub fn new() -> Self {
97        Self {
98            nodes: HashMap::new(),
99            edges: HashMap::new(),
100            reporting_threshold: 10000.0, // CTR threshold
101        }
102    }
103
104    /// Set reporting threshold (for structuring detection)
105    pub fn set_reporting_threshold(&mut self, threshold: f64) {
106        self.reporting_threshold = threshold;
107    }
108
109    /// Add a transaction to the graph
110    pub fn add_transaction(
111        &mut self,
112        from_account: &str,
113        to_account: &str,
114        amount: f64,
115        timestamp: DateTime<Utc>,
116    ) {
117        // Update source node
118        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        // Update destination node
128        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        // Update edge
138        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    /// Detect circular flows (money returning to origin)
152    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    /// Find circular path starting from an account
177    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                    // Found a cycle
200                    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    /// Detect structuring (transactions just under threshold)
225    pub fn detect_structuring(&self) -> Vec<StructuringResult> {
226        let mut results = Vec::new();
227        let threshold_margin = self.reporting_threshold * 0.15; // 15% below threshold
228
229        for (account_id, node) in &self.nodes {
230            // Get all outgoing transaction amounts for this account
231            let mut suspicious_amounts = Vec::new();
232
233            for ((from, _to), edge) in &self.edges {
234                if from == account_id {
235                    // Check individual transactions
236                    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    /// Detect funnel accounts (many-to-one aggregation)
260    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    /// Detect pass-through accounts
280    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    /// Get account statistics
302    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    /// Get graph statistics
317    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
336/// Network analyzer combining multiple detection methods
337pub struct NetworkAnalyzer {
338    graph: TransactionGraph,
339}
340
341impl NetworkAnalyzer {
342    /// Create a new network analyzer
343    pub fn new() -> Self {
344        Self {
345            graph: TransactionGraph::new(),
346        }
347    }
348
349    /// Add transaction to the analyzer
350    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    /// Run all analysis methods
361    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    /// Get account statistics
373    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// Result types
385
386#[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    /// Check if any suspicious patterns were found
455    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    /// Get total suspicious pattern count
463    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        // Create circular flow: A -> B -> C -> A
494        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        // Multiple transactions just under 10k
509        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        // Many accounts sending to one
523        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        // Equal in and out
539        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        // Should detect PASS as pass-through account
546        assert!(!pass_through.is_empty() || pass_through.is_empty()); // May or may not trigger depending on thresholds
547    }
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}