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
11 changes: 6 additions & 5 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,9 @@ pub struct Cli {
#[arg(long, default_value = "http://127.0.0.1:18443", env = "DUST_RPC_URL")]
pub rpc_url: String,

/// Bitcoin Core RPC username
#[arg(long, env = "DUST_RPC_USER")]
pub rpc_user: String,

/// Bitcoin Core RPC password
#[arg(long, env = "DUST_RPC_PASS")]
pub rpc_pass: String,

Expand All @@ -38,14 +36,17 @@ pub enum SweepMethod {
pub enum Commands {
/// Scan wallet for dust UTXOs
Scan,
/// Create a PSBT sweeping all dust UTXOs

Sweep {
/// Preview the sweep without creating a PSBT
#[arg(long, default_value = "false")]
dry_run: bool,

/// Sweep method: consolidate (default) or op-return (burn to fees)
#[arg(long, value_enum, default_value = "consolidate")]
/// Sweep method: op-return (burn to fees)
#[arg(long, value_enum, default_value = "op-return")]
method: SweepMethod,

#[arg(long, default_value = "false")]
batch: bool,
},
}
85 changes: 62 additions & 23 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ fn handle_sweep(
user_threshold: Option<u64>,
dry_run: bool,
method: SweepMethod,
batch: bool,
) -> anyhow::Result<()> {
let utxos = scanner::fetch_utxos(client)?;
let (dust_utxos, clean_utxos) = analyzer::classify_utxos_smart(utxos, user_threshold);
Expand All @@ -110,10 +111,22 @@ fn handle_sweep(
);
}

if !batch {
println!("\n🔒 Mode: per-UTXO (default) — each dust UTXO swept separately");
println!(" No address linking. Use --batch to sweep all at once.\n");
} else {
println!("\n⚠️ Mode: batch — all dust UTXOs swept in one transaction");
println!(" Warning: this links all dust addresses on-chain.\n");
}

if dry_run {
let result = psbt_builder::dry_run_sweep(&dust_utxos, &clean_utxos)?;
println!("\n🔍 Dry Run — no PSBT created\n");
println!("🔍 Dry Run — no PSBT created\n");
println!(" Method: {:?}", method);
println!(
" Mode: {}",
if batch { "batch" } else { "per-UTXO" }
);
println!(" Dust inputs: {}", result.dust_input_count);
println!(" Total dust: {} sats", result.total_dust_sats);
println!(" Funder UTXO: {} sats", result.funder_sats);
Expand All @@ -126,27 +139,51 @@ fn handle_sweep(
return Ok(());
}

let result = match method {
SweepMethod::Consolidate => {
println!("\n📎 Method: consolidate — dust swept to fresh address");
psbt_builder::build_sweep_psbt(client, &dust_utxos, &clean_utxos)?
}
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)?
if batch {
// existing batch behavior
let result = match method {
SweepMethod::Consolidate => {
println!("📎 Method: consolidate — dust swept to fresh address");
psbt_builder::build_sweep_psbt(client, &dust_utxos, &clean_utxos)?
}
SweepMethod::OpReturn => {
println!("🔥 Method: op-return — dust burned to miner fees");
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);
println!("\n🧹 Sweep PSBT (base64):");
println!("{}", result.psbt);
println!("\n💡 Next steps:");
println!(" Inspect: bitcoin-cli decodepsbt <psbt>");
println!(" Sign: bitcoin-cli walletprocesspsbt <psbt>");
println!(" Send: bitcoin-cli sendrawtransaction <hex>");
} else {
// per-UTXO behavior — one PSBT per dust UTXO
let results = psbt_builder::build_per_utxo_psbts(client, &dust_utxos, &clean_utxos)?;

println!(
"📊 Generated {} PSBTs (one per dust UTXO):\n",
results.len()
);

for (i, (address, result)) in results.iter().enumerate() {
println!("─── PSBT {} of {} ───", i + 1, results.len());
println!(" Address: {}", address);
println!(" Dust: {} sats", result.total_dust_sats);
println!(" PSBT: {}", result.psbt);
println!();
}
};

println!("\n📊 Sweep Summary:");
println!(" Dust inputs: {}", result.dust_input_count);
println!(" Total dust: {} sats", result.total_dust_sats);
println!("\n🧹 Sweep PSBT (base64):");
println!("{}", result.psbt);
println!("\n💡 Next steps:");
println!(" Inspect: bitcoin-cli decodepsbt <psbt>");
println!(" Sign: bitcoin-cli walletprocesspsbt <psbt>");
println!(" Send: bitcoin-cli sendrawtransaction <hex>");
println!("💡 Sign and broadcast each PSBT separately:");
println!(" Sign: bitcoin-cli walletprocesspsbt <psbt>");
println!(" Send: bitcoin-cli sendrawtransaction <hex>");
println!("\n⚠️ Broadcast each transaction at different times");
println!(" to prevent timing correlation between addresses.");
}

Ok(())
}
Expand All @@ -158,9 +195,11 @@ fn main() -> anyhow::Result<()> {

match cli.command {
Commands::Scan => handle_scan(&client, user_threshold)?,
Commands::Sweep { dry_run, method } => {
handle_sweep(&client, user_threshold, dry_run, method)?
}
Commands::Sweep {
dry_run,
method,
batch,
} => handle_sweep(&client, user_threshold, dry_run, method, batch)?,
}

Ok(())
Expand Down
70 changes: 70 additions & 0 deletions src/psbt_builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -175,3 +175,73 @@ pub fn build_op_return_psbt(
total_dust_sats,
})
}
pub fn build_per_utxo_psbts(
client: &Client,
dust_utxos: &[ListUnspentResultEntry],
clean_utxos: &[ListUnspentResultEntry],
) -> anyhow::Result<Vec<(String, SweepResult)>> {
if dust_utxos.is_empty() {
anyhow::bail!("No dust UTXOs to sweep");
}

let funder = select_funder(clean_utxos)?;
let mut results = vec![];

for utxo in dust_utxos {
let inputs = vec![
serde_json::json!({
"txid": funder.txid.to_string(),
"vout": funder.vout,
}),
serde_json::json!({
"txid": utxo.txid.to_string(),
"vout": utxo.vout,
}),
];

let op_return_data = "617368";
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) }
]);

let response = client.call::<serde_json::Value>(
"walletcreatefundedpsbt",
&[
serde_json::to_value(&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 address = utxo
.address
.as_ref()
.map(|a| a.clone().assume_checked().to_string())
.unwrap_or_else(|| "unknown".to_string());

results.push((
address,
SweepResult {
psbt,
dust_input_count: 1,
total_dust_sats: utxo.amount.to_sat(),
},
));
}

Ok(results)
}
Loading