kaccy_bitcoin/
adaptive_fees.rs

1//! Adaptive fee strategies for Bitcoin transactions
2//!
3//! Provides intelligent fee management based on user preferences, time constraints,
4//! and market conditions.
5
6use crate::client::BitcoinClient;
7use crate::error::BitcoinError;
8use chrono::{DateTime, Utc};
9use serde::{Deserialize, Serialize};
10
11/// User's urgency preference for transaction confirmation
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
13pub enum TransactionUrgency {
14    /// Low priority - can wait hours or days
15    Low,
16    /// Medium priority - should confirm within a few hours
17    Medium,
18    /// High priority - should confirm within 1-2 blocks
19    High,
20    /// Critical priority - must confirm ASAP
21    Critical,
22}
23
24impl TransactionUrgency {
25    /// Get target confirmation blocks for this urgency level
26    pub fn target_blocks(&self) -> u32 {
27        match self {
28            Self::Low => 144,    // ~1 day
29            Self::Medium => 6,   // ~1 hour
30            Self::High => 2,     // ~20 minutes
31            Self::Critical => 1, // Next block
32        }
33    }
34
35    /// Get multiplier for base fee rate
36    pub fn fee_multiplier(&self) -> f64 {
37        match self {
38            Self::Low => 1.0,
39            Self::Medium => 1.5,
40            Self::High => 2.0,
41            Self::Critical => 3.0,
42        }
43    }
44}
45
46/// Time-based fee adjustment strategy
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct TimeBasedStrategy {
49    /// Target confirmation time (UTC)
50    pub target_time: DateTime<Utc>,
51    /// Minimum acceptable fee rate (sat/vB)
52    pub min_fee_rate: f64,
53    /// Maximum acceptable fee rate (sat/vB)
54    pub max_fee_rate: f64,
55    /// Allow fee bumping if needed
56    pub allow_bumping: bool,
57}
58
59impl TimeBasedStrategy {
60    /// Create a new time-based strategy
61    pub fn new(target_time: DateTime<Utc>, min_fee_rate: f64, max_fee_rate: f64) -> Self {
62        Self {
63            target_time,
64            min_fee_rate,
65            max_fee_rate,
66            allow_bumping: true,
67        }
68    }
69
70    /// Calculate appropriate fee rate based on time remaining
71    pub fn calculate_fee_rate(&self, current_fee_rate: f64) -> f64 {
72        let now = Utc::now();
73        let time_remaining = self.target_time.signed_duration_since(now);
74
75        if time_remaining.num_seconds() <= 0 {
76            // Past deadline - use maximum fee
77            return self.max_fee_rate;
78        }
79
80        // Calculate urgency multiplier based on time remaining
81        let hours_remaining = time_remaining.num_hours() as f64;
82        let multiplier = if hours_remaining < 1.0 {
83            3.0 // Very urgent
84        } else if hours_remaining < 6.0 {
85            2.0 // Urgent
86        } else if hours_remaining < 24.0 {
87            1.5 // Moderate
88        } else {
89            1.0 // Not urgent
90        };
91
92        (current_fee_rate * multiplier)
93            .max(self.min_fee_rate)
94            .min(self.max_fee_rate)
95    }
96
97    /// Check if fee bumping is recommended
98    pub fn should_bump_fee(&self) -> bool {
99        if !self.allow_bumping {
100            return false;
101        }
102
103        let now = Utc::now();
104        let time_remaining = self.target_time.signed_duration_since(now);
105
106        // Recommend bumping if less than 2 hours remain
107        time_remaining.num_hours() < 2
108    }
109}
110
111/// Budget-constrained fee strategy
112#[derive(Debug, Clone, Serialize, Deserialize)]
113pub struct BudgetStrategy {
114    /// Maximum total fee in satoshis
115    pub max_fee_satoshis: u64,
116    /// Transaction size in virtual bytes
117    pub tx_vbytes: usize,
118    /// Minimum acceptable fee rate (sat/vB)
119    pub min_fee_rate: f64,
120}
121
122impl BudgetStrategy {
123    /// Create a new budget-constrained strategy
124    pub fn new(max_fee_satoshis: u64, tx_vbytes: usize) -> Self {
125        Self {
126            max_fee_satoshis,
127            tx_vbytes,
128            min_fee_rate: 1.0,
129        }
130    }
131
132    /// Calculate maximum affordable fee rate
133    pub fn max_fee_rate(&self) -> f64 {
134        self.max_fee_satoshis as f64 / self.tx_vbytes as f64
135    }
136
137    /// Calculate recommended fee rate within budget
138    pub fn calculate_fee_rate(&self, market_fee_rate: f64) -> Result<f64, BitcoinError> {
139        let max_rate = self.max_fee_rate();
140
141        if max_rate < self.min_fee_rate {
142            return Err(BitcoinError::InsufficientFunds(
143                "Budget too low for minimum fee rate".to_string(),
144            ));
145        }
146
147        Ok(market_fee_rate.min(max_rate).max(self.min_fee_rate))
148    }
149
150    /// Check if transaction fits within budget at given fee rate
151    pub fn fits_budget(&self, fee_rate: f64) -> bool {
152        let total_fee = (fee_rate * self.tx_vbytes as f64) as u64;
153        total_fee <= self.max_fee_satoshis
154    }
155}
156
157/// Multi-transaction fee planning
158#[derive(Debug, Clone, Serialize, Deserialize)]
159pub struct MultiTxFeeStrategy {
160    /// Total budget for all transactions (satoshis)
161    pub total_budget: u64,
162    /// List of planned transactions with sizes
163    pub transactions: Vec<PlannedTransaction>,
164    /// Global minimum fee rate (sat/vB)
165    pub min_fee_rate: f64,
166}
167
168/// A planned transaction in a multi-transaction batch
169#[derive(Debug, Clone, Serialize, Deserialize)]
170pub struct PlannedTransaction {
171    /// Transaction ID or label
172    pub id: String,
173    /// Transaction size in vbytes
174    pub vbytes: usize,
175    /// Priority level
176    pub urgency: TransactionUrgency,
177    /// Optional maximum fee for this transaction
178    pub max_fee: Option<u64>,
179}
180
181impl MultiTxFeeStrategy {
182    /// Create a new multi-transaction fee strategy
183    pub fn new(total_budget: u64, min_fee_rate: f64) -> Self {
184        Self {
185            total_budget,
186            transactions: Vec::new(),
187            min_fee_rate,
188        }
189    }
190
191    /// Add a transaction to the plan
192    pub fn add_transaction(&mut self, tx: PlannedTransaction) {
193        self.transactions.push(tx);
194    }
195
196    /// Calculate optimal fee allocation across all transactions
197    pub fn calculate_fee_allocation(
198        &self,
199        market_fee_rate: f64,
200    ) -> Result<Vec<TxFeeAllocation>, BitcoinError> {
201        if self.transactions.is_empty() {
202            return Ok(Vec::new());
203        }
204
205        // Calculate total size and priority-weighted size
206        let mut allocations = Vec::new();
207
208        // Calculate base allocation proportional to size and priority
209        let mut remaining_budget = self.total_budget;
210
211        for tx in &self.transactions {
212            let priority_multiplier = tx.urgency.fee_multiplier();
213            let base_fee = (tx.vbytes as f64 * market_fee_rate * priority_multiplier) as u64;
214
215            // Apply transaction-specific max fee if set
216            let allocated_fee = if let Some(max_fee) = tx.max_fee {
217                base_fee.min(max_fee)
218            } else {
219                base_fee
220            };
221
222            let allocated_fee = allocated_fee.min(remaining_budget);
223            remaining_budget = remaining_budget.saturating_sub(allocated_fee);
224
225            let fee_rate = allocated_fee as f64 / tx.vbytes as f64;
226
227            allocations.push(TxFeeAllocation {
228                tx_id: tx.id.clone(),
229                allocated_fee,
230                fee_rate,
231                vbytes: tx.vbytes,
232                urgency: tx.urgency,
233            });
234        }
235
236        // Check if minimum fee rate is met for all transactions
237        for alloc in &allocations {
238            if alloc.fee_rate < self.min_fee_rate {
239                return Err(BitcoinError::InsufficientFunds(format!(
240                    "Insufficient budget to meet minimum fee rate for tx: {}",
241                    alloc.tx_id
242                )));
243            }
244        }
245
246        Ok(allocations)
247    }
248
249    /// Calculate total cost at market rate
250    pub fn total_cost_at_rate(&self, fee_rate: f64) -> u64 {
251        self.transactions
252            .iter()
253            .map(|tx| {
254                let multiplier = tx.urgency.fee_multiplier();
255                (tx.vbytes as f64 * fee_rate * multiplier) as u64
256            })
257            .sum()
258    }
259}
260
261/// Fee allocation for a specific transaction
262#[derive(Debug, Clone, Serialize, Deserialize)]
263pub struct TxFeeAllocation {
264    /// Transaction ID
265    pub tx_id: String,
266    /// Allocated fee in satoshis
267    pub allocated_fee: u64,
268    /// Resulting fee rate (sat/vB)
269    pub fee_rate: f64,
270    /// Transaction size in vbytes
271    pub vbytes: usize,
272    /// Transaction urgency
273    pub urgency: TransactionUrgency,
274}
275
276/// Adaptive fee manager
277pub struct AdaptiveFeeManager {
278    client: BitcoinClient,
279}
280
281impl AdaptiveFeeManager {
282    /// Create a new adaptive fee manager
283    pub fn new(client: BitcoinClient) -> Self {
284        Self { client }
285    }
286
287    /// Get current market fee rate estimate
288    pub fn get_market_fee_rate(&self, target_blocks: u32) -> Result<f64, BitcoinError> {
289        let estimate = self.client.estimate_smart_fee(target_blocks as u16)?;
290        Ok(estimate.unwrap_or(1.0))
291    }
292
293    /// Calculate fee rate based on urgency
294    pub fn calculate_urgency_fee_rate(
295        &self,
296        urgency: TransactionUrgency,
297    ) -> Result<f64, BitcoinError> {
298        let target_blocks = urgency.target_blocks();
299        let market_rate = self.get_market_fee_rate(target_blocks)?;
300        let multiplier = urgency.fee_multiplier();
301        Ok(market_rate * multiplier)
302    }
303
304    /// Calculate fee rate for time-based strategy
305    pub fn calculate_time_based_fee_rate(
306        &self,
307        strategy: &TimeBasedStrategy,
308    ) -> Result<f64, BitcoinError> {
309        let market_rate = self.get_market_fee_rate(6)?;
310        Ok(strategy.calculate_fee_rate(market_rate))
311    }
312
313    /// Calculate fee rate for budget strategy
314    pub fn calculate_budget_fee_rate(
315        &self,
316        strategy: &BudgetStrategy,
317    ) -> Result<f64, BitcoinError> {
318        let market_rate = self.get_market_fee_rate(6)?;
319        strategy.calculate_fee_rate(market_rate)
320    }
321
322    /// Calculate fee allocation for multi-transaction strategy
323    pub fn calculate_multi_tx_allocation(
324        &self,
325        strategy: &MultiTxFeeStrategy,
326    ) -> Result<Vec<TxFeeAllocation>, BitcoinError> {
327        let market_rate = self.get_market_fee_rate(6)?;
328        strategy.calculate_fee_allocation(market_rate)
329    }
330
331    /// Get recommended fee rate with fallback logic
332    pub fn get_recommended_fee_rate(
333        &self,
334        urgency: TransactionUrgency,
335        max_fee_rate: Option<f64>,
336    ) -> Result<f64, BitcoinError> {
337        let mut fee_rate = self.calculate_urgency_fee_rate(urgency)?;
338
339        // Apply maximum cap if provided
340        if let Some(max_rate) = max_fee_rate {
341            fee_rate = fee_rate.min(max_rate);
342        }
343
344        // Ensure minimum of 1 sat/vB
345        Ok(fee_rate.max(1.0))
346    }
347}
348
349#[cfg(test)]
350mod tests {
351    use super::*;
352
353    #[test]
354    fn test_transaction_urgency_target_blocks() {
355        assert_eq!(TransactionUrgency::Low.target_blocks(), 144);
356        assert_eq!(TransactionUrgency::Medium.target_blocks(), 6);
357        assert_eq!(TransactionUrgency::High.target_blocks(), 2);
358        assert_eq!(TransactionUrgency::Critical.target_blocks(), 1);
359    }
360
361    #[test]
362    fn test_transaction_urgency_multiplier() {
363        assert_eq!(TransactionUrgency::Low.fee_multiplier(), 1.0);
364        assert_eq!(TransactionUrgency::Medium.fee_multiplier(), 1.5);
365        assert_eq!(TransactionUrgency::High.fee_multiplier(), 2.0);
366        assert_eq!(TransactionUrgency::Critical.fee_multiplier(), 3.0);
367    }
368
369    #[test]
370    fn test_time_based_strategy() {
371        let target_time = Utc::now() + chrono::Duration::hours(12);
372        let strategy = TimeBasedStrategy::new(target_time, 1.0, 100.0);
373
374        let fee_rate = strategy.calculate_fee_rate(10.0);
375        assert!(fee_rate >= 1.0);
376        assert!(fee_rate <= 100.0);
377    }
378
379    #[test]
380    fn test_budget_strategy() {
381        let strategy = BudgetStrategy::new(10_000, 200);
382
383        assert_eq!(strategy.max_fee_rate(), 50.0);
384        assert!(strategy.fits_budget(40.0));
385        assert!(!strategy.fits_budget(60.0));
386    }
387
388    #[test]
389    fn test_budget_strategy_calculate_fee_rate() {
390        let strategy = BudgetStrategy::new(5_000, 200);
391
392        // Market rate below max - should use market rate
393        let result = strategy.calculate_fee_rate(20.0);
394        assert!(result.is_ok());
395        assert_eq!(result.unwrap(), 20.0);
396
397        // Market rate above max - should cap at max
398        let result = strategy.calculate_fee_rate(30.0);
399        assert!(result.is_ok());
400        assert_eq!(result.unwrap(), 25.0); // max_fee_rate = 5000/200 = 25
401    }
402
403    #[test]
404    fn test_multi_tx_strategy() {
405        let mut strategy = MultiTxFeeStrategy::new(50_000, 1.0);
406
407        strategy.add_transaction(PlannedTransaction {
408            id: "tx1".to_string(),
409            vbytes: 200,
410            urgency: TransactionUrgency::High,
411            max_fee: None,
412        });
413
414        strategy.add_transaction(PlannedTransaction {
415            id: "tx2".to_string(),
416            vbytes: 150,
417            urgency: TransactionUrgency::Low,
418            max_fee: None,
419        });
420
421        let total_cost = strategy.total_cost_at_rate(10.0);
422        assert!(total_cost > 0);
423    }
424
425    #[test]
426    fn test_multi_tx_fee_allocation() {
427        let mut strategy = MultiTxFeeStrategy::new(10_000, 1.0);
428
429        strategy.add_transaction(PlannedTransaction {
430            id: "tx1".to_string(),
431            vbytes: 200,
432            urgency: TransactionUrgency::Medium,
433            max_fee: Some(4_000),
434        });
435
436        strategy.add_transaction(PlannedTransaction {
437            id: "tx2".to_string(),
438            vbytes: 200,
439            urgency: TransactionUrgency::Low,
440            max_fee: None,
441        });
442
443        let result = strategy.calculate_fee_allocation(10.0);
444        assert!(result.is_ok());
445
446        let allocations = result.unwrap();
447        assert_eq!(allocations.len(), 2);
448
449        let total_allocated: u64 = allocations.iter().map(|a| a.allocated_fee).sum();
450        assert!(total_allocated <= 10_000);
451    }
452
453    #[test]
454    fn test_planned_transaction() {
455        let tx = PlannedTransaction {
456            id: "test_tx".to_string(),
457            vbytes: 250,
458            urgency: TransactionUrgency::High,
459            max_fee: Some(10_000),
460        };
461
462        assert_eq!(tx.id, "test_tx");
463        assert_eq!(tx.vbytes, 250);
464        assert_eq!(tx.urgency, TransactionUrgency::High);
465        assert_eq!(tx.max_fee, Some(10_000));
466    }
467}