1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct ParsedTransaction {
17 pub txid: String,
19 pub version: i32,
21 pub total_input_sats: Option<u64>,
23 pub total_output_sats: u64,
25 pub fee_sats: Option<u64>,
27 pub fee_rate: Option<f64>,
29 pub vsize: u64,
31 pub weight: u64,
33 pub is_rbf: bool,
35 pub is_segwit: bool,
37 pub inputs: Vec<ParsedInput>,
39 pub outputs: Vec<ParsedOutput>,
41 pub confirmations: u32,
43 pub block_hash: Option<String>,
45 pub block_time: Option<u64>,
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct ParsedInput {
52 pub prev_txid: String,
54 pub prev_vout: u32,
56 pub sender_address: Option<String>,
58 pub value_sats: Option<u64>,
60 pub sequence: u32,
62 pub signals_rbf: bool,
64}
65
66#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct ParsedOutput {
69 pub index: u32,
71 pub address: Option<String>,
73 pub value_sats: u64,
75 pub script_type: ScriptType,
77}
78
79#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
81pub enum ScriptType {
82 P2pkh,
84 P2sh,
86 P2wpkh,
88 P2wsh,
90 P2tr,
92 OpReturn,
94 Unknown,
96}
97
98impl ScriptType {
99 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#[derive(Debug, Clone, Serialize, Deserialize)]
119pub struct SenderInfo {
120 pub primary_address: Option<String>,
122 pub all_addresses: Vec<String>,
124 pub address_amounts: HashMap<String, u64>,
126 pub confidence: SenderConfidence,
128}
129
130#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
132pub enum SenderConfidence {
133 High,
135 Medium,
137 Low,
139 Unknown,
141}
142
143pub struct TransactionParser {
145 client: Arc<BitcoinClient>,
146 #[allow(dead_code)]
148 cache: HashMap<String, ParsedTransaction>,
149}
150
151impl TransactionParser {
152 pub fn new(client: Arc<BitcoinClient>) -> Self {
154 Self {
155 client,
156 cache: HashMap::new(),
157 }
158 }
159
160 pub fn parse_transaction(&self, txid: &Txid) -> Result<ParsedTransaction> {
162 let raw_tx = self.client.get_raw_transaction(txid)?;
164
165 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 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 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 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 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 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 let is_rbf = inputs.iter().any(|i| i.signals_rbf);
253
254 let is_segwit = raw_tx.vin.iter().any(|v| v.txinwitness.is_some());
256
257 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 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 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 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 pub fn get_refund_address(&self, txid: &Txid) -> Result<Option<String>> {
340 let sender_info = self.get_sender_info(txid)?;
341
342 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 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 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 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 if parsed.is_rbf {
397 flags.push("Transaction signals RBF (can be replaced)".to_string());
398 }
399
400 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 let dust_threshold = 546; 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 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#[derive(Debug, Clone, Serialize)]
446pub struct TransactionAnalysis {
447 pub txid: String,
449 pub parsed: ParsedTransaction,
451 pub warnings: Vec<String>,
453 pub flags: Vec<String>,
455 pub confirmation_status: ConfirmationStatus,
457 pub is_safe_for_credit: bool,
459}
460
461#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
463pub enum ConfirmationStatus {
464 Unconfirmed,
466 LowConfirmations,
468 MediumConfirmations,
470 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 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}