diff --git a/src/main.rs b/src/main.rs index b9a9afd..72f6e97 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,7 +6,14 @@ use hoover::{ network::monitoring::{TxStatus, tx_status}, psbt::psbt_builder::{build_sweep_psbt, finalize_psbt, read_psbt_file, write_psbt_file}, redb::storage::Store, - utxo::utxo_parser::{DustReason, DustUtxo, Utxo}, + utxo::{ + context::{build_context, build_tx_cache}, + dust_policies::heuristic::{ + DustHeuristic, aggregate, change_address::ChangeAddressHeuristic, + exact_floor::ExactFloorPolicy, multi_address::MultiAddressHeuristic, + }, + utxo_parser::{DustReason, DustUtxo, Utxo}, + }, wallet_descriptor::wallet_parser::parse_descriptor, }; use std::path::PathBuf; @@ -108,10 +115,15 @@ fn list(config: Config, fingerprint: Option) { return; } - let mut all_dust: Vec = Vec::new(); + let tip_height = config.rpc_client.get_block_count().unwrap_or(0) as u32; + + let mut all_utxos: Vec = Vec::new(); + let mut descriptor_map: Vec<( + Utxo, + &hoover::wallet_descriptor::wallet_parser::ParsedDescriptor, + )> = Vec::new(); for descriptor in &descriptors { - // Skip descriptors that don't match the fingerprint filter if let Some(ref fp) = fingerprint && &descriptor.wallet_name != fp { @@ -120,14 +132,82 @@ fn list(config: Config, fingerprint: Option) { match Utxo::fetch_for_descriptor(&config.rpc_client, descriptor) { Ok(utxos) => { - let dust = Utxo::filter_dust_utxos(&utxos, config.dust_threshold); - all_dust.extend(dust); + for u in &utxos { + descriptor_map.push((u.clone(), descriptor)); + } + all_utxos.extend(utxos); } - Err(e) => { - eprintln!( - "Warning: failed to fetch UTXOs for {}: {e}", - descriptor.wallet_name - ); + Err(e) => eprintln!( + "Warning: failed to fetch UTXOs for {}: {e}", + descriptor.wallet_name + ), + } + } + + // Population 1: unconditional dust — below threshold, sweep regardless + let mut all_dust: Vec = Utxo::filter_dust_utxos(&all_utxos, config.dust_threshold); + + // Deduplicate by outpoint + let mut seen = std::collections::HashSet::new(); + all_dust.retain(|d| seen.insert(d.utxo.outpoint)); + + // Build tx cache covering all UTXOs — one getrawtransaction per unique txid + let tx_cache = build_tx_cache( + &config.rpc_client, + &all_utxos, + config.dust_threshold, + tip_height, + ); + + // Register heuristics + let heuristics: Vec<&dyn DustHeuristic> = vec![ + &ChangeAddressHeuristic, + &ExactFloorPolicy, + &MultiAddressHeuristic, + ]; + + // Score population 1 + for dust in &mut all_dust { + let descriptor = descriptor_map + .iter() + .find(|(u, _)| u.outpoint == dust.utxo.outpoint) + .map(|(_, d)| *d); + if let Some(desc) = descriptor { + let ctx = build_context(&dust.utxo, &all_utxos, &tx_cache, desc, tip_height); + let score = aggregate(&dust.utxo, &ctx, &heuristics); + dust.suspicion_score = Some(score.score); + dust.suspicion_reasons = score.reasons; + } + } + + // Population 2: above threshold but within scan window (4x threshold) + // Only added if heuristic score exceeds suspicion threshold + const SUSPICION_THRESHOLD: f32 = 0.5; + let scan_window = config.dust_threshold * 4; + let already_included: std::collections::HashSet<_> = + all_dust.iter().map(|d| d.utxo.outpoint).collect(); + + for utxo in all_utxos.iter().filter(|u| { + let sats = u.value.to_sat(); + sats >= config.dust_threshold + && sats < scan_window + && !already_included.contains(&u.outpoint) + }) { + let descriptor = descriptor_map + .iter() + .find(|(u, _)| u.outpoint == utxo.outpoint) + .map(|(_, d)| *d); + if let Some(desc) = descriptor { + let ctx = build_context(utxo, &all_utxos, &tx_cache, desc, tip_height); + let score = aggregate(utxo, &ctx, &heuristics); + if score.score >= SUSPICION_THRESHOLD { + all_dust.push(DustUtxo { + utxo: utxo.clone(), + reason: DustReason::SuspectedAttack { score: score.score }, + is_spent: false, + suspicion_score: Some(score.score), + suspicion_reasons: score.reasons, + }); } } } @@ -145,10 +225,6 @@ fn list(config: Config, fingerprint: Option) { .upsert_utxos(&all_dust) .expect("Failed to store dust to table"); - // Deduplicate by outpoint — same UTXO can appear under multiple descriptors - let mut seen = std::collections::HashSet::new(); - all_dust.retain(|d| seen.insert(d.utxo.outpoint)); - let rows: Vec = all_dust.iter().map(DustRow::from).collect(); let table = Table::new(&rows).to_string(); println!("Network: {}", config.network); @@ -172,6 +248,8 @@ struct DustRow { block_height: u32, #[tabled(rename = "Reason")] reason: String, + #[tabled(rename = "Score")] + score: String, #[tabled(rename = "Wallet")] wallet: String, } @@ -191,6 +269,7 @@ impl From<&DustUtxo> for DustRow { value_sats, } => format!("uneconomical (fee {fee_to_spend_sats} > value {value_sats})"), DustReason::SuspiciousRoundValue => "suspicious round value".to_string(), + DustReason::SuspectedAttack { score } => format!("suspected attack (score {score:.2})"), }; DustRow { @@ -199,6 +278,10 @@ impl From<&DustUtxo> for DustRow { value_sats: d.utxo.value.to_sat(), block_height: d.utxo.block_height, reason, + score: d + .suspicion_score + .map(|s| format!("{:.2}", s)) + .unwrap_or_else(|| "-".to_string()), wallet: d.utxo.descriptor_fingerprint.clone(), } } diff --git a/src/redb/storage.rs b/src/redb/storage.rs index ba47e19..586ff58 100644 --- a/src/redb/storage.rs +++ b/src/redb/storage.rs @@ -288,6 +288,8 @@ mod tests { threshold_sats: 546, }, is_spent: false, + suspicion_score: None, + suspicion_reasons: vec![], }; store.upsert_utxos(&[dust]).unwrap(); diff --git a/src/utxo/context.rs b/src/utxo/context.rs new file mode 100644 index 0000000..cfe5f1b --- /dev/null +++ b/src/utxo/context.rs @@ -0,0 +1,188 @@ +use bdk_bitcoind_rpc::bitcoincore_rpc::{Client, RpcApi}; +use bdk_wallet::KeychainKind; +use bitcoin::{AddressType, Txid}; +use std::collections::HashMap; + +use crate::utxo::dust_policies::UtxoContext; +use crate::utxo::utxo_parser::Utxo; +use crate::wallet_descriptor::wallet_parser::ParsedDescriptor; + +/// Cached data extracted from a single sending transaction. +/// One entry per unique txid — populated via one `getrawtransaction` call. +#[derive(Debug, Clone)] +pub struct TxInfo { + pub output_count: u32, + pub dust_output_count: u32, + pub input_count: u32, + pub distinct_input_addresses: u32, + /// Outputs fan-out: how many distinct addresses the tx sent to + pub output_fan_out: u32, + /// Fee rate in sat/vbyte, if derivable + pub fee_rate: f64, + /// Confirmations at time of scan + pub confirmations: u32, +} + +/// Builds a `TxInfo` cache for all unique sending txids in `utxos`. +/// Makes one `getrawtransaction` call per unique txid. +/// UTXOs whose txid cannot be fetched are silently skipped (node may lack txindex). +pub fn build_tx_cache( + client: &Client, + utxos: &[Utxo], + dust_threshold_sats: u64, + tip_height: u32, +) -> HashMap { + let mut cache: HashMap = HashMap::new(); + + // Collect unique txids + let txids: Vec = { + let mut seen = std::collections::HashSet::new(); + utxos + .iter() + .filter(|u| seen.insert(u.outpoint.txid)) + .map(|u| u.outpoint.txid) + .collect() + }; + + for txid in txids { + let Ok(tx_info) = client.get_raw_transaction_info(&txid, None) else { + // Node lacks txindex or tx is unknown — skip + continue; + }; + + let output_count = tx_info.vout.len() as u32; + let input_count = tx_info.vin.len() as u32; + + // Count dust-sized outputs + let dust_output_count = tx_info + .vout + .iter() + .filter(|o| o.value.to_sat() < dust_threshold_sats) + .count() as u32; + + // Count distinct output addresses (fan-out) using script hex as key + let output_fan_out = tx_info + .vout + .iter() + .map(|o| o.script_pub_key.hex.clone()) + .collect::>() + .len() as u32; + + // Count distinct input addresses — not available without prevout lookup, + // so default to input_count as a conservative estimate + let distinct_input_addresses = input_count; + + // Estimate fee rate from vsize if mempool entry available + let fee_rate = client + .get_mempool_entry(&txid) + .ok() + .and_then(|e| { + let fee_sats = e.fees.base.to_sat() as f64; + let vsize = tx_info.vsize as f64; + if vsize > 0.0 { + Some(fee_sats / vsize) + } else { + None + } + }) + .unwrap_or(0.0); + + let confirmations = tx_info.confirmations.unwrap_or(0); + let age_blocks = tip_height.saturating_sub(tip_height.saturating_sub(confirmations)); + + cache.insert( + txid, + TxInfo { + output_count, + dust_output_count, + input_count, + distinct_input_addresses, + output_fan_out, + fee_rate, + confirmations: age_blocks, + }, + ); + } + + cache +} + +/// Builds a `UtxoContext` for a single UTXO. +/// +/// - `all_dust` — the full list of dust UTXOs (for cross-UTXO counts) +/// - `tx_cache` — pre-built cache from `build_tx_cache` +/// - `descriptor` — the descriptor this UTXO belongs to +/// - `tip_height` — current chain tip +pub fn build_context( + utxo: &Utxo, + all_dust: &[Utxo], + tx_cache: &HashMap, + descriptor: &ParsedDescriptor, + tip_height: u32, +) -> UtxoContext { + // Determine keychain from descriptor label convention: + // change descriptors are stored with "/change" suffix in wallet_name + let keychain = if descriptor.change_descriptor_str.is_some() + && utxo.descriptor_fingerprint.ends_with("/change") + { + KeychainKind::Internal + } else { + KeychainKind::External + }; + + // External addresses are assumed shared; internal (change) are not + let address_ever_shared = matches!(keychain, KeychainKind::External); + + // Detect address type from script_pubkey + let address_type = if utxo.script_pubkey.is_p2pkh() { + AddressType::P2pkh + } else if utxo.script_pubkey.is_p2sh() { + AddressType::P2sh + } else if utxo.script_pubkey.is_p2wpkh() { + AddressType::P2wpkh + } else if utxo.script_pubkey.is_p2wsh() { + AddressType::P2wsh + } else { + AddressType::P2tr // default for taproot and unknown + }; + + // Count how many other dust UTXOs share the same sending txid (spray detection) + let other_utxos_same_sender = all_dust + .iter() + .filter(|u| u.outpoint.txid == utxo.outpoint.txid && u.outpoint != utxo.outpoint) + .count() as u32; + + // Count how many times this address has received outputs in the dust list + let times_address_received = all_dust + .iter() + .filter(|u| u.script_pubkey == utxo.script_pubkey) + .count() as u32; + + let age_blocks = tip_height.saturating_sub(utxo.block_height); + + // Pull tx-level data from cache, or use safe defaults if unavailable + let tx = tx_cache.get(&utxo.outpoint.txid); + + UtxoContext { + address_type, + keychain, + derivation_index: 0, // not yet derivable without full descriptor scan + address_ever_shared, + times_address_received, + source_output_count: tx.map(|t| t.output_count).unwrap_or(0), + source_dust_output_count: tx.map(|t| t.dust_output_count).unwrap_or(0), + source_input_count: tx.map(|t| t.input_count).unwrap_or(0), + distinct_input_addresses: tx.map(|t| t.distinct_input_addresses).unwrap_or(0), + source_age_blocks: tx.map(|t| t.confirmations).unwrap_or(0), + source_fee_rate: tx.map(|t| t.fee_rate).unwrap_or(0.0), + source_addr_known: false, + source_is_exchange: None, + source_addr_reused: false, + source_output_fan_out: tx.map(|t| t.output_fan_out).unwrap_or(0), + block_height: utxo.block_height, + age_blocks, + prev_spend_attempts: 0, + wallet_total_utxo_count: all_dust.len() as u32, + other_utxos_same_sender, + } +} diff --git a/src/utxo/dust_policies/heuristic/change_address.rs b/src/utxo/dust_policies/heuristic/change_address.rs index b3d92aa..6d0f475 100644 --- a/src/utxo/dust_policies/heuristic/change_address.rs +++ b/src/utxo/dust_policies/heuristic/change_address.rs @@ -1,7 +1,7 @@ use bdk_wallet::KeychainKind; +use crate::utxo::dust_policies::UtxoContext; use crate::utxo::dust_policies::heuristic::DustHeuristic; -use crate::utxo::dust_policy::UtxoContext; use crate::utxo::utxo_parser::Utxo; /// Flags dust received on an internal (change) address. @@ -18,14 +18,18 @@ use crate::utxo::utxo_parser::Utxo; /// 0.0 — external keychain (normal receive address) pub struct ChangeAddressHeuristic; +impl ChangeAddressHeuristic { + const NAME: &'static str = "dust_on_change_address"; + const WEIGHT: f32 = 3.0; +} + impl DustHeuristic for ChangeAddressHeuristic { fn name(&self) -> &'static str { - "dust_on_change_address" + Self::NAME } fn weight(&self) -> f32 { - // High weight — receiving dust on a change address is a very strong signal - 3.0 + Self::WEIGHT } fn evaluate(&self, _utxo: &Utxo, ctx: &UtxoContext) -> f32 { @@ -47,7 +51,7 @@ impl DustHeuristic for ChangeAddressHeuristic { #[cfg(test)] mod tests { use super::*; - use crate::utxo::dust_policy::UtxoContext; + use crate::utxo::dust_policies::UtxoContext; use crate::utxo::utxo_parser::Utxo; use bitcoin::{AddressType, Amount, OutPoint, ScriptBuf, Txid, hashes::Hash}; diff --git a/src/utxo/dust_policies/heuristic/exact_floor.rs b/src/utxo/dust_policies/heuristic/exact_floor.rs new file mode 100644 index 0000000..084b7a1 --- /dev/null +++ b/src/utxo/dust_policies/heuristic/exact_floor.rs @@ -0,0 +1,29 @@ +use crate::utxo::{ + dust_policies::{UtxoContext, heuristic::DustHeuristic}, + utxo_parser::Utxo, +}; + +pub struct ExactFloorPolicy; + +impl ExactFloorPolicy { + const NAME: &'static str = "dust_matching_script_type_relay_floor"; + const WEIGHT: f32 = 2.5; +} + +impl DustHeuristic for ExactFloorPolicy { + fn name(&self) -> &'static str { + Self::NAME + } + + fn weight(&self) -> f32 { + Self::WEIGHT + } + + fn evaluate(&self, utxo: &Utxo, _ctx: &UtxoContext) -> f32 { + if utxo.value == utxo.script_pubkey.minimal_non_dust() { + 1.5 + } else { + 0.0 + } + } +} diff --git a/src/utxo/dust_policies/heuristic/mod.rs b/src/utxo/dust_policies/heuristic/mod.rs index b7a1f9f..f3b7be3 100644 --- a/src/utxo/dust_policies/heuristic/mod.rs +++ b/src/utxo/dust_policies/heuristic/mod.rs @@ -1,6 +1,8 @@ pub mod change_address; +pub mod exact_floor; +pub mod multi_address; -use crate::utxo::dust_policy::{SuspicionScore, UtxoContext}; +use crate::utxo::dust_policies::{SuspicionScore, UtxoContext}; use crate::utxo::utxo_parser::Utxo; /// Every heuristic implements this trait. diff --git a/src/utxo/dust_policies/heuristic/multi_address.rs b/src/utxo/dust_policies/heuristic/multi_address.rs new file mode 100644 index 0000000..9f39470 --- /dev/null +++ b/src/utxo/dust_policies/heuristic/multi_address.rs @@ -0,0 +1,154 @@ +use crate::utxo::dust_policies::UtxoContext; +use crate::utxo::dust_policies::heuristic::DustHeuristic; +use crate::utxo::utxo_parser::Utxo; + +/// Flags dust when the same sending transaction reached multiple addresses in +/// your wallet. +/// +/// If multiple of your UTXOs share the same `txid`, they all came from one +/// transaction — a classic spray pattern. The attacker sent dust to several of +/// your addresses in a single tx, hoping you consolidate them and reveal the +/// link between those addresses. +/// +/// `other_utxos_same_sender` in `UtxoContext` is pre-computed before scoring: +/// - Cheap path: count UTXOs in your local DB sharing the same `outpoint.txid` +/// - Enhanced path: if RPC context is available, also count UTXOs from the +/// same sender across different transactions (cross-tx spray) +/// +/// Signal scale: +/// 0 others → 0.0 (no pattern) +/// 1 other → 0.6 (possible coincidence, moderate suspicion) +/// 2 others → 0.85 (unlikely coincidence) +/// 3+ → 1.0 (almost certainly a spray attack) +pub struct MultiAddressHeuristic; + +impl MultiAddressHeuristic { + const NAME: &'static str = "multi_address_spray"; + const WEIGHT: f32 = 2.0; +} + +impl DustHeuristic for MultiAddressHeuristic { + fn name(&self) -> &'static str { + Self::NAME + } + + fn weight(&self) -> f32 { + Self::WEIGHT + } + + fn evaluate(&self, _utxo: &Utxo, ctx: &UtxoContext) -> f32 { + match ctx.other_utxos_same_sender { + 0 => 0.0, + 1 => 0.6, + 2 => 0.85, + _ => 1.0, + } + } +} + +/// Pre-computes `other_utxos_same_sender` for a batch of UTXOs using the +/// cheap txid-sharing approach — no RPC required. +/// +/// For each UTXO, counts how many *other* UTXOs in the same batch share the +/// same `outpoint.txid`. This is the minimum count; if RPC-derived context +/// is available it can be added on top. +pub fn count_same_sender(utxos: &[Utxo], target: &Utxo) -> u32 { + utxos + .iter() + .filter(|u| { + u.outpoint.txid == target.outpoint.txid && u.outpoint != target.outpoint // don't count self + }) + .count() as u32 +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::utxo::dust_policies::UtxoContext; + use bdk_wallet::KeychainKind; + use bitcoin::{AddressType, Amount, OutPoint, ScriptBuf, Txid, hashes::Hash}; + + fn utxo(txid_byte: u8, vout: u32) -> Utxo { + Utxo { + outpoint: OutPoint { + txid: Txid::from_byte_array([txid_byte; 32]), + vout, + }, + value: Amount::from_sat(300), + script_pubkey: ScriptBuf::new(), + block_height: 800_000, + descriptor_fingerprint: "deadbeef".to_string(), + } + } + + fn ctx(other_utxos_same_sender: u32) -> UtxoContext { + UtxoContext { + address_type: AddressType::P2wpkh, + keychain: KeychainKind::External, + derivation_index: 0, + address_ever_shared: true, + times_address_received: 1, + source_output_count: 5, + source_dust_output_count: 5, + source_input_count: 1, + distinct_input_addresses: 1, + source_age_blocks: 2, + source_fee_rate: 2.0, + source_addr_known: false, + source_is_exchange: None, + source_addr_reused: false, + source_output_fan_out: 5, + block_height: 800_000, + age_blocks: 2, + prev_spend_attempts: 0, + wallet_total_utxo_count: 10, + other_utxos_same_sender, + } + } + + #[test] + fn no_others_scores_zero() { + let h = MultiAddressHeuristic; + assert_eq!(h.evaluate(&utxo(0xaa, 0), &ctx(0)), 0.0); + } + + #[test] + fn one_other_scores_moderate() { + let h = MultiAddressHeuristic; + assert_eq!(h.evaluate(&utxo(0xaa, 0), &ctx(1)), 0.6); + } + + #[test] + fn two_others_scores_high() { + let h = MultiAddressHeuristic; + assert_eq!(h.evaluate(&utxo(0xaa, 0), &ctx(2)), 0.85); + } + + #[test] + fn three_plus_scores_max() { + let h = MultiAddressHeuristic; + assert_eq!(h.evaluate(&utxo(0xaa, 0), &ctx(3)), 1.0); + assert_eq!(h.evaluate(&utxo(0xaa, 0), &ctx(10)), 1.0); + } + + #[test] + fn count_same_sender_finds_shared_txid() { + let shared_txid = 0xbb; + let u0 = utxo(shared_txid, 0); + let u1 = utxo(shared_txid, 1); // same tx, different vout + let u2 = utxo(shared_txid, 2); // same tx, different vout + let u3 = utxo(0xcc, 0); // different tx + + let all = vec![u0.clone(), u1.clone(), u2.clone(), u3.clone()]; + + assert_eq!(count_same_sender(&all, &u0), 2); // u1 and u2 + assert_eq!(count_same_sender(&all, &u3), 0); // no others from 0xcc + } + + #[test] + fn count_same_sender_excludes_self() { + let u = utxo(0xaa, 0); + let all = vec![u.clone()]; + assert_eq!(count_same_sender(&all, &u), 0); + } +} diff --git a/src/utxo/dust_policies/mod.rs b/src/utxo/dust_policies/mod.rs index 454ff5d..70115e5 100644 --- a/src/utxo/dust_policies/mod.rs +++ b/src/utxo/dust_policies/mod.rs @@ -1,2 +1,59 @@ pub mod heuristic; pub mod static_policy; + +use bdk_wallet::KeychainKind; +use bitcoin::AddressType; + +// use crate::utxo::utxo_parser::{Utxo}; + +pub struct UtxoContext { + // --- User Address provenance --- + pub address_type: AddressType, + pub keychain: KeychainKind, // external (shared) or Internal (change) + pub derivation_index: u32, // How deep into the keychain + pub address_ever_shared: bool, // Whether address has been published publicly + pub times_address_received: u32, // How often the address has received outputs + + // --- Sending tx props + pub source_output_count: u32, // Total no of outputs in sending tx + pub source_dust_output_count: u32, // How many of the outputs are dust-sized + pub source_input_count: u32, // No of inputs in the sending tx + pub distinct_input_addresses: u32, // Distince input address + pub source_age_blocks: u32, // No of confirmations since sending tx + pub source_fee_rate: f64, + + // --- Sender address props + pub source_addr_known: bool, // Whether user recognizes the address + pub source_is_exchange: Option, // If identifiable, is it an exchange? + pub source_addr_reused: bool, // Has the address been used often + pub source_output_fan_out: u32, // How many addresses the sender reached in the tx + + // --- UTXO History --- + pub block_height: u32, // When utxo was confirmed + pub age_blocks: u32, // How old UTXO is now + pub prev_spend_attempts: u32, // Has wallet tried and failed to spend this? + + // --- Wallet context --- + pub wallet_total_utxo_count: u32, // How many UTXOs the wallet has in total + pub other_utxos_same_sender: u32, // How many UTXOs came from same sender +} +pub struct HeuristicDustPolicy { + pub fee_rate_sat_per_vb: f64, + pub suspicion_threshold: f32, // 0.0 to 1/0 - how suspicious to flag +} + +pub struct SuspicionScore { + pub score: f32, // 0.0 = definitely legit, 1.0 = almost certainly an attack + pub reasons: Vec, // human-readable explanations +} + +// impl HeuristicDustPolicy { +// pub fn score(&self, utxo: Utxo, context: &UtxoContext) -> SuspicionScore { +// let reasons = vec![String::from("Because I said so")]; +// SuspicionScore { score: 1.0, reasons } +// } + +// pub fn is_dust(&self, utxo: Utxo, context: &UtxoContext) -> bool { +// self.score(utxo, context).score >= self.suspicion_threshold +// } +// } diff --git a/src/utxo/dust_policy.rs b/src/utxo/dust_policy.rs deleted file mode 100644 index be368fc..0000000 --- a/src/utxo/dust_policy.rs +++ /dev/null @@ -1,59 +0,0 @@ -use bdk_wallet::KeychainKind; -use bitcoin::AddressType; - -use crate::utxo::utxo_parser::Utxo; - -pub struct UtxoContext { - // --- User Address provenance --- - pub address_type: AddressType, - pub keychain: KeychainKind, // external (shared) or Internal (change) - pub derivation_index: u32, // How deep into the keychain - pub address_ever_shared: bool, // Whether address has been published publicly - pub times_address_received: u32, // How often the address has received outputs - - // --- Sending tx props - pub source_output_count: u32, // Total no of outputs in sending tx - pub source_dust_output_count: u32, // How many of the outputs are dust-sized - pub source_input_count: u32, // No of inputs in the sending tx - pub distinct_input_addresses: u32, // Distince input address - pub source_age_blocks: u32, // No of confirmations since sending tx - pub source_fee_rate: f64, - - // --- Sender address props - pub source_addr_known: bool, // Whether user recognizes the address - pub source_is_exchange: Option, // If identifiable, is it an exchange? - pub source_addr_reused: bool, // Has the address been used often - pub source_output_fan_out: u32, // How many addresses the sender reached in the tx - - // --- UTXO History --- - pub block_height: u32, // When utxo was confirmed - pub age_blocks: u32, // How old UTXO is now - pub prev_spend_attempts: u32, // Has wallet tried and failed to spend this? - - // --- Wallet context --- - pub wallet_total_utxo_count: u32, // How many UTXOs the wallet has in total - pub other_utxos_same_sender: u32, // How many UTXOs came from same sender -} -pub struct HeuristicDustPolicy { - pub fee_rate_sat_per_vb: f64, - pub suspicion_threshold: f32, // 0.0 to 1/0 - how suspicious to flag -} - -pub struct SuspicionScore { - pub score: f32, // 0.0 = definitely legit, 1.0 = almost certainly an attack - pub reasons: Vec, // human-readable explanations -} - -impl HeuristicDustPolicy { - pub fn score(&self, _utxo: Utxo, _context: &UtxoContext) -> SuspicionScore { - let reasons = vec![String::from("Because I said so")]; - SuspicionScore { - score: 1.0, - reasons, - } - } - - pub fn is_dust(&self, utxo: Utxo, context: &UtxoContext) -> bool { - self.score(utxo, context).score >= self.suspicion_threshold - } -} diff --git a/src/utxo/mod.rs b/src/utxo/mod.rs index 0735fe2..416bc36 100644 --- a/src/utxo/mod.rs +++ b/src/utxo/mod.rs @@ -1,3 +1,3 @@ +pub mod context; pub mod dust_policies; -pub mod dust_policy; pub mod utxo_parser; diff --git a/src/utxo/utxo_parser.rs b/src/utxo/utxo_parser.rs index a69671e..ad71d5d 100644 --- a/src/utxo/utxo_parser.rs +++ b/src/utxo/utxo_parser.rs @@ -24,6 +24,11 @@ pub enum DustReason { value_sats: u64, }, SuspiciousRoundValue, + /// Heuristic scoring flagged this UTXO as a suspected dust attack. + /// Value is above the dust threshold but behavioural signals are strong. + SuspectedAttack { + score: f32, + }, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -31,6 +36,10 @@ pub struct DustUtxo { pub utxo: Utxo, pub reason: DustReason, pub is_spent: bool, + /// Heuristic suspicion score in [0.0, 1.0]. None if not yet evaluated. + pub suspicion_score: Option, + /// Human-readable reasons from each heuristic that fired. + pub suspicion_reasons: Vec, } #[derive(Debug, thiserror::Error)] @@ -124,6 +133,8 @@ impl Utxo { threshold_sats: 546, }, is_spent: false, + suspicion_score: None, + suspicion_reasons: vec![], }; dust_utxos.push(dust_utxo); continue; @@ -263,6 +274,8 @@ mod tests { threshold_sats: 546, }, is_spent: false, + suspicion_score: None, + suspicion_reasons: vec![], } }