From 17956359c4ce0a3639265cbc35c4dd3aaf586ad7 Mon Sep 17 00:00:00 2001 From: Okunlade Jolade <99138852+Jolah1@users.noreply.github.com> Date: Wed, 8 Apr 2026 13:27:06 +0100 Subject: [PATCH 01/10] Add GitHub Actions workflow for Rust project --- .github/workflows/rust.yml | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 .github/workflows/rust.yml diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml new file mode 100644 index 0000000..9fd45e0 --- /dev/null +++ b/.github/workflows/rust.yml @@ -0,0 +1,22 @@ +name: Rust + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +env: + CARGO_TERM_COLOR: always + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - name: Build + run: cargo build --verbose + - name: Run tests + run: cargo test --verbose From 69a291fc1b23c4733220b65c410bdf31eed53898 Mon Sep 17 00:00:00 2001 From: jolah1 Date: Fri, 10 Apr 2026 06:44:09 +0100 Subject: [PATCH 02/10] demo added to readme --- README.md | 7 +++ docs/demo.cast | 141 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 148 insertions(+) create mode 100644 docs/demo.cast diff --git a/README.md b/README.md index fc0ade8..b25f486 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,13 @@ A Bitcoin CLI tool that detects dust attack UTXOs in your wallet and sweeps them Built in Rust! --- +## Demo + +Example of scanning for dust UTXOs and performing a dry-run sweep: + + +[![asciicast](https://asciinema.org/a/1dC18FaxI2Rjif8F.svg)](https://asciinema.org/a/1dC18FaxI2Rjif8F) + ## What is a Dust Attack? A dust attack is a privacy attack where an adversary sends tiny amounts of Bitcoin (called "dust") to your wallet addresses. When you later spend those UTXOs alongside your real funds, the attacker can track the transaction graph to cluster your addresses and de-anonymize your wallet. diff --git a/docs/demo.cast b/docs/demo.cast new file mode 100644 index 0000000..d90d424 --- /dev/null +++ b/docs/demo.cast @@ -0,0 +1,141 @@ +{"version": 2, "width": 82, "height": 44, "timestamp": 1775798717, "env": {"SHELL": "/bin/bash", "TERM": "xterm-256color"}} +[0.820534, "o", "\u001b[?2004h\u001b]0;jolah1@DESKTOP-N4M1LE0: ~/dev/dust-cleaner\u0007\u001b[01;32mjolah1@DESKTOP-N4M1LE0\u001b[00m:\u001b[01;34m~/dev/dust-cleaner\u001b[00m$ "] +[2.795947, "o", "c"] +[2.980767, "o", "a"] +[3.139852, "o", "r"] +[3.392408, "o", "g"] +[3.835027, "o", "o"] +[4.186488, "o", " "] +[5.40972, "o", "r"] +[5.701888, "o", "u"] +[6.069876, "o", "n"] +[6.946351, "o", " "] +[7.654731, "o", "-"] +[7.837404, "o", "-"] +[8.257093, "o", " "] +[9.03623, "o", "-"] +[9.230662, "o", "-"] +[9.907083, "o", "h"] +[10.337625, "o", "e"] +[10.51073, "o", "l"] +[10.830549, "o", "p"] +[12.792466, "o", "\r\n\u001b[?2004l\r"] +[12.967165, "o", "\u001b[1m\u001b[92m Finished\u001b[0m `dev` profile [unoptimized + debuginfo] target(s) in 0.10s\r\n"] +[12.973381, "o", "\u001b[1m\u001b[92m Running\u001b[0m `target/debug/dust-cleaner --help`\r\n"] +[12.981323, "o", "Detect and sweep dust attack UTXOs from your Bitcoin wallet\r\n\r\n\u001b[1m\u001b[4mUsage:\u001b[0m \u001b[1mdust-cleaner\u001b[0m [OPTIONS] \u001b[1m--rpc-user\u001b[0m \u001b[1m--rpc-pass\u001b[0m \r\n\r\n\u001b[1m\u001b[4mCommands:\u001b[0m\r\n \u001b[1mscan\u001b[0m Scan wallet for dust UTXOs\r\n \u001b[1msweep\u001b[0m Create a PSBT sweeping all dust UTXOs\r\n \u001b[1mhelp\u001b[0m Print this message or the help of the given subcommand(s)\r\n\r\n\u001b[1m\u001b[4mOptions:\u001b[0m\r\n \u001b[1m--rpc-url\u001b[0m Bitcoin Core RPC URL [default: http://127.0.0.1:18443]\r\n \u001b[1m--rpc-user\u001b[0m \r\n \u001b[1m--rpc-pass\u001b[0m \r\n \u001b[1m--threshold\u001b[0m Custom dust threshold in sats. If omitted, uses per-script-type thresholds\r\n \u001b[1m-h\u001b[0m, \u001b[1m--help\u001b[0m Print help\r\n"] +[12.982709, "o", "\u001b[?2004h\u001b]0;jolah1@DESKTOP-N4M1LE0: ~/dev/dust-cleaner\u0007\u001b[01;32mjolah1@DESKTOP-N4M1LE0\u001b[00m:\u001b[01;34m~/dev/dust-cleaner\u001b[00m$ "] +[15.274456, "o", "c"] +[15.510141, "o", "a"] +[15.687383, "o", "r"] +[15.980204, "o", "g"] +[16.508834, "o", "o"] +[17.879149, "o", " "] +[18.482939, "o", "r"] +[18.748675, "o", "u"] +[19.060231, "o", "n"] +[19.412957, "o", " "] +[22.445408, "o", "-"] +[22.642001, "o", "-"] +[22.924427, "o", " "] +[23.237142, "o", "-"] +[23.458555, "o", "-"] +[25.72683, "o", "p"] +[26.257663, "o", "\b\u001b[K"] +[26.766145, "o", "r"] +[27.368655, "o", "p"] +[27.735021, "o", "c"] +[30.695653, "o", "-"] +[31.182338, "o", "p"] +[33.338241, "o", "\b\u001b[K"] +[33.578218, "o", "\b\u001b[K"] +[34.803893, "o", "-"] +[35.324833, "o", "u"] +[35.888364, "o", "s"] +[36.083225, "o", "e"] +[36.398795, "o", "r"] +[37.953145, "o", " "] +[38.34731, "o", "u"] +[38.676875, "o", "s"] +[38.937002, "o", "e"] +[39.227316, "o", "r"] +[39.696359, "o", " "] +[40.23407, "o", "-"] +[40.448878, "o", "-"] +[41.333096, "o", "r"] +[41.636531, "o", "p"] +[42.040403, "o", "c"] +[43.378474, "o", "-"] +[43.876399, "o", "p"] +[44.168749, "o", "a"] +[44.48426, "o", "s"] +[44.710614, "o", "s \r"] +[44.947755, "o", " "] +[45.496174, "o", "p"] +[45.79453, "o", "a"] +[46.116484, "o", "s"] +[46.332291, "o", "s"] +[46.501898, "o", " "] +[47.26367, "o", "-"] +[47.459406, "o", "-"] +[48.382401, "o", "t"] +[48.699388, "o", "h"] +[49.072755, "o", "r"] +[49.441081, "o", "e"] +[50.802838, "o", "s"] +[50.933947, "o", "h"] +[51.624798, "o", "o"] +[51.896608, "o", "l"] +[52.192583, "o", "d"] +[52.876343, "o", " "] +[53.534242, "o", "1"] +[53.955866, "o", "0"] +[54.145991, "o", "0"] +[54.384159, "o", "0"] +[55.356373, "o", " "] +[55.933721, "o", "s"] +[56.281976, "o", "c"] +[56.899056, "o", "a"] +[57.043523, "o", "n"] +[61.326621, "o", "\r\n\u001b[?2004l\r"] +[61.51143, "o", "\u001b[1m\u001b[92m Finished\u001b[0m `dev` profile [unoptimized + debuginfo] target(s) in 0.09s\r\n"] +[61.519195, "o", "\u001b[1m\u001b[92m Running\u001b[0m `target/debug/dust-cleaner --rpc-user user --rpc-pass pass --threshold 1000 scan`\r\n"] +[61.575349, "o", "Found 9 total UTXOs (threshold: 1000 sats (custom))\r\n\r\n"] +[61.576409, "o", "⚠️ DUST UTXOs (3 found):\r\n 500 sats | 84e32362d15b14d069934ef2fe7a5d7864323d59f6936ef361bce812c2a8c10e:0 | P2WPKH | bcrt1q4x2qz6a7xrau48hflygphv75vmwx60v6xg9dqz\r\n"] +[61.576935, "o", " 300 sats | 1b6cceaba9b1555ad385f3273e3a726a7f3e4a7d1091128d8628caa38d8514cd:0 | P2WPKH | bcrt1q62sx9egww58us7up2f5w3lf2vspeyrjqwupr2q\r\n 800 sats | 1a51b81e315379f2b036f5bdaf4862edfe3b3527c7596c475918e7f8b97ba6a1:0 | P2WPKH | bcrt1qyk3sa24pnfrex8gneldjdwe2n8ed0yhj83uy8s\r\n\r\n✅ CLEAN UTXOs (6 found):\r\n 4999973630 sats | 84e32362d15b14d069934ef2fe7a5d7864323d59f6936ef361bce812c2a8c10e:1\r\n 5000000000 sats | f50406678677be7e407d55ad7eda96df662237555cc5da5b26cc740838eca7ec:0\r\n 4999999860 sats | 81452410ca2bb3de54916a51a0c1b00f1c723bcef470d812895a01f33abb6500:0\r\n 5000000000 sats | e16c27cbd32cbbeb7f1e3d03fd26c77ae4f2c42f8bb122b77dc9d61cdddc8bcd:0\r\n 4999994210 sats | 1a51b81e315379f2b036f5bdaf4862edfe3b3527c7596c475918e7f8b97ba6a1:1\r\n 5000000000 sats | 8789c76955d1107de5731d618424372b5c6467254ae960a214938c16a6852740:0\r\n\r\n─────────────────────────────────────────\r\n📊 Summary\r\n Total UTXOs: 9"] +[61.577538, "o", "\r\n Dust UTXOs: 3 (1600 sats)\r\n Clean UTXOs: 6 (29999967700 sats)\r\n Threshold: 1000 sats (custom)\r\n\r\n 💡 Run 'sweep' to consolidate dust into a single UTXO\r\n─────────────────────────────────────────\r\n"] +[61.578418, "o", "\u001b[?2004h\u001b]0;jolah1@DESKTOP-N4M1LE0: ~/dev/dust-cleaner\u0007\u001b[01;32mjolah1@DESKTOP-N4M1LE0\u001b[00m:\u001b[01;34m~/dev/dust-cleaner\u001b[00m$ "] +[70.912916, "o", "c"] +[71.007195, "o", "l"] +[71.611786, "o", "e"] +[73.418187, "o", "a"] +[73.593535, "o", "r"] +[75.662852, "o", "\r\n\u001b[?2004l\r"] +[75.667244, "o", "\u001b[H\u001b[2J\u001b[3J"] +[75.66789, "o", "\u001b[?2004h\u001b]0;jolah1@DESKTOP-N4M1LE0: ~/dev/dust-cleaner\u0007\u001b[01;32mjolah1@DESKTOP-N4M1LE0\u001b[00m:\u001b[01;34m~/dev/dust-cleaner\u001b[00m$ "] +[87.016649, "o", "\u001b[7mcargo run -- --rpc-user user --rpc-pass\u001b[27m\u001b[7m \u001b[27m\u001b[7mpass --threshold 1000 sweep --dry-run\u001b[27m"] +[87.973369, "o", "\u0007\u001b[A\r\u001b]0;jolah1@DESKTOP-N4M1LE0: ~/dev/dust-cleaner\u0007\u001b[01;32mjolah1@DESKTOP-N4M1LE0\u001b[00m:\u001b[01;34m~/dev/dust-cleaner\u001b[00m$ cargo run -- --rpc-user user --rpc-pass pass --threshold 1000 sweep --dry-run"] +[91.891958, "o", "\r\n\u001b[?2004l\r"] +[92.04665, "o", "\u001b[1m\u001b[92m Finished\u001b[0m `dev` profile [unoptimized + debuginfo] target(s) in 0.07s\r\n"] +[92.053677, "o", "\u001b[1m\u001b[92m Running\u001b[0m `target/debug/dust-cleaner --rpc-user user --rpc-pass pass --threshold 1000 sweep --dry-run`\r\n"] +[92.070096, "o", "Found 3 dust UTXOs to sweep:\r\n 500 sats | 84e32362d15b14d069934ef2fe7a5d7864323d59f6936ef361bce812c2a8c10e:0\r\n 300 sats | 1b6cceaba9b1555ad385f3273e3a726a7f3e4a7d1091128d8628caa38d8514cd:0\r\n 800 sats | 1a51b81e315379f2b036f5bdaf4862edfe3b3527c7596c475918e7f8b97ba6a1:0\r\n\r\n🔍 Dry Run — no PSBT created\r\n\r\n Method: Consolidate\r\n Dust inputs: 3\r\n Total dust: 1600 sats\r\n Funder UTXO: 5000000000 sats\r\n Estimated fee: 626 sats\r\n Estimated output: 5000000974 sats\r\n\r\n Run without --dry-run to create the PSBT.\r\n"] +[92.071323, "o", "\u001b[?2004h\u001b]0;jolah1@DESKTOP-N4M1LE0: ~/dev/dust-cleaner\u0007\u001b[01;32mjolah1@DESKTOP-N4M1LE0\u001b[00m:\u001b[01;34m~/dev/dust-cleaner\u001b[00m$ "] +[116.248237, "o", "\u001b[7mcargo run -- --rpc-user user --rpc-pass\u001b[27m\u001b[7m \u001b[27m\u001b[7mpass --threshold 1000 sweep --method op-return --dry-run\u001b[27m"] +[118.796131, "o", "\u0007\u001b[A\b\b\b\b\b\b\b\b\b\b\b\b\b\bcargo run -- --rpc-user user --rpc-pass pass --threshold 1000 sweep --method op-return --dry-run"] +[119.62632, "o", "\r\n"] +[119.626848, "o", "\u001b[?2004l\r"] +[119.78859, "o", "\u001b[1m\u001b[92m Finished\u001b[0m `dev` profile [unoptimized + debuginfo] target(s) in 0.07s\r\n"] +[119.794165, "o", "\u001b[1m\u001b[92m Running\u001b[0m `target/debug/dust-cleaner --rpc-user user --rpc-pass pass --threshold 1000 sweep --method op-return --dry-run`\r\n"] +[119.815471, "o", "Found 3 dust UTXOs to sweep:\r\n 500 sats | 84e32362d15b14d069934ef2fe7a5d7864323d59f6936ef361bce812c2a8c10e:0\r\n 300 sats | 1b6cceaba9b1555ad385f3273e3a726a7f3e4a7d1091128d8628caa38d8514cd:0\r\n 800 sats | 1a51b81e315379f2b036f5bdaf4862edfe3b3527c7596c475918e7f8b97ba6a1:0\r\n\r\n🔍 Dry Run — no PSBT created\r\n\r\n Method: OpReturn\r\n Dust inputs: 3\r\n Total dust: 1600 sats\r\n Funder UTXO: 5000000000 sats\r\n Estimated fee: 626 sats\r\n Estimated output: 5000000974 sats\r\n\r\n Run without --dry-run to create the PSBT.\r\n"] +[119.81687, "o", "\u001b[?2004h\u001b]0;jolah1@DESKTOP-N4M1LE0: ~/dev/dust-cleaner\u0007\u001b[01;32mjolah1@DESKTOP-N4M1LE0\u001b[00m:\u001b[01;34m~/dev/dust-cleaner\u001b[00m$ "] +[138.60205, "o", "\u001b[7mcargo run -- --rpc-user user --rpc-pass\u001b[27m\u001b[7m \u001b[27m\u001b[7mpass --threshold 1000 sweep --method op-return\u001b[27m"] +[139.284238, "o", "\u0007\u001b[A\b\b\b\bcargo run -- --rpc-user user --rpc-pass pass --threshold 1000 sweep --method op-return"] +[142.210555, "o", "\r\n"] +[142.21145, "o", "\u001b[?2004l\r"] +[142.36781, "o", "\u001b[1m\u001b[92m Finished\u001b[0m `dev` profile [unoptimized + debuginfo] target(s) in 0.07s\r\n"] +[142.373133, "o", "\u001b[1m\u001b[92m Running\u001b[0m `target/debug/dust-cleaner --rpc-user user --rpc-pass pass --threshold 1000 sweep --method op-return`\r\n"] +[142.406946, "o", "Found 3 dust UTXOs to sweep:\r\n 500 sats | 84e32362d15b14d069934ef2fe7a5d7864323d59f6936ef361bce812c2a8c10e:0\r\n 300 sats | 1b6cceaba9b1555ad385f3273e3a726a7f3e4a7d1091128d8628caa38d8514cd:0\r\n 800 sats | 1a51b81e315379f2b036f5bdaf4862edfe3b3527c7596c475918e7f8b97ba6a1:0\r\n\r\n🔥 Method: op-return — dust burned to miner fees\r\n Output: OP_RETURN (\"ash\" — ashes to ashes, dust to dust)\r\n\r\n ℹ️ Using clean UTXO to fund fees: 5000000000 sats\r\n"] +[142.54227, "o", "\r\n📊 Sweep Summary:\r\n Dust inputs: 3\r\n Total dust: 1600 sats\r\n\r\n🧹 Sweep PSBT (base64):\r\ncHNidP8BAPoCAAAABEAnhaYWjJMUomDpSiVnZFwrNySEYR1z5X0Q0VVpx4mHAAAAAAD9////DsGowhLovGHzbpP2WT0yZHhdev7yTpNp0BRb0WIj44QAAAAAAP3////NFIWNo8ooho0SkRB9Sj5/anI6PifzhdNaVbGpq85sGwAAAAAA/f///6Gme7n45xhZR2xZxyc1O/7tYkivvfU2sPJ5UzEeuFEaAAAAAAD9////AwAAAAAAAAAABWoDYXNoQAYAAAAAAAAWABSttCLfgG4/NURqfAHEpBsd1fcygQTkBSoBAAAAFgAUQJeNT5jyl6LaNiWtzN2MWndlLLYAAAAAAAEAgwIAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/////AlUA/////wIA8gUqAQAAABYAFF6XxDXy7piDz1AhPspCGSm9yBVQAAAAAAAAAAAmaiSqIant4vYcP3HR3v0/qZnfo2lTdVxpBol5mWK0i+vYNpdOjPkAAAAAAQEfAPIFKgEAAAAWABRel8Q18u6Yg89QIT7KQhkpvcgVUCIGAoENJdbT6mMTuD2hDkCwv1V4S5QlOyEdjjW5Ppn9OjIqGJi7BkpUAACAAQAAgAAAAIAAAAAAAAAAAAABAHECAAAAAe4a44e+jKVcfWdeXARqGank2Mtorlv5e/5x4ZMeK5Q4AAAAAAD9////AvQBAAAAAAAAFgAUqZQBa74w+8qe6fkQG7PUZtxtPZr+igUqAQAAABYAFDfwRlg6Y/RLC+tMJGvwLMmNTq8AaQAAAAEBH/QBAAAAAAAAFgAUqZQBa74w+8qe6fkQG7PUZtxtPZoiBgMJaNSmn50loDjR98nh/gMrTvFXaGo7NPI2Xl19VMnY6xiYuwZKVAAAgAEAAIAAAACAAAAAABkAAAAAAQBSAgAAAAHsI1uFz5DnI/y7jeQT9iTIKt0jaOpEnApHws6hSNGO8AIAAAAA/f///wEsAQAAAAAAABYAFNKgYuUOdQ/Ie4FSaOj9KmQDkg5AaQAAAAEBHywBAAAAAAAAFgAU0qBi5Q51D8h7gVJo6P0qZAOSDkAiBgLzk3PR1gOWaC0X7bvc/nHr3+5wc5+zPvgPYa1F6FzImhiYuwZKVAAAgAEAAIAAAACAAAAAABgAAAAAAQBxAgAAAAHsI1uFz5DnI/y7jeQT9iTIKt0jaOpEnApHws6hSNGO8AEAAAAA/f///wIgAwAAAAAAABYAFCWjDqqhmkeTHRPP2ya7Kpny15LyYtsFKgEAAAAWABTLA2JCoOsM139b+ft5JSupavi5H2kAAAABAR8gAwAAAAAAABYAFCWjDqqhmkeTHRPP2ya7Kpny15LyIgYDSYFWXz0zDtQ8nb819SBLfP+t/N6mCOsWq2X0ROrHHtkYmLsGSlQAAIABAACAAAAAgAAAAAAaAAAAAAAiAgNjLZZToK8U19OxlBXF1gJK6lJaPQ7gG6/izvhnnt4m6RiYuwZKVAAAgAEAAIAAAACAAQAAABIAAAAAIgICS/cUTihGMLrbjuES/wYIQR9uEn88RIEQ3tD9NdHVr08YmLsGSlQAAIABAACAAAAAgAAAAAAbAAAAAA==\r\n\r\n💡 Next steps:\r\n Inspect: bitcoin-cli -rpcport=18443 -rpcuser=user -rpcpassword=pass decodepsbt \r\n Sign: bitcoin-cli -rpcport=18443 -rpcuser=user -rpcpassword=pass walletprocesspsbt \r\n Send: bitcoin-cli -rpcport=18443 -rpcuser=user -rpcpassword=pass sendrawtransaction \r\n"] +[142.54348, "o", "\u001b[?2004h\u001b]0;jolah1@DESKTOP-N4M1LE0: ~/dev/dust-cleaner\u0007\u001b[01;32mjolah1@DESKTOP-N4M1LE0\u001b[00m:\u001b[01;34m~/dev/dust-cleaner\u001b[00m$ "] +[150.79263, "r", "170x44"] +[150.79337, "o", "\r\u001b[K\r\u001b]0;jolah1@DESKTOP-N4M1LE0: ~/dev/dust-cleaner\u0007\u001b[01;32mjolah1@DESKTOP-N4M1LE0\u001b[00m:\u001b[01;34m~/dev/dust-cleaner\u001b[00m$ "] +[171.879902, "o", "\u001b[?2004l\r\r\nexit\r\n"] From e052babb4329ad42cbc9a3e7d5b3e36e7f956548 Mon Sep 17 00:00:00 2001 From: jolah1 Date: Fri, 10 Apr 2026 06:59:04 +0100 Subject: [PATCH 03/10] ascii url error fixed --- README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index b25f486..13a5195 100644 --- a/README.md +++ b/README.md @@ -9,8 +9,7 @@ Built in Rust! Example of scanning for dust UTXOs and performing a dry-run sweep: - -[![asciicast](https://asciinema.org/a/1dC18FaxI2Rjif8F.svg)](https://asciinema.org/a/1dC18FaxI2Rjif8F) +[![asciicast](https://asciinema.org/a/911545.svg)](https://asciinema.org/a/911545) ## What is a Dust Attack? From 417fc9b2a20e57b014eceb625c608c40b0618740 Mon Sep 17 00:00:00 2001 From: jolah1 Date: Fri, 10 Apr 2026 10:16:20 +0100 Subject: [PATCH 04/10] CI pipline improved with clippy checks --- .github/workflows/rust.yml | 36 ++++++++++++--- src/analyzer.rs | 90 +++++++++++++++++++++++++------------- src/cli.rs | 3 +- src/lib.rs | 4 +- src/main.rs | 46 ++++++++++++------- src/psbt_builder.rs | 34 +++++++------- src/rpc.rs | 14 +++--- src/scanner.rs | 4 +- src/types.rs | 2 +- 9 files changed, 148 insertions(+), 85 deletions(-) diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 9fd45e0..72708f4 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -1,4 +1,4 @@ -name: Rust +name: CI on: push: @@ -10,13 +10,35 @@ env: CARGO_TERM_COLOR: always jobs: - build: + test: + name: Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - name: Build + run: cargo build --verbose + - name: Run tests + run: cargo test --verbose + clippy: + name: Clippy runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy + - name: Clippy check + run: cargo clippy -- -D warnings + fmt: + name: Format + runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - name: Build - run: cargo build --verbose - - name: Run tests - run: cargo test --verbose + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt + - name: Format check + run: cargo fmt --check \ No newline at end of file diff --git a/src/analyzer.rs b/src/analyzer.rs index adddb15..0103f8c 100644 --- a/src/analyzer.rs +++ b/src/analyzer.rs @@ -1,5 +1,5 @@ -use bitcoincore_rpc::bitcoincore_rpc_json::ListUnspentResultEntry; use crate::types::Utxo; +use bitcoincore_rpc::bitcoincore_rpc_json::ListUnspentResultEntry; /// Dust thresholds per script type based on fee cost to spend pub const DUST_P2PKH: u64 = 546; @@ -35,9 +35,15 @@ pub fn detect_script_type(address: &str) -> ScriptType { ScriptType::P2PKH } else if address.starts_with("3") { ScriptType::P2SH - } else if address.starts_with("bc1q") || address.starts_with("tb1q") || address.starts_with("bcrt1q") { + } else if address.starts_with("bc1q") + || address.starts_with("tb1q") + || address.starts_with("bcrt1q") + { ScriptType::P2WPKH - } else if address.starts_with("bc1p") || address.starts_with("tb1p") || address.starts_with("bcrt1p") { + } else if address.starts_with("bc1p") + || address.starts_with("tb1p") + || address.starts_with("bcrt1p") + { ScriptType::P2TR } else { ScriptType::Unknown @@ -53,12 +59,10 @@ pub fn is_dust(amount_sats: u64, threshold: u64) -> bool { pub fn is_dust_smart(amount_sats: u64, address: Option<&str>, user_threshold: Option) -> bool { let threshold = match user_threshold { Some(t) => t, - None => { - match address { - Some(addr) => detect_script_type(addr).dust_threshold(), - None => DUST_DEFAULT, - } - } + None => match address { + Some(addr) => detect_script_type(addr).dust_threshold(), + None => DUST_DEFAULT, + }, }; amount_sats < threshold } @@ -93,9 +97,10 @@ pub fn classify_utxos_smart( let mut clean = vec![]; for utxo in utxos { - let address = utxo.address - .as_ref() - .map(|a| a.clone().assume_checked().to_string()); + let address = utxo + .address + .as_ref() + .map(|a| a.clone().assume_checked().to_string()); let addr_str = address.as_deref(); @@ -110,10 +115,7 @@ pub fn classify_utxos_smart( } #[allow(dead_code)] -pub fn classify_owned_utxos( - utxos: Vec, - threshold: u64, -) -> (Vec, Vec) { +pub fn classify_owned_utxos(utxos: Vec, threshold: u64) -> (Vec, Vec) { let mut dust = vec![]; let mut clean = vec![]; @@ -161,23 +163,38 @@ mod tests { #[test] fn test_detect_script_type_p2pkh() { - assert_eq!(detect_script_type("1A1zP1eP5QGefi2DMPTfTL5SLmv7Divf"), ScriptType::P2PKH); + assert_eq!( + detect_script_type("1A1zP1eP5QGefi2DMPTfTL5SLmv7Divf"), + ScriptType::P2PKH + ); } #[test] fn test_detect_script_type_p2sh() { - assert_eq!(detect_script_type("3J98t1WpEZ73CNmQviecrnyiWrnqRhWNLy"), ScriptType::P2SH); + assert_eq!( + detect_script_type("3J98t1WpEZ73CNmQviecrnyiWrnqRhWNLy"), + ScriptType::P2SH + ); } #[test] fn test_detect_script_type_p2wpkh() { - assert_eq!(detect_script_type("bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq"), ScriptType::P2WPKH); - assert_eq!(detect_script_type("bcrt1q60z267jynd9swr3dk7k3tmjqmlmsr8xsn4c8mz"), ScriptType::P2WPKH); + assert_eq!( + detect_script_type("bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq"), + ScriptType::P2WPKH + ); + assert_eq!( + detect_script_type("bcrt1q60z267jynd9swr3dk7k3tmjqmlmsr8xsn4c8mz"), + ScriptType::P2WPKH + ); } #[test] fn test_detect_script_type_p2tr() { - assert_eq!(detect_script_type("bc1p0xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vqzk5jj0"), ScriptType::P2TR); + assert_eq!( + detect_script_type("bc1p0xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vqzk5jj0"), + ScriptType::P2TR + ); } #[test] @@ -191,16 +208,32 @@ mod tests { #[test] fn test_is_dust_smart_p2wpkh() { // 294 is NOT dust for P2WPKH (equal to threshold) - assert!(!is_dust_smart(294, Some("bcrt1q60z267jynd9swr3dk7k3tmjqmlmsr8xsn4c8mz"), None)); + assert!(!is_dust_smart( + 294, + Some("bcrt1q60z267jynd9swr3dk7k3tmjqmlmsr8xsn4c8mz"), + None + )); // 293 IS dust for P2WPKH - assert!(is_dust_smart(293, Some("bcrt1q60z267jynd9swr3dk7k3tmjqmlmsr8xsn4c8mz"), None)); + assert!(is_dust_smart( + 293, + Some("bcrt1q60z267jynd9swr3dk7k3tmjqmlmsr8xsn4c8mz"), + None + )); } #[test] fn test_is_dust_smart_user_override() { // user sets threshold to 1000, overrides per-type - assert!(is_dust_smart(500, Some("bcrt1q60z267jynd9swr3dk7k3tmjqmlmsr8xsn4c8mz"), Some(1000))); - assert!(!is_dust_smart(1000, Some("bcrt1q60z267jynd9swr3dk7k3tmjqmlmsr8xsn4c8mz"), Some(1000))); + assert!(is_dust_smart( + 500, + Some("bcrt1q60z267jynd9swr3dk7k3tmjqmlmsr8xsn4c8mz"), + Some(1000) + )); + assert!(!is_dust_smart( + 1000, + Some("bcrt1q60z267jynd9swr3dk7k3tmjqmlmsr8xsn4c8mz"), + Some(1000) + )); } #[test] @@ -221,10 +254,7 @@ mod tests { #[test] fn test_classify_owned_utxos_all_dust() { - let utxos = vec![ - make_utxo("txid1", 0, 100), - make_utxo("txid2", 0, 200), - ]; + let utxos = vec![make_utxo("txid1", 0, 100), make_utxo("txid2", 0, 200)]; let (dust, clean) = classify_owned_utxos(utxos, 1000); assert_eq!(dust.len(), 2); assert_eq!(clean.len(), 0); @@ -259,4 +289,4 @@ mod tests { assert_eq!(dust.len(), 2); assert_eq!(clean.len(), 1); } -} \ No newline at end of file +} diff --git a/src/cli.rs b/src/cli.rs index 32fd8b0..58c71a3 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -13,7 +13,6 @@ pub struct Cli { #[arg(long)] pub rpc_user: String, - #[arg(long)] pub rpc_pass: String, @@ -47,4 +46,4 @@ pub enum Commands { #[arg(long, value_enum, default_value = "consolidate")] method: SweepMethod, }, -} \ No newline at end of file +} diff --git a/src/lib.rs b/src/lib.rs index 52ae9f1..51e1dbf 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,6 +1,6 @@ +pub mod analyzer; pub mod cli; +pub mod psbt_builder; pub mod rpc; pub mod scanner; -pub mod analyzer; -pub mod psbt_builder; pub mod types; diff --git a/src/main.rs b/src/main.rs index de7667b..efde798 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,8 +1,8 @@ +mod analyzer; mod cli; +mod psbt_builder; mod rpc; mod scanner; -mod analyzer; -mod psbt_builder; mod types; use clap::Parser; @@ -23,7 +23,10 @@ fn main() -> anyhow::Result<()> { Some(t) => format!("{} sats (custom)", t), None => "per-script-type (P2PKH:546, P2WPKH:294, P2TR:294, P2SH:540)".to_string(), }; - println!("Found {} total UTXOs (threshold: {})\n", total_utxos, threshold_display); + println!( + "Found {} total UTXOs (threshold: {})\n", + total_utxos, threshold_display + ); let (dust_utxos, clean_utxos) = analyzer::classify_utxos_smart(utxos, user_threshold); @@ -32,9 +35,11 @@ fn main() -> anyhow::Result<()> { println!(" none"); } for utxo in &dust_utxos { - let addr = utxo.address.as_ref() - .map(|a| a.clone().assume_checked().to_string()) - .unwrap_or_else(|| "unknown".to_string()); + let addr = utxo + .address + .as_ref() + .map(|a| a.clone().assume_checked().to_string()) + .unwrap_or_else(|| "unknown".to_string()); let script_type = analyzer::detect_script_type(&addr); println!( " {} sats | {}:{} | {:?} | {}", @@ -65,8 +70,16 @@ fn main() -> anyhow::Result<()> { println!("\n─────────────────────────────────────────"); println!("📊 Summary"); println!(" Total UTXOs: {}", total_utxos); - println!(" Dust UTXOs: {} ({} sats)", dust_utxos.len(), total_dust_sats); - println!(" Clean UTXOs: {} ({} sats)", clean_utxos.len(), total_clean_sats); + println!( + " Dust UTXOs: {} ({} sats)", + dust_utxos.len(), + total_dust_sats + ); + println!( + " Clean UTXOs: {} ({} sats)", + clean_utxos.len(), + total_clean_sats + ); println!(" Threshold: {}", threshold_display); if !dust_utxos.is_empty() { @@ -80,12 +93,12 @@ fn main() -> anyhow::Result<()> { 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,7 +108,7 @@ 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"); @@ -104,11 +117,14 @@ fn main() -> anyhow::Result<()> { println!(" Total dust: {} sats", result.total_dust_sats); println!(" Funder UTXO: {} sats", result.funder_sats); println!(" Estimated fee: {} sats", result.estimated_fee_sats); - println!(" Estimated output: {} sats", result.estimated_output_sats); + println!( + " Estimated output: {} sats", + result.estimated_output_sats + ); println!("\n Run without --dry-run to create the PSBT."); return Ok(()); } - + let result = match method { cli::SweepMethod::Consolidate => { println!("\n📎 Method: consolidate — dust swept to fresh address"); @@ -120,7 +136,7 @@ fn main() -> anyhow::Result<()> { 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); @@ -134,4 +150,4 @@ fn main() -> anyhow::Result<()> { } Ok(()) -} \ No newline at end of file +} diff --git a/src/psbt_builder.rs b/src/psbt_builder.rs index d1aa1b0..5ce45a6 100644 --- a/src/psbt_builder.rs +++ b/src/psbt_builder.rs @@ -31,12 +31,10 @@ pub fn build_sweep_psbt( "Cannot sweep: no clean UTXOs available to fund transaction fees.\nTip: fund your wallet first with a non-dust amount." ))?; - let mut all_inputs: Vec = vec![ - serde_json::json!({ - "txid": funder.txid.to_string(), - "vout": funder.vout, - }) - ]; + 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!({ @@ -91,9 +89,9 @@ pub fn dry_run_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." - ))?; + .ok_or_else(|| { + anyhow::anyhow!("Cannot sweep: no clean UTXOs available to fund transaction fees.") + })?; let total_dust_sats: u64 = dust_utxos.iter().map(|u| u.amount.to_sat()).sum(); let funder_sats = funder.amount.to_sat(); @@ -126,9 +124,9 @@ pub fn build_op_return_psbt( 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." - ))?; + .ok_or_else(|| { + anyhow::anyhow!("Cannot sweep: no clean UTXOs available to fund transaction fees.") + })?; println!( "\n ℹ️ Using clean UTXO to fund fees: {} sats", @@ -136,12 +134,10 @@ pub fn build_op_return_psbt( ); // 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, - }) - ]; + 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!({ @@ -195,4 +191,4 @@ pub fn build_op_return_psbt( dust_input_count: dust_utxos.len(), total_dust_sats, }) -} \ No newline at end of file +} diff --git a/src/rpc.rs b/src/rpc.rs index af22308..8e579ca 100644 --- a/src/rpc.rs +++ b/src/rpc.rs @@ -1,11 +1,11 @@ use bitcoincore_rpc::{Auth, Client}; pub fn connect(url: &str, user: &str, pass: &str) -> anyhow::Result { - let client = Client::new( - url, - Auth::UserPass(user.into(), pass.into()), - ).map_err(|e| anyhow::anyhow!( - "Failed to create RPC client: {}\nCheck your --rpc-url, --rpc-user and --rpc-pass", e - ))?; + let client = Client::new(url, Auth::UserPass(user.into(), pass.into())).map_err(|e| { + anyhow::anyhow!( + "Failed to create RPC client: {}\nCheck your --rpc-url, --rpc-user and --rpc-pass", + e + ) + })?; Ok(client) -} \ No newline at end of file +} diff --git a/src/scanner.rs b/src/scanner.rs index 0df5e1c..eac916b 100644 --- a/src/scanner.rs +++ b/src/scanner.rs @@ -1,5 +1,5 @@ -use bitcoincore_rpc::{Client, RpcApi}; use bitcoincore_rpc::bitcoincore_rpc_json::ListUnspentResultEntry; +use bitcoincore_rpc::{Client, RpcApi}; pub fn fetch_utxos(client: &Client) -> anyhow::Result> { let utxos = client.list_unspent(None, None, None, None, None) @@ -14,4 +14,4 @@ pub fn fetch_utxos(client: &Client) -> anyhow::Result Date: Sun, 12 Apr 2026 03:34:49 +0100 Subject: [PATCH 05/10] CI updated for Github actions --- .github/workflows/rust.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 72708f4..8bf5ad7 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -8,6 +8,7 @@ on: env: CARGO_TERM_COLOR: always + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true jobs: test: From 732eca546eed6a66d2e0bd9749149d3238bcf5fe Mon Sep 17 00:00:00 2001 From: jolah1 Date: Sun, 12 Apr 2026 03:40:14 +0100 Subject: [PATCH 06/10] CI updated to use target node V --- .github/workflows/rust.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 8bf5ad7..72708f4 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -8,7 +8,6 @@ on: env: CARGO_TERM_COLOR: always - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true jobs: test: From 65515cc6a52f0a4134c556043e28e5e97663e4a8 Mon Sep 17 00:00:00 2001 From: jolah1 Date: Sun, 12 Apr 2026 03:56:06 +0100 Subject: [PATCH 07/10] readme cleanup --- README.md | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 13a5195..2c5e489 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,6 @@ A Bitcoin CLI tool that detects dust attack UTXOs in your wallet and sweeps them safely using PSBTs ([BIP174](https://github.com/bitcoin/bips/blob/master/bip-0174.mediawiki)). -Built in Rust! ---- ## Demo @@ -152,6 +150,17 @@ cHNidP8BA... Send: bitcoin-cli sendrawtransaction ``` +### Sweep using OP_RETURN (burn to miner fees) +```bash +dust-cleaner --rpc-user --rpc-pass sweep --method op-return +``` + +Example output: +``` +🔥 Method: op-return — dust burned to miner fees + Output: OP_RETURN ("ash" — ashes to ashes, dust to dust) +``` + ### Custom threshold ```bash From 60ab5bb93094feef3c2e677201a31408471b93f3 Mon Sep 17 00:00:00 2001 From: jolah1 Date: Sun, 12 Apr 2026 04:19:00 +0100 Subject: [PATCH 08/10] refactor: helper functions extracted --- src/main.rs | 268 ++++++++++++++++++++++---------------------- src/psbt_builder.rs | 98 +++++++--------- src/scanner.rs | 2 +- 3 files changed, 174 insertions(+), 194 deletions(-) diff --git a/src/main.rs b/src/main.rs index efde798..192274d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,153 +1,151 @@ -mod analyzer; mod cli; -mod psbt_builder; mod rpc; mod scanner; +mod analyzer; +mod psbt_builder; mod types; use clap::Parser; -use cli::{Cli, Commands}; +use cli::{Cli, Commands, SweepMethod}; +use bitcoincore_rpc::Client; + +fn handle_scan(client: &Client, user_threshold: Option) -> anyhow::Result<()> { + let utxos = scanner::fetch_utxos(client)?; + let total_utxos = utxos.len(); + + let threshold_display = match user_threshold { + Some(t) => format!("{} sats (custom)", t), + None => "per-script-type (P2PKH:546, P2WPKH:294, P2TR:294, P2SH:540)".to_string(), + }; + println!("Found {} total UTXOs (threshold: {})\n", total_utxos, threshold_display); + + let (dust_utxos, clean_utxos) = analyzer::classify_utxos_smart(utxos, user_threshold); + + println!("⚠️ DUST UTXOs ({} found):", dust_utxos.len()); + if dust_utxos.is_empty() { + println!(" none"); + } + for utxo in &dust_utxos { + let addr = utxo.address.as_ref() + .map(|a| a.clone().assume_checked().to_string()) + .unwrap_or_else(|| "unknown".to_string()); + let script_type = analyzer::detect_script_type(&addr); + println!( + " {} sats | {}:{} | {:?} | {}", + utxo.amount.to_sat(), + utxo.txid, + utxo.vout, + script_type, + addr + ); + } + + println!("\n✅ CLEAN UTXOs ({} found):", clean_utxos.len()); + if clean_utxos.is_empty() { + println!(" none"); + } + for utxo in &clean_utxos { + println!( + " {} sats | {}:{}", + utxo.amount.to_sat(), + utxo.txid, + utxo.vout + ); + } + + let total_dust_sats: u64 = dust_utxos.iter().map(|u| u.amount.to_sat()).sum(); + let total_clean_sats: u64 = clean_utxos.iter().map(|u| u.amount.to_sat()).sum(); + + println!("\n─────────────────────────────────────────"); + println!("📊 Summary"); + println!(" Total UTXOs: {}", total_utxos); + println!(" Dust UTXOs: {} ({} sats)", dust_utxos.len(), total_dust_sats); + println!(" Clean UTXOs: {} ({} sats)", clean_utxos.len(), total_clean_sats); + println!(" Threshold: {}", threshold_display); + + if !dust_utxos.is_empty() { + println!("\n 💡 Run 'sweep' to consolidate dust into a single UTXO"); + } else { + println!("\n ✅ Wallet is clean — no dust detected"); + } + println!("─────────────────────────────────────────"); + + Ok(()) +} + +fn handle_sweep( + client: &Client, + user_threshold: Option, + dry_run: bool, + method: SweepMethod, +) -> anyhow::Result<()> { + 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!( + " {} sats | {}:{}", + utxo.amount.to_sat(), + utxo.txid, + 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); + println!(" Estimated fee: {} sats", result.estimated_fee_sats); + println!(" Estimated output: {} sats", result.estimated_output_sats); + println!("\n Run without --dry-run to create the PSBT."); + 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)? + } + }; + + 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 "); + println!(" Sign: bitcoin-cli walletprocesspsbt "); + println!(" Send: bitcoin-cli sendrawtransaction "); + + Ok(()) +} fn main() -> anyhow::Result<()> { let cli = Cli::parse(); - let client = rpc::connect(&cli.rpc_url, &cli.rpc_user, &cli.rpc_pass)?; let user_threshold = cli.threshold; match cli.command { - Commands::Scan => { - let utxos = scanner::fetch_utxos(&client)?; - let total_utxos = utxos.len(); - - let threshold_display = match user_threshold { - Some(t) => format!("{} sats (custom)", t), - None => "per-script-type (P2PKH:546, P2WPKH:294, P2TR:294, P2SH:540)".to_string(), - }; - println!( - "Found {} total UTXOs (threshold: {})\n", - total_utxos, threshold_display - ); - - let (dust_utxos, clean_utxos) = analyzer::classify_utxos_smart(utxos, user_threshold); - - println!("⚠️ DUST UTXOs ({} found):", dust_utxos.len()); - if dust_utxos.is_empty() { - println!(" none"); - } - for utxo in &dust_utxos { - let addr = utxo - .address - .as_ref() - .map(|a| a.clone().assume_checked().to_string()) - .unwrap_or_else(|| "unknown".to_string()); - let script_type = analyzer::detect_script_type(&addr); - println!( - " {} sats | {}:{} | {:?} | {}", - utxo.amount.to_sat(), - utxo.txid, - utxo.vout, - script_type, - addr - ); - } - - println!("\n✅ CLEAN UTXOs ({} found):", clean_utxos.len()); - if clean_utxos.is_empty() { - println!(" none"); - } - for utxo in &clean_utxos { - println!( - " {} sats | {}:{}", - utxo.amount.to_sat(), - utxo.txid, - utxo.vout - ); - } - - let total_dust_sats: u64 = dust_utxos.iter().map(|u| u.amount.to_sat()).sum(); - let total_clean_sats: u64 = clean_utxos.iter().map(|u| u.amount.to_sat()).sum(); - - println!("\n─────────────────────────────────────────"); - println!("📊 Summary"); - println!(" Total UTXOs: {}", total_utxos); - println!( - " Dust UTXOs: {} ({} sats)", - dust_utxos.len(), - total_dust_sats - ); - println!( - " Clean UTXOs: {} ({} sats)", - clean_utxos.len(), - total_clean_sats - ); - println!(" Threshold: {}", threshold_display); - - if !dust_utxos.is_empty() { - println!("\n 💡 Run 'sweep' to consolidate dust into a single UTXO"); - } else { - println!("\n ✅ Wallet is clean — no dust detected"); - } - println!("─────────────────────────────────────────"); - } - + Commands::Scan => handle_scan(&client, user_threshold)?, 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!( - " {} sats | {}:{}", - utxo.amount.to_sat(), - utxo.txid, - 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); - println!(" Estimated fee: {} sats", result.estimated_fee_sats); - println!( - " Estimated output: {} sats", - result.estimated_output_sats - ); - println!("\n Run without --dry-run to create the PSBT."); - return Ok(()); - } - - 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); - println!("\n🧹 Sweep PSBT (base64):"); - println!("{}", result.psbt); - println!("\n💡 Next steps:"); - println!(" Inspect: bitcoin-cli -rpcport=18443 -rpcuser=user -rpcpassword=pass decodepsbt "); - println!(" Sign: bitcoin-cli -rpcport=18443 -rpcuser=user -rpcpassword=pass walletprocesspsbt "); - println!(" Send: bitcoin-cli -rpcport=18443 -rpcuser=user -rpcpassword=pass sendrawtransaction "); + handle_sweep(&client, user_threshold, dry_run, method)? } } Ok(()) -} +} \ No newline at end of file diff --git a/src/psbt_builder.rs b/src/psbt_builder.rs index 5ce45a6..8932920 100644 --- a/src/psbt_builder.rs +++ b/src/psbt_builder.rs @@ -15,33 +15,49 @@ pub struct DryRunResult { pub estimated_output_sats: u64, } -pub fn build_sweep_psbt( - client: &Client, - dust_utxos: &[ListUnspentResultEntry], - clean_utxos: &[ListUnspentResultEntry], -) -> anyhow::Result { - if dust_utxos.is_empty() { - anyhow::bail!("No dust UTXOs to sweep"); - } +//helpers - let funder = clean_utxos +fn select_funder( + clean_utxos: &[ListUnspentResultEntry], +) -> anyhow::Result<&ListUnspentResultEntry> { + 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.\nTip: fund your wallet first with a non-dust amount." - ))?; - - let mut all_inputs: Vec = vec![serde_json::json!({ - "txid": funder.txid.to_string(), - "vout": funder.vout, - })]; + )) +} +fn build_inputs( + funder: &ListUnspentResultEntry, + dust_utxos: &[ListUnspentResultEntry], +) -> Vec { + let mut inputs = vec![ + serde_json::json!({ + "txid": funder.txid.to_string(), + "vout": funder.vout, + }) + ]; for utxo in dust_utxos { - all_inputs.push(serde_json::json!({ + inputs.push(serde_json::json!({ "txid": utxo.txid.to_string(), "vout": utxo.vout, })); } + inputs +} + +pub fn build_sweep_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 = select_funder(clean_utxos)?; + let all_inputs = build_inputs(funder, dust_utxos); let out_address = client.get_new_address(None, None)?; let out_address = out_address.assume_checked(); @@ -86,13 +102,7 @@ pub fn dry_run_sweep( 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.") - })?; - + let funder = select_funder(clean_utxos)?; let total_dust_sats: u64 = dust_utxos.iter().map(|u| u.amount.to_sat()).sum(); let funder_sats = funder.amount.to_sat(); @@ -121,51 +131,23 @@ pub fn build_op_return_psbt( 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() - ); + let funder = select_funder(clean_utxos)?; + 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, - })]; + let all_inputs = build_inputs(funder, dust_utxos); - 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 + // "ash" in hex — ashes to ashes, dust to dust 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) - } + { "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", &[ @@ -191,4 +173,4 @@ pub fn build_op_return_psbt( dust_input_count: dust_utxos.len(), total_dust_sats, }) -} +} \ No newline at end of file diff --git a/src/scanner.rs b/src/scanner.rs index eac916b..d880037 100644 --- a/src/scanner.rs +++ b/src/scanner.rs @@ -8,7 +8,7 @@ pub fn fetch_utxos(client: &Client) -> anyhow::Result -rpcuser= -rpcpassword= loadwallet ") } else { anyhow::anyhow!("Failed to fetch UTXOs: {}", msg) } From 2b2df0dced7f9a7a0cc752cb99dc89107dd06741 Mon Sep 17 00:00:00 2001 From: jolah1 Date: Sun, 12 Apr 2026 05:28:16 +0100 Subject: [PATCH 09/10] git commit -m feat:add evn for credentials --- Cargo.toml | 2 +- src/cli.rs | 16 +++++++++------- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 0155ac9..4fc5f04 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,6 @@ edition = "2021" [dependencies] bitcoincore-rpc = "0.19" bitcoin = "0.32" -clap = { version = "4", features = ["derive"] } +clap = { version = "4", features = ["derive", "env"] } anyhow = "1" serde_json = "1.0.149" diff --git a/src/cli.rs b/src/cli.rs index 58c71a3..a03e9bd 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -7,17 +7,19 @@ use clap::{Parser, Subcommand, ValueEnum}; )] pub struct Cli { /// Bitcoin Core RPC URL - #[arg(long, default_value = "http://127.0.0.1:18443")] + #[arg(long, default_value = "http://127.0.0.1:18443", env = "DUST_RPC_URL")] pub rpc_url: String, - #[arg(long)] + /// Bitcoin Core RPC username + #[arg(long, env = "DUST_RPC_USER")] pub rpc_user: String, - #[arg(long)] + /// Bitcoin Core RPC password + #[arg(long, env = "DUST_RPC_PASS")] pub rpc_pass: String, - /// Custom dust threshold in sats. If omitted, uses per-script-type thresholds - #[arg(long)] + /// Dust threshold in sats. If omitted, uses per-script-type thresholds + #[arg(long, env = "DUST_THRESHOLD")] pub threshold: Option, #[command(subcommand)] @@ -42,8 +44,8 @@ pub enum Commands { #[arg(long, default_value = "false")] dry_run: bool, - /// Sweep method: op-return (burn to fees) + /// Sweep method: consolidate (default) or op-return (burn to fees) #[arg(long, value_enum, default_value = "consolidate")] method: SweepMethod, }, -} +} \ No newline at end of file From 91fbe32691ed58cd928441f4e5b97a715358c008 Mon Sep 17 00:00:00 2001 From: jolah1 Date: Sun, 12 Apr 2026 05:36:01 +0100 Subject: [PATCH 10/10] chore: fix formatting --- src/cli.rs | 2 +- src/main.rs | 34 +++++++++++++++++++++++++--------- src/psbt_builder.rs | 17 +++++++++-------- 3 files changed, 35 insertions(+), 18 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index a03e9bd..8e92d5d 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -48,4 +48,4 @@ pub enum Commands { #[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 192274d..334c511 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,13 +1,13 @@ +mod analyzer; mod cli; +mod psbt_builder; mod rpc; mod scanner; -mod analyzer; -mod psbt_builder; mod types; +use bitcoincore_rpc::Client; use clap::Parser; use cli::{Cli, Commands, SweepMethod}; -use bitcoincore_rpc::Client; fn handle_scan(client: &Client, user_threshold: Option) -> anyhow::Result<()> { let utxos = scanner::fetch_utxos(client)?; @@ -17,7 +17,10 @@ fn handle_scan(client: &Client, user_threshold: Option) -> anyhow::Result<( Some(t) => format!("{} sats (custom)", t), None => "per-script-type (P2PKH:546, P2WPKH:294, P2TR:294, P2SH:540)".to_string(), }; - println!("Found {} total UTXOs (threshold: {})\n", total_utxos, threshold_display); + println!( + "Found {} total UTXOs (threshold: {})\n", + total_utxos, threshold_display + ); let (dust_utxos, clean_utxos) = analyzer::classify_utxos_smart(utxos, user_threshold); @@ -26,7 +29,9 @@ fn handle_scan(client: &Client, user_threshold: Option) -> anyhow::Result<( println!(" none"); } for utxo in &dust_utxos { - let addr = utxo.address.as_ref() + let addr = utxo + .address + .as_ref() .map(|a| a.clone().assume_checked().to_string()) .unwrap_or_else(|| "unknown".to_string()); let script_type = analyzer::detect_script_type(&addr); @@ -59,8 +64,16 @@ fn handle_scan(client: &Client, user_threshold: Option) -> anyhow::Result<( println!("\n─────────────────────────────────────────"); println!("📊 Summary"); println!(" Total UTXOs: {}", total_utxos); - println!(" Dust UTXOs: {} ({} sats)", dust_utxos.len(), total_dust_sats); - println!(" Clean UTXOs: {} ({} sats)", clean_utxos.len(), total_clean_sats); + println!( + " Dust UTXOs: {} ({} sats)", + dust_utxos.len(), + total_dust_sats + ); + println!( + " Clean UTXOs: {} ({} sats)", + clean_utxos.len(), + total_clean_sats + ); println!(" Threshold: {}", threshold_display); if !dust_utxos.is_empty() { @@ -105,7 +118,10 @@ fn handle_sweep( println!(" Total dust: {} sats", result.total_dust_sats); println!(" Funder UTXO: {} sats", result.funder_sats); println!(" Estimated fee: {} sats", result.estimated_fee_sats); - println!(" Estimated output: {} sats", result.estimated_output_sats); + println!( + " Estimated output: {} sats", + result.estimated_output_sats + ); println!("\n Run without --dry-run to create the PSBT."); return Ok(()); } @@ -148,4 +164,4 @@ fn main() -> anyhow::Result<()> { } Ok(()) -} \ No newline at end of file +} diff --git a/src/psbt_builder.rs b/src/psbt_builder.rs index 8932920..5e7bace 100644 --- a/src/psbt_builder.rs +++ b/src/psbt_builder.rs @@ -32,12 +32,10 @@ fn build_inputs( funder: &ListUnspentResultEntry, dust_utxos: &[ListUnspentResultEntry], ) -> Vec { - let mut inputs = vec![ - serde_json::json!({ - "txid": funder.txid.to_string(), - "vout": funder.vout, - }) - ]; + let mut inputs = vec![serde_json::json!({ + "txid": funder.txid.to_string(), + "vout": funder.vout, + })]; for utxo in dust_utxos { inputs.push(serde_json::json!({ "txid": utxo.txid.to_string(), @@ -132,7 +130,10 @@ pub fn build_op_return_psbt( } let funder = select_funder(clean_utxos)?; - println!("\n ℹ️ Using clean UTXO to fund fees: {} sats", funder.amount.to_sat()); + println!( + "\n ℹ️ Using clean UTXO to fund fees: {} sats", + funder.amount.to_sat() + ); let all_inputs = build_inputs(funder, dust_utxos); @@ -173,4 +174,4 @@ pub fn build_op_return_psbt( dust_input_count: dust_utxos.len(), total_dust_sats, }) -} \ No newline at end of file +}