1use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8
9use crate::error::{BitcoinError, Result};
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct BatchWithdrawal {
14 pub user_id: String,
16 pub address: String,
18 pub amount_sats: u64,
20 pub priority: u8,
22}
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
26pub enum BatchStrategy {
27 MinimizeTransactions,
29 MinimizeFees,
31 Balanced,
33 Priority,
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct OptimizedBatch {
40 pub batches: Vec<Vec<BatchWithdrawal>>,
42 pub estimated_total_fees: u64,
44 pub estimated_savings: u64,
46 pub transaction_count: usize,
48}
49
50pub struct BatchOptimizer {
83 max_outputs_per_tx: usize,
85 min_batch_size: usize,
87 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 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 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 match strategy {
133 BatchStrategy::MinimizeTransactions => {
134 }
136 BatchStrategy::MinimizeFees => {
137 withdrawals.sort_by_key(|w| w.amount_sats);
139 }
140 BatchStrategy::Balanced => {
141 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 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 if current_batch.len() >= self.max_outputs_per_tx {
162 batches.push(current_batch.clone());
163 current_batch.clear();
164 }
165 }
166
167 if !current_batch.is_empty() {
169 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 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 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 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 fn estimate_transaction_fee(&self, num_outputs: usize, num_inputs: usize) -> u64 {
208 let input_size = num_inputs as f64 * 68.0; 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 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
271pub struct BatchEfficiency {
272 pub avg_batch_size: f64,
274 pub fee_per_withdrawal: u64,
276 pub total_withdrawals: usize,
278 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}