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
16 changes: 15 additions & 1 deletion src/cli.rs
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
use clap::{Parser, Subcommand};
use clap::{Parser, Subcommand, ValueEnum};

#[derive(Parser)]
#[command(
name = "dust-cleaner",
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,

Expand All @@ -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
Expand All @@ -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,
},
}
24 changes: 18 additions & 6 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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!(
Expand All @@ -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);
Expand All @@ -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);
Expand Down
83 changes: 83 additions & 0 deletions src/psbt_builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<SweepResult> {
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<serde_json::Value> = 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::<serde_json::Value>(
"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,
})
}
Loading