kaccy_bitcoin/
tx_parser.rs

1//! Transaction parsing and sender extraction
2//!
3//! This module provides utilities for parsing Bitcoin transactions,
4//! extracting sender addresses (for refunds), and analyzing transaction details.
5
6use bitcoin::Txid;
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use std::sync::Arc;
10
11use crate::client::BitcoinClient;
12use crate::error::{BitcoinError, Result};
13
14/// Parsed transaction with extracted details
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct ParsedTransaction {
17    /// Transaction ID
18    pub txid: String,
19    /// Transaction version
20    pub version: i32,
21    /// Total input value in satoshis (if known)
22    pub total_input_sats: Option<u64>,
23    /// Total output value in satoshis
24    pub total_output_sats: u64,
25    /// Estimated fee in satoshis (if inputs are known)
26    pub fee_sats: Option<u64>,
27    /// Fee rate in sat/vB (if fee is known)
28    pub fee_rate: Option<f64>,
29    /// Virtual size in vbytes
30    pub vsize: u64,
31    /// Weight units
32    pub weight: u64,
33    /// Whether the transaction signals RBF
34    pub is_rbf: bool,
35    /// Whether this is a SegWit transaction
36    pub is_segwit: bool,
37    /// Parsed inputs with sender information
38    pub inputs: Vec<ParsedInput>,
39    /// Parsed outputs
40    pub outputs: Vec<ParsedOutput>,
41    /// Number of confirmations
42    pub confirmations: u32,
43    /// Block hash if confirmed
44    pub block_hash: Option<String>,
45    /// Block time if confirmed
46    pub block_time: Option<u64>,
47}
48
49/// Parsed transaction input
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct ParsedInput {
52    /// Previous transaction ID
53    pub prev_txid: String,
54    /// Previous output index
55    pub prev_vout: u32,
56    /// Sender address (extracted from previous output)
57    pub sender_address: Option<String>,
58    /// Value in satoshis (from previous output)
59    pub value_sats: Option<u64>,
60    /// Sequence number (for RBF detection)
61    pub sequence: u32,
62    /// Whether this input signals RBF
63    pub signals_rbf: bool,
64}
65
66/// Parsed transaction output
67#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct ParsedOutput {
69    /// Output index
70    pub index: u32,
71    /// Recipient address (if standard script)
72    pub address: Option<String>,
73    /// Value in satoshis
74    pub value_sats: u64,
75    /// Script type
76    pub script_type: ScriptType,
77}
78
79/// Bitcoin script types
80#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
81pub enum ScriptType {
82    /// Pay to Public Key Hash (legacy)
83    P2pkh,
84    /// Pay to Script Hash
85    P2sh,
86    /// Pay to Witness Public Key Hash (native SegWit)
87    P2wpkh,
88    /// Pay to Witness Script Hash
89    P2wsh,
90    /// Pay to Taproot
91    P2tr,
92    /// OP_RETURN (data output)
93    OpReturn,
94    /// Unknown or non-standard
95    Unknown,
96}
97
98impl ScriptType {
99    /// Determine script type from address prefix
100    pub fn from_address(address: &str) -> Self {
101        if address.starts_with("1") {
102            ScriptType::P2pkh
103        } else if address.starts_with("3") {
104            ScriptType::P2sh
105        } else if address.starts_with("bc1q") || address.starts_with("tb1q") {
106            ScriptType::P2wpkh
107        } else if address.starts_with("bc1p") || address.starts_with("tb1p") {
108            ScriptType::P2tr
109        } else if address.starts_with("bc1") || address.starts_with("tb1") {
110            ScriptType::P2wsh
111        } else {
112            ScriptType::Unknown
113        }
114    }
115}
116
117/// Sender information extracted from a transaction
118#[derive(Debug, Clone, Serialize, Deserialize)]
119pub struct SenderInfo {
120    /// Primary sender address (the most likely sender)
121    pub primary_address: Option<String>,
122    /// All unique sender addresses
123    pub all_addresses: Vec<String>,
124    /// Total amount sent by each address
125    pub address_amounts: HashMap<String, u64>,
126    /// Confidence level in sender identification
127    pub confidence: SenderConfidence,
128}
129
130/// Confidence level in sender identification
131#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
132pub enum SenderConfidence {
133    /// High confidence - single input or clear majority
134    High,
135    /// Medium confidence - multiple inputs but largest is clear
136    Medium,
137    /// Low confidence - many inputs, no clear primary
138    Low,
139    /// Unknown - could not determine sender
140    Unknown,
141}
142
143/// Transaction parser service
144pub struct TransactionParser {
145    client: Arc<BitcoinClient>,
146    /// Cache for parsed transactions
147    #[allow(dead_code)]
148    cache: HashMap<String, ParsedTransaction>,
149}
150
151impl TransactionParser {
152    /// Create a new transaction parser
153    pub fn new(client: Arc<BitcoinClient>) -> Self {
154        Self {
155            client,
156            cache: HashMap::new(),
157        }
158    }
159
160    /// Parse a transaction by its ID
161    pub fn parse_transaction(&self, txid: &Txid) -> Result<ParsedTransaction> {
162        // Get raw transaction details
163        let raw_tx = self.client.get_raw_transaction(txid)?;
164
165        // Parse inputs with previous output information
166        let mut inputs = Vec::with_capacity(raw_tx.vin.len());
167        let mut total_input_sats: u64 = 0;
168        let mut all_inputs_known = true;
169
170        for vin in &raw_tx.vin {
171            if let Some(prev_txid) = vin.txid {
172                let prev_vout = vin.vout.unwrap_or(0);
173                let sequence = vin.sequence;
174                let signals_rbf = sequence < 0xfffffffe;
175
176                // Try to get the previous transaction to find sender address and value
177                let (sender_address, value_sats) =
178                    match self.get_previous_output(&prev_txid, prev_vout) {
179                        Ok((addr, val)) => {
180                            total_input_sats += val;
181                            (addr, Some(val))
182                        }
183                        Err(_) => {
184                            all_inputs_known = false;
185                            (None, None)
186                        }
187                    };
188
189                inputs.push(ParsedInput {
190                    prev_txid: prev_txid.to_string(),
191                    prev_vout,
192                    sender_address,
193                    value_sats,
194                    sequence,
195                    signals_rbf,
196                });
197            } else {
198                // Coinbase transaction input
199                inputs.push(ParsedInput {
200                    prev_txid: String::from("coinbase"),
201                    prev_vout: 0,
202                    sender_address: None,
203                    value_sats: None,
204                    sequence: vin.sequence,
205                    signals_rbf: false,
206                });
207                all_inputs_known = false;
208            }
209        }
210
211        // Parse outputs
212        let mut outputs = Vec::with_capacity(raw_tx.vout.len());
213        let mut total_output_sats: u64 = 0;
214
215        for (index, vout) in raw_tx.vout.iter().enumerate() {
216            let value_sats = vout.value.to_sat();
217            total_output_sats += value_sats;
218
219            // Handle address conversion (NetworkUnchecked to string)
220            let address = vout
221                .script_pub_key
222                .address
223                .as_ref()
224                .map(|a| a.clone().assume_checked().to_string());
225            let script_type = if vout.script_pub_key.asm.starts_with("OP_RETURN") {
226                ScriptType::OpReturn
227            } else if let Some(ref addr) = address {
228                ScriptType::from_address(addr)
229            } else {
230                ScriptType::Unknown
231            };
232
233            outputs.push(ParsedOutput {
234                index: index as u32,
235                address,
236                value_sats,
237                script_type,
238            });
239        }
240
241        // Calculate fee and fee rate if all inputs are known
242        let fee_sats = if all_inputs_known {
243            Some(total_input_sats.saturating_sub(total_output_sats))
244        } else {
245            None
246        };
247
248        let vsize = raw_tx.vsize as u64;
249        let fee_rate = fee_sats.map(|fee| fee as f64 / vsize as f64);
250
251        // Check if transaction signals RBF
252        let is_rbf = inputs.iter().any(|i| i.signals_rbf);
253
254        // Check if SegWit
255        let is_segwit = raw_tx.vin.iter().any(|v| v.txinwitness.is_some());
256
257        // Calculate weight from vsize (weight = vsize * 4, approximate for segwit)
258        let weight = vsize * 4;
259
260        Ok(ParsedTransaction {
261            txid: txid.to_string(),
262            version: raw_tx.version as i32,
263            total_input_sats: if all_inputs_known {
264                Some(total_input_sats)
265            } else {
266                None
267            },
268            total_output_sats,
269            fee_sats,
270            fee_rate,
271            vsize,
272            weight,
273            is_rbf,
274            is_segwit,
275            inputs,
276            outputs,
277            confirmations: raw_tx.confirmations.unwrap_or(0),
278            block_hash: raw_tx.blockhash.map(|h| h.to_string()),
279            block_time: raw_tx.blocktime.map(|t| t as u64),
280        })
281    }
282
283    /// Get the sender information from a transaction
284    pub fn get_sender_info(&self, txid: &Txid) -> Result<SenderInfo> {
285        let parsed = self.parse_transaction(txid)?;
286
287        let mut address_amounts: HashMap<String, u64> = HashMap::new();
288
289        for input in &parsed.inputs {
290            if let (Some(addr), Some(value)) = (&input.sender_address, input.value_sats) {
291                *address_amounts.entry(addr.clone()).or_insert(0) += value;
292            }
293        }
294
295        if address_amounts.is_empty() {
296            return Ok(SenderInfo {
297                primary_address: None,
298                all_addresses: Vec::new(),
299                address_amounts: HashMap::new(),
300                confidence: SenderConfidence::Unknown,
301            });
302        }
303
304        // Find the address with the highest contribution
305        let all_addresses: Vec<String> = address_amounts.keys().cloned().collect();
306        let total_value: u64 = address_amounts.values().sum();
307
308        let primary = address_amounts
309            .iter()
310            .max_by_key(|(_, v)| *v)
311            .map(|(a, _)| a.clone());
312
313        // Determine confidence based on input distribution
314        let confidence = if all_addresses.len() == 1 {
315            SenderConfidence::High
316        } else if let Some(ref primary_addr) = primary {
317            let primary_value = address_amounts.get(primary_addr).copied().unwrap_or(0);
318            let ratio = primary_value as f64 / total_value as f64;
319            if ratio >= 0.8 {
320                SenderConfidence::High
321            } else if ratio >= 0.5 {
322                SenderConfidence::Medium
323            } else {
324                SenderConfidence::Low
325            }
326        } else {
327            SenderConfidence::Unknown
328        };
329
330        Ok(SenderInfo {
331            primary_address: primary,
332            all_addresses,
333            address_amounts,
334            confidence,
335        })
336    }
337
338    /// Extract the most likely sender address for refund purposes
339    pub fn get_refund_address(&self, txid: &Txid) -> Result<Option<String>> {
340        let sender_info = self.get_sender_info(txid)?;
341
342        // Only return address if confidence is sufficient
343        match sender_info.confidence {
344            SenderConfidence::High | SenderConfidence::Medium => Ok(sender_info.primary_address),
345            _ => {
346                tracing::warn!(
347                    txid = %txid,
348                    confidence = ?sender_info.confidence,
349                    addresses = ?sender_info.all_addresses,
350                    "Low confidence in sender identification for refund"
351                );
352                Ok(sender_info.primary_address)
353            }
354        }
355    }
356
357    /// Get the previous output (address and value) for an input
358    fn get_previous_output(&self, txid: &Txid, vout: u32) -> Result<(Option<String>, u64)> {
359        let prev_tx = self.client.get_raw_transaction(txid)?;
360
361        let output = prev_tx
362            .vout
363            .get(vout as usize)
364            .ok_or_else(|| BitcoinError::UtxoNotFound {
365                txid: txid.to_string(),
366                vout,
367            })?;
368
369        let address = output
370            .script_pub_key
371            .address
372            .as_ref()
373            .map(|a| a.clone().assume_checked().to_string());
374        let value = output.value.to_sat();
375
376        Ok((address, value))
377    }
378
379    /// Analyze a transaction for potential issues
380    pub fn analyze_transaction(&self, txid: &Txid) -> Result<TransactionAnalysis> {
381        let parsed = self.parse_transaction(txid)?;
382
383        let mut warnings = Vec::new();
384        let mut flags = Vec::new();
385
386        // Check fee rate
387        if let Some(fee_rate) = parsed.fee_rate {
388            if fee_rate < 1.0 {
389                warnings.push("Very low fee rate (< 1 sat/vB), may not confirm".to_string());
390            } else if fee_rate > 100.0 {
391                flags.push("High fee rate (> 100 sat/vB)".to_string());
392            }
393        }
394
395        // Check for RBF
396        if parsed.is_rbf {
397            flags.push("Transaction signals RBF (can be replaced)".to_string());
398        }
399
400        // Check for OP_RETURN outputs
401        let op_return_count = parsed
402            .outputs
403            .iter()
404            .filter(|o| o.script_type == ScriptType::OpReturn)
405            .count();
406        if op_return_count > 0 {
407            flags.push(format!("Contains {} OP_RETURN output(s)", op_return_count));
408        }
409
410        // Check for dust outputs
411        let dust_threshold = 546; // Standard dust threshold for P2PKH
412        let dust_outputs = parsed
413            .outputs
414            .iter()
415            .filter(|o| o.value_sats < dust_threshold && o.script_type != ScriptType::OpReturn)
416            .count();
417        if dust_outputs > 0 {
418            warnings.push(format!("Contains {} dust output(s)", dust_outputs));
419        }
420
421        // Check confirmation status
422        let confirmation_status = if parsed.confirmations == 0 {
423            ConfirmationStatus::Unconfirmed
424        } else if parsed.confirmations < 3 {
425            ConfirmationStatus::LowConfirmations
426        } else if parsed.confirmations < 6 {
427            ConfirmationStatus::MediumConfirmations
428        } else {
429            ConfirmationStatus::FullyConfirmed
430        };
431
432        let txid_str = parsed.txid.clone();
433        Ok(TransactionAnalysis {
434            txid: txid_str,
435            parsed,
436            warnings,
437            flags,
438            confirmation_status,
439            is_safe_for_credit: confirmation_status == ConfirmationStatus::FullyConfirmed,
440        })
441    }
442}
443
444/// Transaction analysis result
445#[derive(Debug, Clone, Serialize)]
446pub struct TransactionAnalysis {
447    /// Transaction ID
448    pub txid: String,
449    /// Parsed transaction details
450    pub parsed: ParsedTransaction,
451    /// Warnings about the transaction
452    pub warnings: Vec<String>,
453    /// Informational flags
454    pub flags: Vec<String>,
455    /// Confirmation status
456    pub confirmation_status: ConfirmationStatus,
457    /// Whether it's safe to credit this transaction
458    pub is_safe_for_credit: bool,
459}
460
461/// Confirmation status categories
462#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
463pub enum ConfirmationStatus {
464    /// Transaction is not yet confirmed
465    Unconfirmed,
466    /// 1-2 confirmations
467    LowConfirmations,
468    /// 3-5 confirmations
469    MediumConfirmations,
470    /// 6+ confirmations
471    FullyConfirmed,
472}
473
474#[cfg(test)]
475mod tests {
476    use super::*;
477
478    #[test]
479    fn test_script_type_detection() {
480        assert_eq!(
481            ScriptType::from_address("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa"),
482            ScriptType::P2pkh
483        );
484        assert_eq!(
485            ScriptType::from_address("3J98t1WpEZ73CNmQviecrnyiWrnqRhWNLy"),
486            ScriptType::P2sh
487        );
488        assert_eq!(
489            ScriptType::from_address("bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq"),
490            ScriptType::P2wpkh
491        );
492        assert_eq!(
493            ScriptType::from_address(
494                "bc1p0xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vqzk5jj0"
495            ),
496            ScriptType::P2tr
497        );
498    }
499
500    #[test]
501    fn test_sender_confidence() {
502        // Single address should have high confidence
503        let mut amounts = HashMap::new();
504        amounts.insert("addr1".to_string(), 100000);
505
506        let info = SenderInfo {
507            primary_address: Some("addr1".to_string()),
508            all_addresses: vec!["addr1".to_string()],
509            address_amounts: amounts,
510            confidence: SenderConfidence::High,
511        };
512
513        assert_eq!(info.confidence, SenderConfidence::High);
514    }
515}