dust_cleaner/
lib.rs

1use bitcoin::{
2    Transaction, TxIn, TxOut, OutPoint, ScriptBuf, Witness, Sequence, transaction::Version, Address
3};
4use bitcoin::psbt::Psbt;
5use bitcoin::Amount;
6use bitcoincore_rpc::{Client, RpcApi};
7use base64::{engine::general_purpose, Engine as _};
8use std::collections::HashMap;
9use std::str::FromStr;
10
11/// DustSweeper provides functionality to identify and clean up dust UTXOs from a Bitcoin wallet
12/// in a privacy-preserving way.
13///
14/// # Privacy Features
15/// - Groups UTXOs by address to avoid linking different addresses
16/// - Creates separate transactions for each address's dust
17///
18/// # Example
19/// ```no_run
20/// use bitcoincore_rpc::{Auth, Client};
21/// use dust_cleaner::DustSweeper;
22///
23/// let rpc_client = Client::new(
24///     "http://localhost:18443",
25///     Auth::UserPass("user".to_string(), "pass".to_string())
26/// ).unwrap();
27///
28/// let sweeper = DustSweeper::new(rpc_client, 1000);
29/// let dust_utxos = sweeper.get_dust_utxos().unwrap();
30/// ```
31pub struct DustSweeper {
32    rpc_client: Client,
33    threshold: u64,
34}
35
36impl DustSweeper {
37    /// Creates a new DustSweeper instance
38    ///
39    /// # Arguments
40    /// * `rpc_client` - Bitcoin Core RPC client
41    /// * `threshold` - Amount in satoshis below which UTXOs are considered dust
42    pub fn new(rpc_client: Client, threshold: u64) -> Self {
43        Self { rpc_client, threshold }
44    }
45
46    /// Retrieves all UTXOs below the dust threshold from the wallet
47    ///
48    /// # Returns
49    /// * `Result<Vec<ListUnspentResultEntry>>` - List of dust UTXOs or RPC error
50    pub fn get_dust_utxos(&self) -> Result<Vec<bitcoincore_rpc::json::ListUnspentResultEntry>, bitcoincore_rpc::Error> {
51        let utxos = self.rpc_client.list_unspent(None, None, None, None, None)?;
52        Ok(utxos.into_iter()
53            .filter(|u| u.amount.to_sat() < self.threshold)
54            .collect())
55    }
56
57    /// Groups UTXOs by their source address to maintain privacy
58    ///
59    /// # Arguments
60    /// * `utxos` - Vector of UTXOs to group
61    ///
62    /// # Returns
63    /// * `HashMap<String, Vec<ListUnspentResultEntry>>` - UTXOs grouped by address
64    fn group_utxos_by_address(&self, utxos: Vec<bitcoincore_rpc::json::ListUnspentResultEntry>) -> HashMap<String, Vec<bitcoincore_rpc::json::ListUnspentResultEntry>> {
65        let mut grouped: HashMap<String, Vec<bitcoincore_rpc::json::ListUnspentResultEntry>> = HashMap::new();
66
67        for utxo in utxos {
68            if let Some(address) = &utxo.address {
69                grouped.entry(address.clone().assume_checked().to_string()).or_default().push(utxo);
70            }
71        }
72
73        grouped
74    }
75
76    /// Creates PSBTs to burn dust UTXOs to a specified address
77    ///
78    /// # Arguments
79    /// * `dust_utxos` - Vector of dust UTXOs to burn
80    /// * `fee` - Transaction fee in satoshis
81    /// * `burn_addr` - Address to send dust to
82    ///
83    /// # Returns
84    /// * `Result<Vec<Psbt>>` - Vector of unsigned PSBTs or error
85    pub fn build_psbts_burn(&self, dust_utxos: Vec<bitcoincore_rpc::json::ListUnspentResultEntry>, fee: u64, burn_addr: &str) -> Result<Vec<Psbt>, bitcoin::psbt::Error> {
86        let utxos_by_address = self.group_utxos_by_address(dust_utxos);
87        let mut psbts = Vec::new();
88
89        for (_address, utxos) in utxos_by_address {
90            let psbt = self.build_psbt(utxos, fee, burn_addr)?;
91            psbts.push(psbt);
92        }
93
94        Ok(psbts)
95    }
96
97    /// Builds a single PSBT for a group of UTXOs
98    ///
99    /// # Arguments
100    /// * `dust_utxos` - Vector of dust UTXOs from the same address
101    /// * `fee` - Transaction fee in satoshis
102    /// * `burn_addr` - Address to send dust to
103    ///
104    /// # Returns
105    /// * `Result<Psbt>` - Unsigned PSBT or error
106    fn build_psbt(&self, dust_utxos: Vec<bitcoincore_rpc::json::ListUnspentResultEntry>, fee: u64, burn_addr: &str) -> Result<Psbt, bitcoin::psbt::Error> {
107        let inputs: Vec<TxIn> = dust_utxos.iter().map(|u| {
108            TxIn {
109                previous_output: OutPoint { txid: u.txid, vout: u.vout },
110                script_sig: ScriptBuf::new(),
111                sequence: Sequence(0xFFFFFFFD), // Enable RBF
112                witness: Witness::new(),
113            }
114        }).collect();
115
116        let total_input_amount: u64 = dust_utxos.iter().map(|u| u.amount.to_sat()).sum();
117        let output_value = total_input_amount.saturating_sub(fee);
118
119        let burn_address = Address::from_str(burn_addr)
120            .expect("❌ Invalid burn address")
121            .assume_checked();
122
123        let txout = TxOut {
124            value: Amount::from_sat(output_value),
125            script_pubkey: burn_address.script_pubkey(),
126        };
127
128        let outputs: Vec<TxOut> = vec![txout];
129
130        let unsigned_tx = Transaction {
131            version: Version(2),
132            lock_time: bitcoin::absolute::LockTime::ZERO,
133            input: inputs,
134            output: outputs,
135        };
136
137        Psbt::from_unsigned_tx(unsigned_tx)
138    }
139
140    /// Converts a PSBT to base64 encoding for transport
141    ///
142    /// # Arguments
143    /// * `psbt` - The PSBT to encode
144    ///
145    /// # Returns
146    /// * `String` - Base64 encoded PSBT
147    pub fn psbt_to_base64(psbt: &Psbt) -> String {
148        general_purpose::STANDARD.encode(psbt.serialize())
149    }
150}