Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 97 additions & 14 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -108,10 +115,15 @@ fn list(config: Config, fingerprint: Option<String>) {
return;
}

let mut all_dust: Vec<DustUtxo> = Vec::new();
let tip_height = config.rpc_client.get_block_count().unwrap_or(0) as u32;

let mut all_utxos: Vec<Utxo> = 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
{
Expand All @@ -120,14 +132,82 @@ fn list(config: Config, fingerprint: Option<String>) {

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<DustUtxo> = 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,
});
}
}
}
Expand All @@ -145,10 +225,6 @@ fn list(config: Config, fingerprint: Option<String>) {
.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<DustRow> = all_dust.iter().map(DustRow::from).collect();
let table = Table::new(&rows).to_string();
println!("Network: {}", config.network);
Expand All @@ -172,6 +248,8 @@ struct DustRow {
block_height: u32,
#[tabled(rename = "Reason")]
reason: String,
#[tabled(rename = "Score")]
score: String,
#[tabled(rename = "Wallet")]
wallet: String,
}
Expand All @@ -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 {
Expand All @@ -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(),
}
}
Expand Down
2 changes: 2 additions & 0 deletions src/redb/storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,8 @@ mod tests {
threshold_sats: 546,
},
is_spent: false,
suspicion_score: None,
suspicion_reasons: vec![],
};

store.upsert_utxos(&[dust]).unwrap();
Expand Down
188 changes: 188 additions & 0 deletions src/utxo/context.rs
Original file line number Diff line number Diff line change
@@ -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<Txid, TxInfo> {
let mut cache: HashMap<Txid, TxInfo> = HashMap::new();

// Collect unique txids
let txids: Vec<Txid> = {
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::<std::collections::HashSet<_>>()
.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<Txid, TxInfo>,
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,
}
}
Loading
Loading