kaccy_bitcoin/
batch_optimizer.rs

1//! Batch Transaction Optimizer
2//!
3//! This module provides utilities for optimizing batch transactions to minimize
4//! fees and improve efficiency.
5
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8
9use crate::error::{BitcoinError, Result};
10
11/// Batch withdrawal request
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct BatchWithdrawal {
14    /// User ID
15    pub user_id: String,
16    /// Recipient address
17    pub address: String,
18    /// Amount in satoshis
19    pub amount_sats: u64,
20    /// Priority (higher = more important)
21    pub priority: u8,
22}
23
24/// Batch optimization strategy
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
26pub enum BatchStrategy {
27    /// Minimize transaction count
28    MinimizeTransactions,
29    /// Minimize total fees
30    MinimizeFees,
31    /// Balance between count and fees
32    Balanced,
33    /// Group by priority
34    Priority,
35}
36
37/// Optimized batch result
38#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct OptimizedBatch {
40    /// Batches of withdrawals
41    pub batches: Vec<Vec<BatchWithdrawal>>,
42    /// Estimated total fees in satoshis
43    pub estimated_total_fees: u64,
44    /// Estimated fee savings compared to individual transactions
45    pub estimated_savings: u64,
46    /// Number of transactions required
47    pub transaction_count: usize,
48}
49
50/// Batch optimizer
51///
52/// Optimizes batch withdrawals to minimize fees and transaction count.
53///
54/// # Examples
55///
56/// ```
57/// use kaccy_bitcoin::{BatchOptimizer, BatchWithdrawal, BatchStrategy};
58///
59/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
60/// let optimizer = BatchOptimizer::default();
61///
62/// let withdrawals = vec![
63///     BatchWithdrawal {
64///         user_id: "user1".to_string(),
65///         address: "bc1qaddr1".to_string(),
66///         amount_sats: 50_000,
67///         priority: 1,
68///     },
69///     BatchWithdrawal {
70///         user_id: "user2".to_string(),
71///         address: "bc1qaddr2".to_string(),
72///         amount_sats: 75_000,
73///         priority: 1,
74///     },
75/// ];
76///
77/// let optimized = optimizer.optimize(withdrawals, BatchStrategy::MinimizeFees)?;
78/// println!("Fee savings: {} sats", optimized.estimated_savings);
79/// # Ok(())
80/// # }
81/// ```
82pub struct BatchOptimizer {
83    /// Maximum outputs per transaction
84    max_outputs_per_tx: usize,
85    /// Minimum batch size
86    min_batch_size: usize,
87    /// Fee rate in sat/vB
88    fee_rate: f64,
89}
90
91impl Default for BatchOptimizer {
92    fn default() -> Self {
93        Self::new(100, 2, 10.0)
94    }
95}
96
97impl BatchOptimizer {
98    /// Create a new batch optimizer
99    ///
100    /// # Examples
101    ///
102    /// ```
103    /// use kaccy_bitcoin::BatchOptimizer;
104    ///
105    /// let optimizer = BatchOptimizer::new(
106    ///     100,  // max outputs per tx
107    ///     2,    // min batch size
108    ///     10.0, // fee rate sat/vB
109    /// );
110    /// ```
111    pub fn new(max_outputs_per_tx: usize, min_batch_size: usize, fee_rate: f64) -> Self {
112        Self {
113            max_outputs_per_tx,
114            min_batch_size,
115            fee_rate,
116        }
117    }
118
119    /// Optimize a batch of withdrawals
120    pub fn optimize(
121        &self,
122        mut withdrawals: Vec<BatchWithdrawal>,
123        strategy: BatchStrategy,
124    ) -> Result<OptimizedBatch> {
125        if withdrawals.is_empty() {
126            return Err(BitcoinError::InvalidTransaction(
127                "No withdrawals to batch".to_string(),
128            ));
129        }
130
131        // Sort based on strategy
132        match strategy {
133            BatchStrategy::MinimizeTransactions => {
134                // No specific sorting needed
135            }
136            BatchStrategy::MinimizeFees => {
137                // Group similar amounts together for better UTXO selection
138                withdrawals.sort_by_key(|w| w.amount_sats);
139            }
140            BatchStrategy::Balanced => {
141                // Sort by priority then amount
142                withdrawals.sort_by(|a, b| {
143                    b.priority
144                        .cmp(&a.priority)
145                        .then(a.amount_sats.cmp(&b.amount_sats))
146                });
147            }
148            BatchStrategy::Priority => {
149                // Sort by priority only
150                withdrawals.sort_by_key(|w| std::cmp::Reverse(w.priority));
151            }
152        }
153
154        let mut batches = Vec::new();
155        let mut current_batch = Vec::new();
156
157        for withdrawal in withdrawals {
158            current_batch.push(withdrawal);
159
160            // Create a batch when we reach max outputs
161            if current_batch.len() >= self.max_outputs_per_tx {
162                batches.push(current_batch.clone());
163                current_batch.clear();
164            }
165        }
166
167        // Add remaining withdrawals as final batch
168        if !current_batch.is_empty() {
169            // Check if the final batch meets minimum size, otherwise merge with previous
170            if current_batch.len() < self.min_batch_size && !batches.is_empty() {
171                if let Some(last_batch) = batches.last_mut() {
172                    last_batch.extend(current_batch);
173                }
174            } else {
175                batches.push(current_batch);
176            }
177        }
178
179        // Calculate fees
180        let estimated_total_fees = self.estimate_batch_fees(&batches);
181        let individual_fees = self.estimate_individual_fees(&batches);
182        let estimated_savings = individual_fees.saturating_sub(estimated_total_fees);
183
184        Ok(OptimizedBatch {
185            transaction_count: batches.len(),
186            batches,
187            estimated_total_fees,
188            estimated_savings,
189        })
190    }
191
192    /// Estimate fees for batched transactions
193    fn estimate_batch_fees(&self, batches: &[Vec<BatchWithdrawal>]) -> u64 {
194        batches
195            .iter()
196            .map(|batch| self.estimate_transaction_fee(batch.len(), 2))
197            .sum()
198    }
199
200    /// Estimate fees if all transactions were individual
201    fn estimate_individual_fees(&self, batches: &[Vec<BatchWithdrawal>]) -> u64 {
202        let total_withdrawals: usize = batches.iter().map(|b| b.len()).sum();
203        total_withdrawals as u64 * self.estimate_transaction_fee(1, 2)
204    }
205
206    /// Estimate fee for a single transaction
207    fn estimate_transaction_fee(&self, num_outputs: usize, num_inputs: usize) -> u64 {
208        // Approximate transaction size calculation
209        // Input: ~148 vbytes for P2WPKH (SegWit)
210        // Output: ~31 vbytes for P2WPKH
211        // Overhead: ~10.5 vbytes
212        let input_size = num_inputs as f64 * 68.0; // Witness data
213        let output_size = num_outputs as f64 * 31.0;
214        let overhead = 10.5;
215
216        let total_vsize = (input_size + output_size + overhead).ceil();
217        (total_vsize * self.fee_rate).ceil() as u64
218    }
219
220    /// Group withdrawals by user
221    pub fn group_by_user(
222        withdrawals: Vec<BatchWithdrawal>,
223    ) -> HashMap<String, Vec<BatchWithdrawal>> {
224        let mut groups: HashMap<String, Vec<BatchWithdrawal>> = HashMap::new();
225
226        for withdrawal in withdrawals {
227            groups
228                .entry(withdrawal.user_id.clone())
229                .or_default()
230                .push(withdrawal);
231        }
232
233        groups
234    }
235
236    /// Analyze batch efficiency
237    pub fn analyze_efficiency(&self, batches: &OptimizedBatch) -> BatchEfficiency {
238        let avg_batch_size = if batches.batches.is_empty() {
239            0.0
240        } else {
241            batches.batches.iter().map(|b| b.len()).sum::<usize>() as f64
242                / batches.batches.len() as f64
243        };
244
245        let fee_per_withdrawal = if batches.batches.iter().map(|b| b.len()).sum::<usize>() == 0 {
246            0
247        } else {
248            batches.estimated_total_fees
249                / batches.batches.iter().map(|b| b.len()).sum::<usize>() as u64
250        };
251
252        let savings_percentage = if batches.estimated_total_fees > 0 {
253            (batches.estimated_savings as f64
254                / (batches.estimated_total_fees + batches.estimated_savings) as f64
255                * 100.0) as u32
256        } else {
257            0
258        };
259
260        BatchEfficiency {
261            avg_batch_size,
262            fee_per_withdrawal,
263            total_withdrawals: batches.batches.iter().map(|b| b.len()).sum(),
264            savings_percentage,
265        }
266    }
267}
268
269/// Batch efficiency metrics
270#[derive(Debug, Clone, Serialize, Deserialize)]
271pub struct BatchEfficiency {
272    /// Average batch size
273    pub avg_batch_size: f64,
274    /// Fee per withdrawal in satoshis
275    pub fee_per_withdrawal: u64,
276    /// Total number of withdrawals
277    pub total_withdrawals: usize,
278    /// Savings percentage compared to individual transactions
279    pub savings_percentage: u32,
280}
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285
286    #[test]
287    fn test_batch_optimizer_creation() {
288        let optimizer = BatchOptimizer::default();
289        assert_eq!(optimizer.max_outputs_per_tx, 100);
290        assert_eq!(optimizer.min_batch_size, 2);
291    }
292
293    #[test]
294    fn test_batch_withdrawal_grouping() {
295        let withdrawals = vec![
296            BatchWithdrawal {
297                user_id: "user1".to_string(),
298                address: "addr1".to_string(),
299                amount_sats: 100_000,
300                priority: 5,
301            },
302            BatchWithdrawal {
303                user_id: "user1".to_string(),
304                address: "addr2".to_string(),
305                amount_sats: 200_000,
306                priority: 5,
307            },
308            BatchWithdrawal {
309                user_id: "user2".to_string(),
310                address: "addr3".to_string(),
311                amount_sats: 150_000,
312                priority: 3,
313            },
314        ];
315
316        let groups = BatchOptimizer::group_by_user(withdrawals);
317        assert_eq!(groups.len(), 2);
318        assert_eq!(groups.get("user1").unwrap().len(), 2);
319        assert_eq!(groups.get("user2").unwrap().len(), 1);
320    }
321
322    #[test]
323    fn test_batch_optimization() {
324        let optimizer = BatchOptimizer::new(3, 2, 10.0);
325
326        let withdrawals = vec![
327            BatchWithdrawal {
328                user_id: "user1".to_string(),
329                address: "addr1".to_string(),
330                amount_sats: 100_000,
331                priority: 5,
332            },
333            BatchWithdrawal {
334                user_id: "user2".to_string(),
335                address: "addr2".to_string(),
336                amount_sats: 200_000,
337                priority: 3,
338            },
339            BatchWithdrawal {
340                user_id: "user3".to_string(),
341                address: "addr3".to_string(),
342                amount_sats: 150_000,
343                priority: 4,
344            },
345        ];
346
347        let result = optimizer.optimize(withdrawals, BatchStrategy::MinimizeFees);
348        assert!(result.is_ok());
349
350        let batch = result.unwrap();
351        assert_eq!(batch.transaction_count, 1);
352        assert!(batch.estimated_savings > 0);
353    }
354
355    #[test]
356    fn test_efficiency_analysis() {
357        let optimizer = BatchOptimizer::default();
358
359        let batch = OptimizedBatch {
360            batches: vec![vec![
361                BatchWithdrawal {
362                    user_id: "user1".to_string(),
363                    address: "addr1".to_string(),
364                    amount_sats: 100_000,
365                    priority: 5,
366                },
367                BatchWithdrawal {
368                    user_id: "user2".to_string(),
369                    address: "addr2".to_string(),
370                    amount_sats: 200_000,
371                    priority: 3,
372                },
373            ]],
374            estimated_total_fees: 5_000,
375            estimated_savings: 3_000,
376            transaction_count: 1,
377        };
378
379        let efficiency = optimizer.analyze_efficiency(&batch);
380        assert_eq!(efficiency.total_withdrawals, 2);
381        assert_eq!(efficiency.avg_batch_size, 2.0);
382        assert!(efficiency.savings_percentage > 0);
383    }
384}