diff --git a/src/cli.rs b/src/cli.rs index c851f78..32fd8b0 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,4 +1,4 @@ -use clap::{Parser, Subcommand}; +use clap::{Parser, Subcommand, ValueEnum}; #[derive(Parser)] #[command( @@ -6,12 +6,14 @@ use clap::{Parser, Subcommand}; about = "Detect and sweep dust attack UTXOs from your Bitcoin wallet" )] pub struct Cli { + /// Bitcoin Core RPC URL #[arg(long, default_value = "http://127.0.0.1:18443")] pub rpc_url: String, #[arg(long)] pub rpc_user: String, + #[arg(long)] pub rpc_pass: String, @@ -23,6 +25,14 @@ pub struct Cli { pub command: Commands, } +#[derive(ValueEnum, Clone, Debug)] +pub enum SweepMethod { + /// Consolidate dust into a fresh wallet address + Consolidate, + /// Burn dust to miner fees via OP_RETURN (more private) + OpReturn, +} + #[derive(Subcommand)] pub enum Commands { /// Scan wallet for dust UTXOs @@ -32,5 +42,9 @@ pub enum Commands { /// Preview the sweep without creating a PSBT #[arg(long, default_value = "false")] dry_run: bool, + + /// Sweep method: op-return (burn to fees) + #[arg(long, value_enum, default_value = "consolidate")] + method: SweepMethod, }, } \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index 70a7830..de7667b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -77,15 +77,15 @@ fn main() -> anyhow::Result<()> { println!("─────────────────────────────────────────"); } - Commands::Sweep { dry_run } => { + Commands::Sweep { dry_run, method } => { let utxos = scanner::fetch_utxos(&client)?; let (dust_utxos, clean_utxos) = analyzer::classify_utxos_smart(utxos, user_threshold); - + if dust_utxos.is_empty() { println!("✅ No dust UTXOs found. Wallet is clean!"); return Ok(()); } - + println!("Found {} dust UTXOs to sweep:", dust_utxos.len()); for utxo in &dust_utxos { println!( @@ -95,10 +95,11 @@ fn main() -> anyhow::Result<()> { utxo.vout ); } - + if dry_run { let result = psbt_builder::dry_run_sweep(&dust_utxos, &clean_utxos)?; println!("\n🔍 Dry Run — no PSBT created\n"); + println!(" Method: {:?}", method); println!(" Dust inputs: {}", result.dust_input_count); println!(" Total dust: {} sats", result.total_dust_sats); println!(" Funder UTXO: {} sats", result.funder_sats); @@ -107,8 +108,19 @@ fn main() -> anyhow::Result<()> { println!("\n Run without --dry-run to create the PSBT."); return Ok(()); } - - let result = psbt_builder::build_sweep_psbt(&client, &dust_utxos, &clean_utxos)?; + + let result = match method { + cli::SweepMethod::Consolidate => { + println!("\n📎 Method: consolidate — dust swept to fresh address"); + psbt_builder::build_sweep_psbt(&client, &dust_utxos, &clean_utxos)? + } + cli::SweepMethod::OpReturn => { + println!("\n🔥 Method: op-return — dust burned to miner fees"); + println!(" Output: OP_RETURN (\"ash\" — ashes to ashes, dust to dust)"); + psbt_builder::build_op_return_psbt(&client, &dust_utxos, &clean_utxos)? + } + }; + println!("\n📊 Sweep Summary:"); println!(" Dust inputs: {}", result.dust_input_count); println!(" Total dust: {} sats", result.total_dust_sats); diff --git a/src/psbt_builder.rs b/src/psbt_builder.rs index aaf2997..d1aa1b0 100644 --- a/src/psbt_builder.rs +++ b/src/psbt_builder.rs @@ -112,4 +112,87 @@ pub fn dry_run_sweep( estimated_fee_sats, estimated_output_sats, }) +} + +pub fn build_op_return_psbt( + client: &Client, + dust_utxos: &[ListUnspentResultEntry], + clean_utxos: &[ListUnspentResultEntry], +) -> anyhow::Result { + if dust_utxos.is_empty() { + anyhow::bail!("No dust UTXOs to sweep"); + } + + let funder = clean_utxos + .iter() + .max_by_key(|u| u.amount.to_sat()) + .ok_or_else(|| anyhow::anyhow!( + "Cannot sweep: no clean UTXOs available to fund transaction fees." + ))?; + + println!( + "\n ℹ️ Using clean UTXO to fund fees: {} sats", + funder.amount.to_sat() + ); + + // Build inputs — funder first, then all dust UTXOs + let mut all_inputs: Vec = vec![ + serde_json::json!({ + "txid": funder.txid.to_string(), + "vout": funder.vout, + }) + ]; + + for utxo in dust_utxos { + all_inputs.push(serde_json::json!({ + "txid": utxo.txid.to_string(), + "vout": utxo.vout, + })); + } + + // OP_RETURN output — "ashes to ashes, dust to dust" + // data: 617368 = "ash" in hex + let op_return_data = "617368"; + + // Change output to receive funder amount back minus fees + let change_address = client.get_new_address(None, None)?; + let change_address = change_address.assume_checked(); + + let funder_btc = funder.amount.to_btc(); + let outputs = serde_json::json!([ + { + "data": op_return_data + }, + { + change_address.to_string(): format!("{:.8}", funder_btc) + } + ]); + + // subtractFeeFromOutputs: [1] means subtract fee from the change output (index 1) + // OP_RETURN is index 0 and carries no value + let response = client.call::( + "walletcreatefundedpsbt", + &[ + serde_json::to_value(&all_inputs)?, + outputs, + serde_json::Value::Null, + serde_json::json!({ + "subtractFeeFromOutputs": [1], + "replaceable": true, + }), + ], + )?; + + let psbt = response["psbt"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("No PSBT returned from node"))? + .to_string(); + + let total_dust_sats = dust_utxos.iter().map(|u| u.amount.to_sat()).sum(); + + Ok(SweepResult { + psbt, + dust_input_count: dust_utxos.len(), + total_dust_sats, + }) } \ No newline at end of file