From 5355b4d3a24532ea7b2d369e6ce9e678c3d37907 Mon Sep 17 00:00:00 2001 From: Renato Britto Date: Tue, 24 Mar 2026 20:23:33 -0300 Subject: [PATCH 1/5] refactor: introduce workspace and stealth-core crate --- .gitignore | 2 + Cargo.lock | 1798 +++++++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 32 + core/Cargo.toml | 30 + rustfmt.toml | 12 + 5 files changed, 1874 insertions(+) create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 core/Cargo.toml create mode 100644 rustfmt.toml diff --git a/.gitignore b/.gitignore index 7bfc0b5..e8d5369 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,5 @@ dist/ .pnpm-store .qwen **/__pycache__/ + +target/ \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..a2542e0 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1798 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "axum" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "base58ck" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c8d66485a3a2ea485c1913c4572ce0256067a5377ac8c75c4960e1cda98605f" +dependencies = [ + "bitcoin-internals", + "bitcoin_hashes", +] + +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bech32" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f" + +[[package]] +name = "bitcoin" +version = "0.32.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e499f9fc0407f50fe98af744ab44fa67d409f76b6772e1689ec8485eb0c0f66" +dependencies = [ + "base58ck", + "base64 0.21.7", + "bech32", + "bitcoin-internals", + "bitcoin-io", + "bitcoin-units", + "bitcoin_hashes", + "hex-conservative", + "hex_lit", + "secp256k1", + "serde", +] + +[[package]] +name = "bitcoin-internals" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30bdbe14aa07b06e6cfeffc529a1f099e5fbe249524f8125358604df99a4bed2" +dependencies = [ + "serde", +] + +[[package]] +name = "bitcoin-io" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dee39a0ee5b4095224a0cfc6bf4cc1baf0f9624b96b367e53b66d974e51d953" + +[[package]] +name = "bitcoin-units" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5285c8bcaa25876d07f37e3d30c303f2609179716e11d688f51e8f1fe70063e2" +dependencies = [ + "bitcoin-internals", + "serde", +] + +[[package]] +name = "bitcoin_hashes" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26ec84b80c482df901772e931a9a681e26a1b9ee2302edeff23cb30328745c8b" +dependencies = [ + "bitcoin-io", + "hex-conservative", + "serde", +] + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "corepc-client" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7755b8b9219b23d166a5897b5e2d8266cbdd0de5861d351b96f6db26bcf415f3" +dependencies = [ + "bitcoin", + "corepc-types 0.10.1", + "jsonrpc", + "log", + "serde", + "serde_json", +] + +[[package]] +name = "corepc-node" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "768391062ec3812e223bb3031c5b2fcdd6e0e60b816157f21df82fd3e6617dc0" +dependencies = [ + "anyhow", + "corepc-client", + "log", + "serde_json", + "tempfile", + "which", +] + +[[package]] +name = "corepc-types" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c22db78b0223b66f82f92b14345f06307078f76d94b18280431ea9bc6cd9cbb6" +dependencies = [ + "bitcoin", + "serde", + "serde_json", +] + +[[package]] +name = "corepc-types" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6ea6101b2da248ff9c7e0ead02c6e0b8243db140d86c6190e1b043c306d97a" +dependencies = [ + "bitcoin", + "serde", + "serde_json", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "hex-conservative" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fda06d18ac606267c40c04e41b9947729bf8b9efe74bd4e82b61a5f26a510b9f" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "hex_lit" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3011d1213f159867b13cfd6ac92d2cd5f1345762c63be3554e84092d85a50bbd" + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "iri-string" +version = "0.7.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8e7418f59cc01c88316161279a7f665217ae316b388e58a0d10e29f54f1e5eb" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "jsonrpc" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3662a38d341d77efecb73caf01420cfa5aa63c0253fd7bc05289ef9f6616e1bf" +dependencies = [ + "base64 0.13.1", + "minreq", + "serde", + "serde_json", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.183" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "minreq" +version = "2.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05015102dad0f7d61691ca347e9d9d9006685a64aefb3d79eecf62665de2153d" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.52.0", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "secp256k1" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113" +dependencies = [ + "bitcoin_hashes", + "secp256k1-sys", + "serde", +] + +[[package]] +name = "secp256k1-sys" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4387882333d3aa8cb20530a17c69a3752e97837832f34f6dccc760e715001d9" +dependencies = [ + "cc", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stealth-api" +version = "0.1.0" +dependencies = [ + "axum", + "corepc-client", + "http-body-util", + "reqwest", + "serde", + "serde_json", + "stealth-core", + "thiserror", + "tokio", + "tower", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "stealth-cli" +version = "0.1.0" +dependencies = [ + "corepc-client", + "serde", + "serde_json", + "stealth-core", +] + +[[package]] +name = "stealth-core" +version = "0.1.0" +dependencies = [ + "bitcoin", + "corepc-client", + "corepc-node", + "corepc-types 0.11.0", + "serde", + "serde_json", + "thiserror", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8" +dependencies = [ + "cfg-if", + "futures-util", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "which" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d011071ae14a2f6671d0b74080ae0cd8ebf3a6f8c9589a2cd45f23126fe29724" +dependencies = [ + "libc", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..610029d --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,32 @@ +[workspace] +members = [ + "core", +] +resolver = "2" + +[workspace.package] +version = "0.1.0" +edition = "2021" +authors = [ + "Breno Brito (brenorb)", + "Herberson Miranda (hsmiranda)", + "Jorge Santana (LORDBABUINO) ", + "Renato Britto (satsfy) <0xsatsfy@gmail.com>", +] +license = "CC0-1.0" +repository = "https://github.com/stealth-bitcoin/stealth" +rust-version = "1.93.1" + +[workspace.dependencies] +bitcoin = { version = "0.32.0", default-features = false, features = ["serde", "base64", "secp-recovery"] } +corepc-client = { version = "0.10.0", features = ["client-sync"] } +corepc-node = { version = "0.10.1", features = ["29_0"] } +corepc-types = "0.11.0" +serde = { version = "1.0.228", default-features = false, features = ["derive", "alloc"] } +serde_json = "1.0.145" +thiserror = "2.0.17" +stealth-core = { path = "core" } +axum = "0.8.6" +tokio = { version = "1.48.0", features = ["macros", "net", "rt-multi-thread"] } +tracing = "0.1.41" +tracing-subscriber = { version = "0.3.20", features = ["env-filter", "fmt"] } diff --git a/core/Cargo.toml b/core/Cargo.toml new file mode 100644 index 0000000..02c743e --- /dev/null +++ b/core/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "stealth-core" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true +description = "Detects and reproduces Bitcoin UTXO privacy vulnerabilities" +categories = ["cryptography::cryptocurrencies"] +keywords = ["bitcoin", "privacy", "utxo", "chain-analysis"] +readme = "README.md" + +[features] +default = ["std"] +std = ["bitcoin/std"] + +[dependencies] +bitcoin = { workspace = true } +corepc-client = { workspace = true } +corepc-types = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +corepc-node = { workspace = true } + +[lints.rust] +missing_debug_implementations = "deny" diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..ead7e7b --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,12 @@ +max_width = 100 +hard_tabs = false +tab_spaces = 4 +newline_style = "Auto" +use_small_heuristics = "Default" +reorder_imports = true +reorder_modules = true +edition = "2021" +use_try_shorthand = false +use_field_init_shorthand = false +force_explicit_abi = true +merge_derives = true \ No newline at end of file From fe7c2f9a781c54d5d946e2e39846a75574a982f5 Mon Sep 17 00:00:00 2001 From: Renato Britto Date: Tue, 24 Mar 2026 20:24:15 -0300 Subject: [PATCH 2/5] core: implement TxGraph for analyzing Bitcoin wallet transactions - Added TxGraph struct to manage and analyze wallet transactions and UTXOs. - Implemented methods for fetching transaction details, input/output addresses, and detecting vulnerabilities. - Introduced UtxoEntry struct to represent UTXO data. - Created a new module for scanning wallets and UTXOs, including RPC connection handling. - Added types for vulnerability detection and reporting, including Severity and VulnerabilityType enums. - Implemented a structured report system for scan results, including findings and statistics. --- core/README.md | 110 ++++ core/src/detect.rs | 1529 +++++++++++++++++++++++++++++++++++++++++++ core/src/graph.rs | 404 ++++++++++++ core/src/lib.rs | 44 ++ core/src/scanner.rs | 233 +++++++ core/src/types.rs | 152 +++++ 6 files changed, 2472 insertions(+) create mode 100644 core/README.md create mode 100644 core/src/detect.rs create mode 100644 core/src/graph.rs create mode 100644 core/src/lib.rs create mode 100644 core/src/scanner.rs create mode 100644 core/src/types.rs diff --git a/core/README.md b/core/README.md new file mode 100644 index 0000000..e0c907f --- /dev/null +++ b/core/README.md @@ -0,0 +1,110 @@ +# stealth-core + +Detects Bitcoin UTXO privacy vulnerabilities by analysing a wallet's transaction +history on a Bitcoin Core node via JSON-RPC. + +The library connects to a running `bitcoind`, fetches the wallet's transaction +history and current UTXO set, then runs **12 independent vulnerability +detectors** through `TxGraph::detect_all()`. Results are returned as a +structured `Report` that serialises to JSON. + +Primary public scanning API: `TxGraph::detect_all(...)`. + +## Detected vulnerabilities + +| # | Vulnerability | Default severity | +| --- | --------------------------------------- | ---------------- | +| 1 | Address reuse | HIGH | +| 2 | Common-input-ownership heuristic (CIOH) | HIGH – CRITICAL | +| 3 | Dust UTXO reception | MEDIUM – HIGH | +| 4 | Dust spent alongside normal inputs | HIGH | +| 5 | Identifiable change outputs | MEDIUM | +| 6 | UTXOs born from consolidation txs | MEDIUM | +| 7 | Mixed script types in inputs | HIGH | +| 8 | Cross-origin cluster merge | HIGH | +| 9 | UTXO age / lookback-depth spread | LOW | +| 10 | Exchange-origin batch withdrawal | MEDIUM | +| 11 | Tainted UTXO merge | HIGH | +| 12 | Behavioural fingerprinting | MEDIUM | + +## Prerequisites + +- **Rust** >= 1.93.1 +- **Bitcoin Core** (`bitcoind`) >= 0.29.0 — must be on your `PATH` + +### Installing Bitcoin Core + +```bash +# macOS (Homebrew) +brew install bitcoin + +# Ubuntu / Debian +sudo apt install bitcoind + +# Or download from https://bitcoincore.org/en/download/ +``` + +Verify it is available: + +```bash +bitcoind --version +``` + +## Usage + +Add the crate to your `Cargo.toml`: + +```toml +[dependencies] +stealth-core = "0.1.0" +``` + +```rust +use corepc_client::client_sync::v29::Client; +use stealth_core::{TxGraph, VulnerabilityType}; + +// Connect to a wallet-loaded bitcoind +let client = Client::new("http://127.0.0.1:8332", "user", "pass").unwrap(); + +let mut graph = TxGraph::build(client).unwrap(); +let report = graph.detect_all(None, None); + +for finding in &report.findings { + println!("{}: {}", finding.severity, finding.vulnerability_type); +} +``` + +## Running the tests + +The integration tests spin up a temporary `bitcoind` in regtest mode +(via [`corepc-node`](https://crates.io/crates/corepc-node)). +No external setup is required — just ensure `bitcoind` is on your `PATH`. + +```bash +# Run all tests (unit + 13 regtest integration tests) +cargo test -p stealth-core + +# Run a single test with output +cargo test -p stealth-core detect_address_reuse -- --nocapture +``` + +> **Note:** The integration tests create ephemeral regtest nodes that are +> automatically cleaned up. Each test takes a few seconds due to block mining. + +## Project structure + +``` +core/ +├── Cargo.toml +├── src/ +│ ├── lib.rs # Crate root and re-exports +│ ├── types.rs # Severity, VulnerabilityType, Finding, Report +│ ├── graph.rs # TxGraph — builds wallet tx graph via RPC +│ └── detect.rs # 12 vulnerability detectors + detect_all() +└── tests/ + └── integration.rs # 13 regtest integration tests +``` + +## License + +[CC0-1.0](../LICENSE) diff --git a/core/src/detect.rs b/core/src/detect.rs new file mode 100644 index 0000000..c59b33c --- /dev/null +++ b/core/src/detect.rs @@ -0,0 +1,1529 @@ +use std::collections::{HashMap, HashSet}; + +use serde_json::json; + +use crate::graph::TxGraph; +use crate::types::*; + +impl TxGraph { + /// Run all vulnerability detectors and produce a [`Report`]. + /// + /// Optionally pass sets of known-risky and known-exchange transaction IDs + /// to enable taint analysis (detector 11) and exchange-origin detection + /// (detector 10). + pub fn detect_all( + &mut self, + known_risky_txids: Option<&HashSet>, + known_exchange_txids: Option<&HashSet>, + ) -> Report { + let mut findings = Vec::new(); + let mut warnings = Vec::new(); + + self.detect_address_reuse(&mut findings); + self.detect_cioh(&mut findings); + self.detect_dust(&mut findings); + self.detect_dust_spending(&mut findings); + self.detect_change_detection(&mut findings); + self.detect_consolidation_origin(&mut findings); + self.detect_script_type_mixing(&mut findings); + self.detect_cluster_merge(&mut findings); + self.detect_lookback_depth(&mut findings, &mut warnings); + self.detect_exchange_origin(&mut findings, known_exchange_txids); + self.detect_tainted_utxos(&mut findings, &mut warnings, known_risky_txids); + self.detect_behavioral_fingerprint(&mut findings); + self.detect_dust_attack(&mut findings); + self.detect_peel_chain(&mut findings); + self.detect_deterministic_links(&mut findings, &mut warnings); + self.detect_unnecessary_input(&mut findings); + self.detect_toxic_change(&mut findings); + + let stats = Stats { + transactions_analyzed: self.our_txids.len(), + addresses_derived: self.addr_map.len(), + utxos_current: self.utxos.len(), + }; + + Report::new(stats, findings, warnings) + } + + // ── 1. Address Reuse ─────────────────────────────────────────────────── + + fn detect_address_reuse(&mut self, findings: &mut Vec) { + for addr in self.our_addrs.clone() { + let entries = match self.addr_txs.get(&addr) { + Some(e) => e, + None => continue, + }; + let receive_txids: HashSet<&str> = entries + .iter() + .filter(|e| e.category == "receive") + .filter_map(|e| { + if e.txid.is_empty() { + None + } else { + Some(e.txid.as_str()) + } + }) + .collect(); + + if receive_txids.len() >= 2 { + let meta = self.addr_map.get(&addr); + let role = if meta.is_some_and(|m| m.internal) { + "change" + } else { + "receive" + }; + findings.push(Finding { + vulnerability_type: VulnerabilityType::AddressReuse, + severity: Severity::High, + description: format!( + "Address {} ({}) reused across {} transactions", + addr, + role, + receive_txids.len() + ), + details: Some(json!({ + "address": addr, + "role": role, + "tx_count": receive_txids.len(), + "txids": receive_txids.iter().collect::>(), + })), + correction: Some( + "Generate a fresh address for every payment received. \ + Enable HD wallet derivation (BIP-32/44/84) so your wallet \ + produces a new address automatically." + .into(), + ), + }); + } + } + } + + // ── 2. Common Input Ownership Heuristic (CIOH) ───────────────────────── + + fn detect_cioh(&mut self, findings: &mut Vec) { + let txids: Vec = self.our_txids.iter().cloned().collect(); + for txid in &txids { + let tx = match self.fetch_tx(txid) { + Some(t) => t, + None => continue, + }; + let vin_count = tx + .get("vin") + .and_then(|v| v.as_array()) + .map_or(0, |a| a.len()); + if vin_count < 2 { + continue; + } + + let input_addrs = self.get_input_addresses(txid); + if input_addrs.len() < 2 { + continue; + } + + let our_inputs: Vec<_> = input_addrs + .iter() + .filter(|ia| self.is_ours(&ia.address)) + .collect(); + if our_inputs.len() < 2 { + continue; + } + + let total_inputs = input_addrs.len(); + let n_ours = our_inputs.len(); + let ownership_pct = (n_ours as f64 / total_inputs as f64 * 100.0).round() as u32; + + let severity = if n_ours == total_inputs { + Severity::Critical + } else { + Severity::High + }; + + findings.push(Finding { + vulnerability_type: VulnerabilityType::Cioh, + severity, + description: format!( + "TX {} merges {}/{} of your inputs ({}% ownership)", + txid, n_ours, total_inputs, ownership_pct + ), + details: Some(json!({ + "txid": txid, + "total_inputs": total_inputs, + "our_inputs": n_ours, + "ownership_pct": ownership_pct, + })), + correction: Some( + "Use coin control to select only one UTXO per transaction. \ + If consolidation is unavoidable, do it privately via a CoinJoin round." + .into(), + ), + }); + } + } + + // ── 3. Dust UTXO Detection ───────────────────────────────────────────── + + fn detect_dust(&mut self, findings: &mut Vec) { + const DUST_SATS: u64 = 1000; + const STRICT_DUST: u64 = 546; + + // Current UTXOs + let utxos = self.utxos.clone(); + for utxo in &utxos { + if !self.is_ours(&utxo.address) { + continue; + } + let sats = (utxo.amount * 1e8).round() as u64; + if sats <= DUST_SATS { + let label = if sats <= STRICT_DUST { + "STRICT_DUST" + } else { + "dust-class" + }; + let severity = if sats <= STRICT_DUST { + Severity::High + } else { + Severity::Medium + }; + findings.push(Finding { + vulnerability_type: VulnerabilityType::Dust, + severity, + description: format!( + "Dust UTXO at {} ({} sats, {}, unspent)", + utxo.address, sats, label + ), + details: Some(json!({ + "status": "unspent", + "address": utxo.address, + "sats": sats, + "label": label, + "txid": utxo.txid, + "vout": utxo.vout, + })), + correction: Some( + "Do not spend this dust output — doing so links your other inputs \ + to this address via CIOH. Use your wallet's coin freeze feature to \ + exclude it from future transactions." + .into(), + ), + }); + } + } + + // Historical dust (already spent) + let txids: Vec = self.our_txids.iter().cloned().collect(); + let current_keys: HashSet<(String, String)> = utxos + .iter() + .map(|u| (u.txid.clone(), u.address.clone())) + .collect(); + let mut seen = HashSet::new(); + for txid in &txids { + let outputs = self.get_output_addresses(txid); + for out in &outputs { + let sats = (out.value * 1e8).round() as u64; + if sats <= DUST_SATS && self.is_ours(&out.address) { + let key = (txid.clone(), out.address.clone()); + if !current_keys.contains(&key) && seen.insert(key) { + findings.push(Finding { + vulnerability_type: VulnerabilityType::Dust, + severity: Severity::Low, + description: format!( + "Historical dust output at {} ({} sats, already spent)", + out.address, sats + ), + details: Some(json!({ + "status": "spent", + "address": out.address, + "sats": sats, + "txid": txid, + })), + correction: Some( + "This dust has already been spent. Going forward, reject \ + unsolicited dust by enabling automatic dust rejection." + .into(), + ), + }); + } + } + } + } + } + + // ── 4. Dust Spent with Normal Inputs ─────────────────────────────────── + + fn detect_dust_spending(&mut self, findings: &mut Vec) { + const DUST_SATS: u64 = 1000; + + let txids: Vec = self.our_txids.iter().cloned().collect(); + for txid in &txids { + let input_addrs = self.get_input_addresses(txid); + if input_addrs.len() < 2 { + continue; + } + + let mut dust_inputs = Vec::new(); + let mut normal_inputs = Vec::new(); + for ia in &input_addrs { + if !self.is_ours(&ia.address) { + continue; + } + let sats = (ia.value * 1e8).round() as u64; + if sats <= DUST_SATS { + dust_inputs.push(ia); + } else if sats > 10_000 { + normal_inputs.push(ia); + } + } + + if !dust_inputs.is_empty() && !normal_inputs.is_empty() { + findings.push(Finding { + vulnerability_type: VulnerabilityType::DustSpending, + severity: Severity::High, + description: format!( + "TX {} spends {} dust input(s) alongside {} normal input(s)", + txid, + dust_inputs.len(), + normal_inputs.len() + ), + details: Some(json!({ + "txid": txid, + "dust_inputs": dust_inputs.iter().map(|d| { + json!({"address": d.address, "sats": (d.value * 1e8).round() as u64}) + }).collect::>(), + "normal_inputs": normal_inputs.iter().map(|n| { + json!({"address": n.address, "amount_btc": n.value}) + }).collect::>(), + })), + correction: Some( + "Freeze dust UTXOs in your wallet to prevent them from being \ + automatically selected as inputs." + .into(), + ), + }); + } + } + } + + // ── 5. Change Detection ──────────────────────────────────────────────── + + fn detect_change_detection(&mut self, findings: &mut Vec) { + let txids: Vec = self.our_txids.iter().cloned().collect(); + for txid in &txids { + let outputs = self.get_output_addresses(txid); + if outputs.len() < 2 { + continue; + } + let input_addrs = self.get_input_addresses(txid); + let our_in: Vec<_> = input_addrs + .iter() + .filter(|ia| self.is_ours(&ia.address)) + .collect(); + if our_in.is_empty() { + continue; + } + + let our_outs: Vec<_> = outputs + .iter() + .filter(|o| self.is_ours(&o.address)) + .collect(); + let ext_outs: Vec<_> = outputs + .iter() + .filter(|o| !self.is_ours(&o.address)) + .collect(); + if our_outs.is_empty() || ext_outs.is_empty() { + continue; + } + + let mut problems = Vec::new(); + for change in &our_outs { + let ch_sats = (change.value * 1e8).round() as u64; + let ch_round = ch_sats.is_multiple_of(100_000) || ch_sats.is_multiple_of(1_000_000); + + for payment in &ext_outs { + let pay_sats = (payment.value * 1e8).round() as u64; + let pay_round = + pay_sats.is_multiple_of(100_000) || pay_sats.is_multiple_of(1_000_000); + + if pay_round && !ch_round { + problems.push(format!( + "Round payment ({} sats) vs non-round change ({} sats)", + pay_sats, ch_sats + )); + } + + let in_types: HashSet = our_in + .iter() + .map(|ia| self.script_type(&ia.address)) + .collect(); + let ch_type = self.script_type(&change.address); + if in_types.contains(&ch_type) && change.script_type != payment.script_type { + problems.push(format!( + "Change script type ({}) matches input type — different from payment ({})", + change.script_type, payment.script_type + )); + } + + if let Some(meta) = self.addr_map.get(&change.address) { + if meta.internal { + problems.push( + "Change uses an internal (BIP-44 /1/*) derivation path".into(), + ); + } + } + } + } + + if !problems.is_empty() { + problems.truncate(6); + findings.push(Finding { + vulnerability_type: VulnerabilityType::ChangeDetection, + severity: Severity::Medium, + description: format!( + "TX {} has identifiable change output(s) ({} heuristic(s) matched)", + txid, + problems.len() + ), + details: Some(json!({ + "txid": txid, + "reasons": problems, + })), + correction: Some( + "Use PayJoin (BIP-78) so the receiver also contributes an input. \ + Avoid sending round amounts so the change amount is not the obvious leftover." + .into(), + ), + }); + } + } + } + + // ── 6. Consolidation Origin ──────────────────────────────────────────── + + fn detect_consolidation_origin(&mut self, findings: &mut Vec) { + const CONSOLIDATION_THRESHOLD: usize = 3; + + let utxos = self.utxos.clone(); + for utxo in &utxos { + if !self.is_ours(&utxo.address) { + continue; + } + let parent = match self.fetch_tx(&utxo.txid) { + Some(t) => t, + None => continue, + }; + let n_in = parent + .get("vin") + .and_then(|v| v.as_array()) + .map_or(0, |a| a.len()); + let n_out = parent + .get("vout") + .and_then(|v| v.as_array()) + .map_or(0, |a| a.len()); + + if n_in >= CONSOLIDATION_THRESHOLD && n_out <= 2 { + let parent_inputs = self.get_input_addresses(&utxo.txid); + let our_parent_in = parent_inputs + .iter() + .filter(|ia| self.is_ours(&ia.address)) + .count(); + + findings.push(Finding { + vulnerability_type: VulnerabilityType::Consolidation, + severity: Severity::Medium, + description: format!( + "UTXO {}:{} ({:.8} BTC) born from a {}-input consolidation", + utxo.txid, utxo.vout, utxo.amount, n_in + ), + details: Some(json!({ + "txid": utxo.txid, + "vout": utxo.vout, + "amount_btc": utxo.amount, + "consolidation_inputs": n_in, + "consolidation_outputs": n_out, + "our_inputs_in_consolidation": our_parent_in, + })), + correction: Some( + "Avoid consolidating many UTXOs into one in a single transaction. \ + If fee savings require consolidation, do it through a CoinJoin." + .into(), + ), + }); + } + } + } + + // ── 7. Script Type Mixing ────────────────────────────────────────────── + + fn detect_script_type_mixing(&mut self, findings: &mut Vec) { + let txids: Vec = self.our_txids.iter().cloned().collect(); + for txid in &txids { + let input_addrs = self.get_input_addresses(txid); + if input_addrs.len() < 2 { + continue; + } + let our_in: Vec<_> = input_addrs + .iter() + .filter(|ia| self.is_ours(&ia.address)) + .collect(); + if our_in.len() < 2 { + continue; + } + + let mut types: HashSet = HashSet::new(); + for ia in &input_addrs { + let t = self.script_type(&ia.address); + if t != "unknown" { + types.insert(t); + } + } + + if types.len() >= 2 { + let mut sorted: Vec = types.into_iter().collect(); + sorted.sort(); + findings.push(Finding { + vulnerability_type: VulnerabilityType::ScriptTypeMixing, + severity: Severity::High, + description: format!("TX {} mixes input script types: {:?}", txid, sorted), + details: Some(json!({ + "txid": txid, + "script_types": sorted, + })), + correction: Some( + "Migrate all funds to a single address type — preferably Taproot (P2TR). \ + Never mix P2PKH, P2SH, P2WPKH, P2WSH, and P2TR inputs in the same transaction." + .into(), + ), + }); + } + } + } + + // ── 8. Cluster Merge ─────────────────────────────────────────────────── + + fn detect_cluster_merge(&mut self, findings: &mut Vec) { + let txids: Vec = self.our_txids.iter().cloned().collect(); + for txid in &txids { + let input_addrs = self.get_input_addresses(txid); + if input_addrs.len() < 2 { + continue; + } + let our_in: Vec<_> = input_addrs + .iter() + .filter(|ia| self.is_ours(&ia.address)) + .collect(); + if our_in.len() < 2 { + continue; + } + + // Trace each input one hop back to find funding sources. + let mut funding_sources: HashMap> = HashMap::new(); + for ia in &our_in { + let parent_tx = match self.fetch_tx(&ia.funding_txid) { + Some(t) => t, + None => continue, + }; + let mut gp_sources = HashSet::new(); + if let Some(vins) = parent_tx.get("vin").and_then(|v| v.as_array()) { + for p_vin in vins { + if p_vin.get("coinbase").is_some() { + gp_sources.insert("coinbase".into()); + } else if let Some(ptxid) = p_vin.get("txid").and_then(|v| v.as_str()) { + gp_sources.insert(ptxid[..16.min(ptxid.len())].to_string()); + } + } + } + let key = format!( + "{}:{}", + &ia.funding_txid[..16.min(ia.funding_txid.len())], + ia.funding_vout + ); + funding_sources.insert(key, gp_sources); + } + + let all_sources: Vec<&HashSet> = funding_sources.values().collect(); + if all_sources.len() >= 2 { + let mut merged = false; + 'outer: for i in 0..all_sources.len() { + for j in (i + 1)..all_sources.len() { + if all_sources[i].is_disjoint(all_sources[j]) { + merged = true; + break 'outer; + } + } + } + + if merged { + findings.push(Finding { + vulnerability_type: VulnerabilityType::ClusterMerge, + severity: Severity::High, + description: format!( + "TX {} merges UTXOs from {} different funding chains", + txid, + funding_sources.len() + ), + details: Some(json!({ + "txid": txid, + "funding_sources": funding_sources.iter() + .map(|(k, v)| (k.clone(), v.iter().cloned().collect::>())) + .collect::>(), + })), + correction: Some( + "Use coin control to spend UTXOs from only one funding source \ + per transaction. Keep UTXOs received from different counterparties \ + in separate wallets." + .into(), + ), + }); + } + } + } + } + + // ── 9. Lookback Depth / UTXO Age ─────────────────────────────────────── + + fn detect_lookback_depth(&mut self, findings: &mut Vec, warnings: &mut Vec) { + let our_utxos: Vec<_> = self + .utxos + .iter() + .filter(|u| self.is_ours(&u.address)) + .cloned() + .collect(); + if our_utxos.len() < 2 { + return; + } + + let mut aged: Vec<_> = our_utxos.iter().map(|u| (u, u.confirmations)).collect(); + aged.sort_by(|a, b| b.1.cmp(&a.1)); + + let oldest = aged.first().unwrap(); + let newest = aged.last().unwrap(); + let spread = oldest.1 - newest.1; + + if spread < 10 { + return; + } + + findings.push(Finding { + vulnerability_type: VulnerabilityType::UtxoAgeSpread, + severity: Severity::Low, + description: format!( + "UTXO age spread of {} blocks between oldest and newest", + spread + ), + details: Some(json!({ + "spread_blocks": spread, + "oldest": { + "txid": oldest.0.txid, + "confirmations": oldest.1, + "amount_btc": oldest.0.amount, + }, + "newest": { + "txid": newest.0.txid, + "confirmations": newest.1, + "amount_btc": newest.0.amount, + }, + })), + correction: Some( + "Prefer spending older UTXOs first (FIFO coin selection) to normalize \ + the age distribution of your UTXO set." + .into(), + ), + }); + + const OLD_THRESHOLD: i64 = 100; + let old_count = aged.iter().filter(|(_, c)| *c >= OLD_THRESHOLD).count(); + if old_count > 0 { + warnings.push(Finding { + vulnerability_type: VulnerabilityType::DormantUtxos, + severity: Severity::Low, + description: format!( + "{} UTXO(s) have ≥{} confirmations (dormant/hoarded coins pattern)", + old_count, OLD_THRESHOLD + ), + details: Some(json!({ + "count": old_count, + "threshold_blocks": OLD_THRESHOLD, + })), + correction: None, + }); + } + } + + // ── 10. Exchange Origin ──────────────────────────────────────────────── + + fn detect_exchange_origin( + &mut self, + findings: &mut Vec, + known_exchange_txids: Option<&HashSet>, + ) { + const BATCH_THRESHOLD: usize = 5; + + let txids: Vec = self.our_txids.iter().cloned().collect(); + for txid in &txids { + let tx = match self.fetch_tx(txid) { + Some(t) => t, + None => continue, + }; + let n_out = tx + .get("vout") + .and_then(|v| v.as_array()) + .map_or(0, |a| a.len()); + if n_out < BATCH_THRESHOLD { + continue; + } + + let input_addrs = self.get_input_addresses(txid); + let our_inputs: Vec<_> = input_addrs + .iter() + .filter(|ia| self.is_ours(&ia.address)) + .collect(); + if !our_inputs.is_empty() { + continue; // We're a sender, not a recipient. + } + + let our_outputs: Vec<_> = self + .get_output_addresses(txid) + .into_iter() + .filter(|o| self.is_ours(&o.address)) + .collect(); + if our_outputs.is_empty() { + continue; + } + + let mut signals = vec![format!("High output count: {}", n_out)]; + + if let Some(vouts) = tx.get("vout").and_then(|v| v.as_array()) { + let unique_addrs: HashSet<&str> = vouts + .iter() + .filter_map(|v| v.pointer("/scriptPubKey/address").and_then(|a| a.as_str())) + .collect(); + if unique_addrs.len() >= BATCH_THRESHOLD { + signals.push(format!("{} unique recipient addresses", unique_addrs.len())); + } + } + + if let Some(exchange_txids) = known_exchange_txids { + if exchange_txids.contains(txid.as_str()) { + signals.push("TX matches known exchange wallet history".into()); + } + } + + if signals.len() >= 2 { + findings.push(Finding { + vulnerability_type: VulnerabilityType::ExchangeOrigin, + severity: Severity::Medium, + description: format!( + "TX {} looks like an exchange batch withdrawal ({} signal(s))", + txid, + signals.len() + ), + details: Some(json!({ + "txid": txid, + "signals": signals, + "received_outputs": our_outputs.iter().map(|o| { + json!({"address": o.address, "amount_btc": o.value}) + }).collect::>(), + })), + correction: Some( + "Withdraw via Lightning Network to avoid the exchange-origin fingerprint. \ + After withdrawal, pass the UTXO through a CoinJoin." + .into(), + ), + }); + } + } + } + + // ── 11. Tainted UTXOs ────────────────────────────────────────────────── + + fn detect_tainted_utxos( + &mut self, + findings: &mut Vec, + warnings: &mut Vec, + known_risky_txids: Option<&HashSet>, + ) { + let risky_txids = match known_risky_txids { + Some(t) if !t.is_empty() => t, + _ => return, + }; + + let txids: Vec = self.our_txids.iter().cloned().collect(); + + for txid in &txids { + let input_addrs = self.get_input_addresses(txid); + let our_in: Vec<_> = input_addrs + .iter() + .filter(|ia| self.is_ours(&ia.address)) + .collect(); + if our_in.is_empty() || input_addrs.len() < 2 { + continue; + } + + let tainted: Vec<_> = input_addrs + .iter() + .filter(|ia| risky_txids.contains(&ia.funding_txid)) + .collect(); + let clean: Vec<_> = input_addrs + .iter() + .filter(|ia| !risky_txids.contains(&ia.funding_txid)) + .collect(); + + if !tainted.is_empty() && !clean.is_empty() { + let taint_pct = + (tainted.len() as f64 / input_addrs.len() as f64 * 100.0).round() as u32; + findings.push(Finding { + vulnerability_type: VulnerabilityType::TaintedUtxoMerge, + severity: Severity::High, + description: format!( + "TX {} merges {} tainted + {} clean inputs ({}% taint)", + txid, + tainted.len(), + clean.len(), + taint_pct + ), + details: Some(json!({ + "txid": txid, + "tainted_inputs": tainted.iter().map(|t| { + json!({"address": t.address, "amount_btc": t.value, "source_txid": t.funding_txid}) + }).collect::>(), + "clean_inputs": clean.iter().map(|c| { + json!({"address": c.address, "amount_btc": c.value}) + }).collect::>(), + "taint_pct": taint_pct, + })), + correction: Some( + "Freeze tainted UTXOs to prevent them from being spent alongside \ + clean funds. Never merge inputs from known risky sources." + .into(), + ), + }); + } + } + + // Direct taint: we received directly from a risky source. + for txid in &txids { + if risky_txids.contains(txid.as_str()) { + let our_outs: Vec<_> = self + .get_output_addresses(txid) + .into_iter() + .filter(|o| self.is_ours(&o.address)) + .collect(); + if !our_outs.is_empty() { + warnings.push(Finding { + vulnerability_type: VulnerabilityType::DirectTaint, + severity: Severity::High, + description: format!("TX {} is directly from a known risky source", txid), + details: Some(json!({ + "txid": txid, + "received_outputs": our_outs.iter().map(|o| { + json!({"address": o.address, "amount_btc": o.value}) + }).collect::>(), + })), + correction: None, + }); + } + } + } + } + + // ── 12. Behavioral Fingerprint ───────────────────────────────────────── + + fn detect_behavioral_fingerprint(&mut self, findings: &mut Vec) { + // Collect send transactions (where we have inputs). + let txids: Vec = self.our_txids.iter().cloned().collect(); + let mut send_txids = Vec::new(); + for txid in &txids { + let input_addrs = self.get_input_addresses(txid); + if input_addrs.iter().any(|ia| self.is_ours(&ia.address)) { + send_txids.push(txid.clone()); + } + } + + if send_txids.len() < 3 { + return; + } + + let mut output_counts = Vec::new(); + let mut input_script_types = Vec::new(); + let mut rbf_signals = Vec::new(); + let mut locktime_values = Vec::new(); + let mut fee_rates: Vec = Vec::new(); + let mut uses_round_amounts: usize = 0; + let mut total_payments: usize = 0; + + for txid in &send_txids { + let tx = match self.fetch_tx(txid) { + Some(t) => t, + None => continue, + }; + + let n_out = tx + .get("vout") + .and_then(|v| v.as_array()) + .map_or(0, |a| a.len()); + output_counts.push(n_out); + + locktime_values.push(tx.get("locktime").and_then(|v| v.as_u64()).unwrap_or(0)); + + if let Some(vins) = tx.get("vin").and_then(|v| v.as_array()) { + for vin in vins { + let seq = vin + .get("sequence") + .and_then(|v| v.as_u64()) + .unwrap_or(0xffff_ffff); + rbf_signals.push(seq < 0xffff_fffe); + } + } + + let input_addrs = self.get_input_addresses(txid); + for ia in &input_addrs { + if self.is_ours(&ia.address) { + input_script_types.push(self.script_type(&ia.address)); + } + } + + let outputs = self.get_output_addresses(txid); + for out in &outputs { + if !self.is_ours(&out.address) { + let sats = (out.value * 1e8).round() as u64; + total_payments += 1; + if sats > 0 && (sats.is_multiple_of(100_000) || sats.is_multiple_of(1_000_000)) + { + uses_round_amounts += 1; + } + } + } + + // Fee rate + let vsize = tx.get("vsize").and_then(|v| v.as_u64()).unwrap_or(0); + if vsize > 0 { + let in_total: f64 = input_addrs.iter().map(|ia| ia.value).sum(); + let out_total: f64 = tx + .get("vout") + .and_then(|v| v.as_array()) + .map_or(0.0, |arr| { + arr.iter() + .filter_map(|v| v.get("value").and_then(|val| val.as_f64())) + .sum() + }); + let fee_sats = ((in_total - out_total) * 1e8).round(); + if fee_sats > 0.0 { + fee_rates.push(fee_sats / vsize as f64); + } + } + } + + let mut problems = Vec::new(); + + // Round amount pattern + if total_payments > 0 { + let round_pct = uses_round_amounts as f64 / total_payments as f64 * 100.0; + if round_pct > 60.0 { + problems.push(format!( + "Round payment amounts: {:.0}% of payments are round numbers.", + round_pct + )); + } + } + + // Uniform output count + if output_counts.len() >= 3 && output_counts.iter().all(|&c| c == output_counts[0]) { + problems.push(format!( + "Uniform output count: all {} send TXs have exactly {} outputs.", + output_counts.len(), + output_counts[0] + )); + } + + // Script type consistency + let input_types_set: HashSet<&String> = input_script_types.iter().collect(); + if input_types_set.len() > 1 { + problems.push(format!( + "Mixed input script types used across TXs: {:?}.", + input_types_set + )); + } + + // RBF signaling + if !rbf_signals.is_empty() { + let rbf_pct = rbf_signals.iter().filter(|&&b| b).count() as f64 + / rbf_signals.len() as f64 + * 100.0; + if rbf_pct == 100.0 { + problems.push("RBF always enabled: 100% of inputs signal replace-by-fee.".into()); + } else if rbf_pct == 0.0 { + problems.push("RBF never enabled: 0% of inputs signal replace-by-fee.".into()); + } + } + + // Locktime pattern + if locktime_values.len() >= 3 { + let all_nonzero = locktime_values.iter().all(|<| lt > 0); + let all_zero = locktime_values.iter().all(|<| lt == 0); + if all_nonzero { + problems.push( + "Anti-fee-sniping locktime always set — consistent with Bitcoin Core.".into(), + ); + } else if all_zero { + problems.push("Locktime always 0 — no anti-fee-sniping.".into()); + } + } + + // Fee rate consistency + if fee_rates.len() >= 3 { + let avg: f64 = fee_rates.iter().sum::() / fee_rates.len() as f64; + if avg > 0.0 { + let variance: f64 = fee_rates.iter().map(|f| (f - avg).powi(2)).sum::() + / fee_rates.len() as f64; + let stddev = variance.sqrt(); + let cv = stddev / avg; + if cv < 0.15 { + problems.push(format!( + "Very consistent fee rate: avg {:.1} sat/vB ± {:.1} (CV={:.2}).", + avg, stddev, cv + )); + } + } + } + + if problems.is_empty() { + return; + } + + findings.push(Finding { + vulnerability_type: VulnerabilityType::BehavioralFingerprint, + severity: Severity::Medium, + description: format!( + "Behavioral fingerprint detected across {} send transactions ({} pattern(s))", + send_txids.len(), + problems.len() + ), + details: Some(json!({ + "send_tx_count": send_txids.len(), + "patterns": problems, + })), + correction: Some( + "Switch to wallet software that applies anti-fingerprinting defaults. \ + Avoid sending only round amounts — add small random satoshi offsets. \ + Standardize on a single modern script type (Taproot)." + .into(), + ), + }); + } + + // ── 13. Dust Attack Detection ────────────────────────────────────────── + // + // Port of: am-i-exposed/src/lib/analysis/chain/backward.ts + // + // Detects when our wallet received a tiny UTXO from a probable dust + // attack transaction. A dust attack parent typically has ≥10 outputs, + // ≥5 of which are ≤ 546 sats, distributed to many distinct addresses. + + fn detect_dust_attack(&mut self, findings: &mut Vec) { + const MIN_OUTPUTS: usize = 10; + const DUST_THRESHOLD: u64 = 546; + const MIN_DUST_OUTPUTS: usize = 5; + + // Check receiving transactions only (we didn't create them). + let txids: Vec = self.our_txids.iter().cloned().collect(); + for txid in &txids { + let input_addrs = self.get_input_addresses(txid); + let has_our_inputs = input_addrs.iter().any(|ia| self.is_ours(&ia.address)); + if has_our_inputs { + continue; // Skip our own sends + } + + let outputs = self.get_output_addresses(txid); + if outputs.len() < MIN_OUTPUTS { + continue; + } + + let dust_outputs: Vec<_> = outputs + .iter() + .filter(|o| (o.value * 1e8).round() as u64 <= DUST_THRESHOLD) + .collect(); + if dust_outputs.len() < MIN_DUST_OUTPUTS { + continue; + } + + let unique_addrs: HashSet<&str> = outputs + .iter() + .filter(|o| !o.address.is_empty()) + .map(|o| o.address.as_str()) + .collect(); + let diversity = unique_addrs.len() as f64 / outputs.len().max(1) as f64; + if diversity < 0.8 { + continue; + } + + // Our wallet received from this dust attack tx + let our_outs: Vec<_> = outputs + .iter() + .filter(|o| self.is_ours(&o.address)) + .collect(); + if our_outs.is_empty() { + continue; + } + + findings.push(Finding { + vulnerability_type: VulnerabilityType::DustAttack, + severity: Severity::Critical, + description: format!( + "TX {} is a likely dust attack: {} outputs, {} of which are ≤{} sats, \ + targeting {} unique addresses", + txid, + outputs.len(), + dust_outputs.len(), + DUST_THRESHOLD, + unique_addrs.len() + ), + details: Some(json!({ + "txid": txid, + "total_outputs": outputs.len(), + "dust_outputs": dust_outputs.len(), + "unique_addresses": unique_addrs.len(), + "diversity_ratio": (diversity * 100.0).round() as u32, + "our_received": our_outs.iter().map(|o| { + json!({"address": o.address, "sats": (o.value * 1e8).round() as u64}) + }).collect::>(), + })), + correction: Some( + "Do NOT spend this dust UTXO — spending it reveals your other UTXOs \ + via common-input-ownership. Freeze it in your wallet immediately." + .into(), + ), + }); + } + } + + // ── 14. Peel Chain Detection ─────────────────────────────────────────── + // + // Port of: am-i-exposed/src/lib/analysis/chain/forward.ts and + // peel-chain-trace.ts + // + // Detects peel-chain patterns: a sequence of transactions where one + // output is "peeled off" as payment and the remaining change feeds + // the next hop. Signature: 1-2 inputs, 2 outputs with highly + // asymmetric values (ratio < 0.3). + + fn detect_peel_chain(&mut self, findings: &mut Vec) { + let txids: Vec = self.our_txids.iter().cloned().collect(); + for txid in &txids { + let input_addrs = self.get_input_addresses(txid); + let our_in: Vec<_> = input_addrs + .iter() + .filter(|ia| self.is_ours(&ia.address)) + .collect(); + if our_in.is_empty() { + continue; + } + if input_addrs.len() > 2 { + continue; // Peel chains have 1-2 inputs + } + + let outputs = self.get_output_addresses(txid); + if outputs.len() != 2 { + continue; + } + + let mut values: Vec = outputs.iter().map(|o| o.value).collect(); + values.sort_by(|a, b| a.partial_cmp(b).unwrap()); + let small = values[0]; + let large = values[1]; + if large <= 0.0 { + continue; + } + let ratio = small / large; + if ratio >= 0.3 { + continue; // Outputs are too similar for a peel + } + + // Trace forward: does the "large" output feed into another + // 2-output transaction? If so, count the chain length. + let mut hops = 1u32; + let large_idx = if outputs[0].value >= outputs[1].value { + 0 + } else { + 1 + }; + let mut trace_txid = txid.clone(); + let mut trace_vout = outputs[large_idx].index; + let max_hops = 6; + + while hops < max_hops { + // Find the child transaction that spends trace_txid:trace_vout + let child_txid = self.find_spending_tx(&trace_txid, trace_vout as u32); + let child_txid = match child_txid { + Some(t) => t, + None => break, + }; + let child_outs = self.get_output_addresses(&child_txid); + if child_outs.len() != 2 { + break; + } + let mut cv: Vec = child_outs.iter().map(|o| o.value).collect(); + cv.sort_by(|a, b| a.partial_cmp(b).unwrap()); + if cv[1] <= 0.0 || cv[0] / cv[1] >= 0.3 { + break; + } + hops += 1; + let large_child = if child_outs[0].value >= child_outs[1].value { + 0 + } else { + 1 + }; + trace_txid = child_txid; + trace_vout = child_outs[large_child].index; + } + + if hops < 2 { + continue; // At least 2 hops to qualify + } + + let severity = if hops >= 4 { + Severity::Critical + } else { + Severity::High + }; + + findings.push(Finding { + vulnerability_type: VulnerabilityType::PeelChain, + severity, + description: format!( + "Peel chain detected from TX {}: {} hops of asymmetric 2-output transactions", + txid, hops + ), + details: Some(json!({ + "start_txid": txid, + "hops": hops, + "initial_ratio": (ratio * 100.0).round() as u32, + })), + correction: Some( + "Avoid sending sequential transactions from the change output. \ + Use PayJoin or CoinJoin between sends. Send the exact UTXO \ + amount when possible to avoid leaving trackable change." + .into(), + ), + }); + } + } + + // ── 15. Deterministic Link Detection ─────────────────────────────────── + // + // Port of: am-i-exposed/src/lib/analysis/chain/linkability.ts + // + // For small transactions (≤4 inputs, ≤4 outputs) we enumerate all + // valid input→output assignments to find deterministic links — cases + // where a specific input can only map to one specific output (or + // vice versa). This indicates zero ambiguity for that link. + + fn detect_deterministic_links( + &mut self, + findings: &mut Vec, + warnings: &mut Vec, + ) { + let txids: Vec = self.our_txids.iter().cloned().collect(); + for txid in &txids { + let inputs = self.get_input_addresses(txid); + let outputs = self.get_output_addresses(txid); + + if inputs.is_empty() || outputs.is_empty() || inputs.len() < 2 || outputs.len() < 2 { + continue; + } + + // Only our sends + if !inputs.iter().any(|ia| self.is_ours(&ia.address)) { + continue; + } + + let n_in = inputs.len(); + let n_out = outputs.len(); + + // Skip large transactions (too expensive to enumerate) + if n_in > 4 || n_out > 4 { + continue; + } + + let in_sats: Vec = inputs + .iter() + .map(|i| (i.value * 1e8).round() as u64) + .collect(); + let out_sats: Vec = outputs + .iter() + .map(|o| (o.value * 1e8).round() as u64) + .collect(); + + // Count how many times each input→output pair appears in valid + // assignments (a valid assignment maps each input to one output + // such that the assigned inputs can fund each output). + let mut pair_count = vec![vec![0u64; n_out]; n_in]; + let mut total_valid: u64 = 0; + + // Enumerate all n_out^n_in assignments (≤ 4^4 = 256) + let total_combos = (n_out as u64).pow(n_in as u32); + for combo in 0..total_combos { + let mut assignment = vec![0usize; n_in]; + let mut c = combo; + for slot in assignment.iter_mut().take(n_in) { + *slot = (c % n_out as u64) as usize; + c /= n_out as u64; + } + + // Check validity: each output must receive at least its value + let mut output_funding = vec![0u64; n_out]; + for (i, &out_idx) in assignment.iter().enumerate() { + output_funding[out_idx] += in_sats[i]; + } + let valid = output_funding + .iter() + .zip(out_sats.iter()) + .all(|(&funded, &needed)| funded >= needed); + if valid { + total_valid += 1; + for (i, &out_idx) in assignment.iter().enumerate() { + pair_count[i][out_idx] += 1; + } + } + } + + if total_valid == 0 { + continue; + } + + // A deterministic link exists when an input maps to the same + // output in 100% of valid assignments (probability = 1.0). + let mut det_links = Vec::new(); + for i in 0..n_in { + for j in 0..n_out { + if pair_count[i][j] == total_valid { + det_links.push(json!({ + "input_index": i, + "output_index": j, + "input_address": inputs[i].address, + "output_address": outputs[j].address, + "input_sats": in_sats[i], + "output_sats": out_sats[j], + })); + } + } + } + + // Compute average ambiguity + let mut max_probs = Vec::new(); + for row in pair_count.iter().take(n_in) { + let max_p = (0..n_out) + .map(|j| row[j] as f64 / total_valid as f64) + .fold(0.0f64, f64::max); + max_probs.push(max_p); + } + let avg_max_prob: f64 = max_probs.iter().sum::() / max_probs.len() as f64; + let ambiguity = 1.0 - avg_max_prob; + + if !det_links.is_empty() { + findings.push(Finding { + vulnerability_type: VulnerabilityType::DeterministicLink, + severity: Severity::High, + description: format!( + "TX {} has {} deterministic input→output link(s) out of {} valid interpretations", + txid, + det_links.len(), + total_valid + ), + details: Some(json!({ + "txid": txid, + "deterministic_links": det_links, + "total_valid_interpretations": total_valid, + "ambiguity_pct": (ambiguity * 100.0).round() as u32, + })), + correction: Some( + "Create transactions where multiple valid input→output mappings exist. \ + Use CoinJoin or PayJoin to increase ambiguity." + .into(), + ), + }); + } else if ambiguity >= 0.6 { + warnings.push(Finding { + vulnerability_type: VulnerabilityType::DeterministicLink, + severity: Severity::Low, + description: format!( + "TX {} has good ambiguity ({:.0}%, {} valid interpretations)", + txid, + ambiguity * 100.0, + total_valid + ), + details: Some(json!({ + "txid": txid, + "total_valid_interpretations": total_valid, + "ambiguity_pct": (ambiguity * 100.0).round() as u32, + })), + correction: None, + }); + } + } + } + + // ── 16. Unnecessary Input Detection ──────────────────────────────────── + // + // Port of: am-i-exposed/src/lib/analysis/chain/spending-patterns.ts + // + // A transaction has an unnecessary input when any single input is + // larger than the total output value (excluding change). This means + // a smaller UTXO selection was possible — including extra inputs + // needlessly links more addresses via CIOH. + + fn detect_unnecessary_input(&mut self, findings: &mut Vec) { + let txids: Vec = self.our_txids.iter().cloned().collect(); + for txid in &txids { + let input_addrs = self.get_input_addresses(txid); + if input_addrs.len() < 2 { + continue; + } + let our_in: Vec<_> = input_addrs + .iter() + .filter(|ia| self.is_ours(&ia.address)) + .collect(); + if our_in.len() < 2 { + continue; + } + + let outputs = self.get_output_addresses(txid); + let ext_total_sats: u64 = outputs + .iter() + .filter(|o| !self.is_ours(&o.address)) + .map(|o| (o.value * 1e8).round() as u64) + .sum(); + if ext_total_sats == 0 { + continue; + } + + // Total fee + let in_total: f64 = input_addrs.iter().map(|i| i.value).sum(); + let out_total: f64 = outputs.iter().map(|o| o.value).sum(); + let fee_sats = ((in_total - out_total) * 1e8).round().max(0.0) as u64; + let needed_sats = ext_total_sats + fee_sats; + + // Check if any single input could have funded the payment + fee + let mut oversized_inputs = Vec::new(); + for ia in &our_in { + let in_sats = (ia.value * 1e8).round() as u64; + if in_sats >= needed_sats { + oversized_inputs.push(ia); + } + } + + if !oversized_inputs.is_empty() && our_in.len() > 1 { + let extra_count = our_in.len() - 1; + findings.push(Finding { + vulnerability_type: VulnerabilityType::UnnecessaryInput, + severity: Severity::Medium, + description: format!( + "TX {} has {} unnecessary input(s): a single UTXO of {:.8} BTC \ + could cover the {:.8} BTC payment + fee", + txid, + extra_count, + oversized_inputs[0].value, + ext_total_sats as f64 / 1e8, + ), + details: Some(json!({ + "txid": txid, + "sufficient_input": { + "address": oversized_inputs[0].address, + "amount_btc": oversized_inputs[0].value, + }, + "total_inputs_used": input_addrs.len(), + "unnecessary_count": extra_count, + "payment_sats": ext_total_sats, + "fee_sats": fee_sats, + })), + correction: Some( + "Use coin control to select only the single sufficient UTXO. \ + Adding extra inputs needlessly links more of your addresses \ + via common-input-ownership." + .into(), + ), + }); + } + } + } + + // ── 17. Toxic Change Detection ───────────────────────────────────────── + // + // Port of: am-i-exposed/src/lib/analysis/chain/forward.ts + // + // Detects when a small change output (< 10 000 sats) is later spent + // alongside a larger UTXO, linking the two. "Toxic" change is the + // non-round leftover from a payment that, when later consolidated, + // reveals the connection between the payment transaction and the + // user's larger holdings. + + fn detect_toxic_change(&mut self, findings: &mut Vec) { + const TOXIC_UPPER: u64 = 10_000; + const DUST_LOWER: u64 = 546; + + let txids: Vec = self.our_txids.iter().cloned().collect(); + for txid in &txids { + let input_addrs = self.get_input_addresses(txid); + let our_in: Vec<_> = input_addrs + .iter() + .filter(|ia| self.is_ours(&ia.address)) + .collect(); + if our_in.is_empty() { + continue; + } + + let outputs = self.get_output_addresses(txid); + // Look for our outputs that are small "toxic change" + for out in &outputs { + if !self.is_ours(&out.address) { + continue; + } + let sats = (out.value * 1e8).round() as u64; + if !(DUST_LOWER..=TOXIC_UPPER).contains(&sats) { + continue; + } + + // Check if this toxic change was later spent alongside + // a larger UTXO (the dangerous consolidation). + let child_txid = self.find_spending_tx(txid, out.index as u32); + let child_txid = match child_txid { + Some(t) => t, + None => continue, + }; + let child_inputs = self.get_input_addresses(&child_txid); + if child_inputs.len() < 2 { + continue; + } + let has_larger = child_inputs.iter().any(|ci| { + let ci_sats = (ci.value * 1e8).round() as u64; + ci_sats > TOXIC_UPPER && self.is_ours(&ci.address) + }); + if !has_larger { + continue; + } + + findings.push(Finding { + vulnerability_type: VulnerabilityType::ToxicChange, + severity: Severity::High, + description: format!( + "Toxic change ({} sats) from TX {} was later merged with a larger \ + UTXO in TX {}, linking both transactions", + sats, txid, child_txid + ), + details: Some(json!({ + "source_txid": txid, + "change_address": out.address, + "change_sats": sats, + "spending_txid": child_txid, + "total_inputs_in_child": child_inputs.len(), + })), + correction: Some( + "Absorb tiny change into the miner fee (bump fee to consume it) \ + or freeze small change outputs. Never consolidate small change \ + with unrelated UTXOs." + .into(), + ), + }); + } + } + } +} diff --git a/core/src/graph.rs b/core/src/graph.rs new file mode 100644 index 0000000..cec744a --- /dev/null +++ b/core/src/graph.rs @@ -0,0 +1,404 @@ +use std::collections::{HashMap, HashSet}; +use std::str::FromStr; + +use bitcoin::address::NetworkUnchecked; +use bitcoin::Address; +use corepc_client::client_sync::{v29::Client, Result as RpcResult}; + +use crate::types::{AddressInfo, InputInfo, OutputInfo, WalletTx}; + +/// Indexed view of all transactions touching a wallet's address set. +/// +/// The graph lazily fetches and caches raw transactions from the RPC node +/// as detectors request input/output data for specific txids. +#[derive(Debug)] +pub struct TxGraph { + /// Map of our addresses → metadata. + pub addr_map: HashMap, + /// All our addresses (quick lookup). + pub our_addrs: HashSet, + /// Current UTXOs from `listunspent`. + pub utxos: Vec, + /// Transaction IDs that touch our wallet. + pub our_txids: HashSet, + /// Per-address transaction entries. + pub addr_txs: HashMap>, + /// Per-txid set of our addresses involved. + pub tx_addrs: HashMap>, + + /// Client reference for lazy tx fetches. + pub client: Client, + /// Cached decoded transactions (txid → JSON value). + pub tx_cache: HashMap, + /// Cached input addresses per txid. + pub input_cache: HashMap>, + /// Cached output addresses per txid. + pub output_cache: HashMap>, +} + +/// A UTXO entry from `listunspent`. +#[derive(Debug, Clone)] +pub struct UtxoEntry { + pub txid: String, + pub vout: u32, + pub address: String, + pub amount: f64, + pub confirmations: i64, +} + +impl TxGraph { + /// Build a `TxGraph` by querying the RPC client for the wallet's + /// full transaction history and current UTXO set. + pub fn build(client: Client) -> RpcResult { + // Get all transactions (listsinceblock includes change addresses) + let list_txs = client.list_since_block()?; + let wallet_txs: Vec = list_txs + .transactions + .iter() + .map(|item| WalletTx { + txid: item.txid.clone(), + address: item.address.clone().unwrap_or_default(), + category: format!("{:?}", item.category).to_lowercase(), + amount: item.amount, + confirmations: item.confirmations, + }) + .collect(); + + // Get all UTXOs + let list_unspent = client.list_unspent()?; + let utxos: Vec = list_unspent + .0 + .iter() + .map(|item| UtxoEntry { + txid: item.txid.clone(), + vout: item.vout as u32, + address: item.address.clone(), + amount: item.amount, + confirmations: item.confirmations, + }) + .collect(); + + // Build indices + let mut our_txids = HashSet::new(); + let mut addr_txs: HashMap> = HashMap::new(); + let mut tx_addrs: HashMap> = HashMap::new(); + + for wtx in &wallet_txs { + if !wtx.txid.is_empty() { + our_txids.insert(wtx.txid.clone()); + } + if !wtx.address.is_empty() && !wtx.txid.is_empty() { + addr_txs + .entry(wtx.address.clone()) + .or_default() + .push(wtx.clone()); + tx_addrs + .entry(wtx.txid.clone()) + .or_default() + .insert(wtx.address.clone()); + } + } + + // Derive address map from UTXOs (basic — full descriptor resolution + // would require importdescriptors support). + let mut our_addrs = HashSet::new(); + let mut addr_map = HashMap::new(); + for utxo in &utxos { + our_addrs.insert(utxo.address.clone()); + addr_map + .entry(utxo.address.clone()) + .or_insert_with(|| AddressInfo { + script_type: script_type_from_address(&utxo.address), + internal: false, + index: 0, + }); + } + // Also include addresses seen in transaction history. + // Only "receive" entries are our addresses; "send" entries have the + // counterparty's destination address. + for wtx in &wallet_txs { + if !wtx.address.is_empty() && wtx.category != "send" { + our_addrs.insert(wtx.address.clone()); + addr_map + .entry(wtx.address.clone()) + .or_insert_with(|| AddressInfo { + script_type: script_type_from_address(&wtx.address), + internal: false, + index: 0, + }); + } + } + // list_since_block/list_transactions omit change addresses. + // list_address_groupings includes ALL used addresses (including change). + if let Ok(groupings) = client.list_address_groupings() { + let json = serde_json::to_value(&groupings).unwrap_or_default(); + if let Some(groups) = json.as_array() { + for group in groups { + if let Some(items) = group.as_array() { + for item in items { + let addr = item + .as_array() + .and_then(|a| a.first()) + .and_then(|v| v.as_str()) + .unwrap_or(""); + if !addr.is_empty() { + our_addrs.insert(addr.to_string()); + addr_map + .entry(addr.to_string()) + .or_insert_with(|| AddressInfo { + script_type: script_type_from_address(addr), + internal: false, + index: 0, + }); + } + } + } + } + } + } + + // Populate `internal` and `index` from the HD key path reported + // by `getaddressinfo`. BIP-44/49/84/86 paths look like: + // m/'/'/'// + // where == 1 means internal (change) address. + for addr_str in &our_addrs { + if let Ok(address) = addr_str + .parse::>() + .map(|a| a.assume_checked()) + { + if let Ok(info) = client.get_address_info(&address) { + if let Some(ref path) = info.hd_key_path { + let parts: Vec<&str> = path.split('/').collect(); + // e.g. ["m", "84'", "1'", "0'", "1", "5"] + if parts.len() >= 2 { + let change_part = parts[parts.len() - 2]; + let index_part = parts[parts.len() - 1]; + let is_internal = change_part == "1"; + let idx = index_part.parse::().unwrap_or(0); + if let Some(ai) = addr_map.get_mut(addr_str) { + ai.internal = is_internal; + ai.index = idx; + } + } + } + } + } + } + + Ok(TxGraph { + addr_map, + our_addrs, + utxos, + our_txids, + addr_txs, + tx_addrs, + client, + tx_cache: HashMap::new(), + input_cache: HashMap::new(), + output_cache: HashMap::new(), + }) + } + + /// Check whether an address belongs to our wallet. + pub fn is_ours(&self, address: &str) -> bool { + self.our_addrs.contains(address) + } + + /// Get the script type for an address. + pub fn script_type(&self, address: &str) -> String { + self.addr_map + .get(address) + .map(|info| info.script_type.clone()) + .unwrap_or_else(|| script_type_from_address(address)) + } + + /// Fetch a decoded transaction as a JSON value (cached). + pub fn fetch_tx(&mut self, txid: &str) -> Option { + if let Some(cached) = self.tx_cache.get(txid) { + return Some(cached.clone()); + } + let txid_parsed: bitcoin::Txid = txid.parse().ok()?; + let raw = self.client.get_raw_transaction_verbose(txid_parsed).ok()?; + let value = serde_json::to_value(&raw).ok()?; + self.tx_cache.insert(txid.to_string(), value.clone()); + Some(value) + } + + /// Get all input addresses for a transaction (cached). + pub fn get_input_addresses(&mut self, txid: &str) -> Vec { + if let Some(cached) = self.input_cache.get(txid) { + return cached.clone(); + } + + let tx = match self.fetch_tx(txid) { + Some(tx) => tx, + None => { + self.input_cache.insert(txid.to_string(), vec![]); + return vec![]; + } + }; + + let mut addrs = Vec::new(); + if let Some(inputs) = tx.get("vin").and_then(|v| v.as_array()) { + for vin in inputs { + if vin.get("coinbase").is_some() { + continue; + } + let parent_txid = match vin.get("txid").and_then(|v| v.as_str()) { + Some(t) => t.to_string(), + None => continue, + }; + let vout = vin.get("vout").and_then(|v| v.as_u64()).unwrap_or(0); + if let Some(parent) = self.fetch_tx(&parent_txid) { + if let Some(outputs) = parent.get("vout").and_then(|v| v.as_array()) { + if let Some(vout_data) = outputs.get(vout as usize) { + let addr = vout_data + .pointer("/scriptPubKey/address") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let value = vout_data + .get("value") + .and_then(|v| v.as_f64()) + .unwrap_or(0.0); + addrs.push(InputInfo { + address: addr, + value, + funding_txid: parent_txid, + funding_vout: vout as u32, + }); + } + } + } + } + } + + self.input_cache.insert(txid.to_string(), addrs.clone()); + addrs + } + + /// Get all output addresses for a transaction (cached). + pub fn get_output_addresses(&mut self, txid: &str) -> Vec { + if let Some(cached) = self.output_cache.get(txid) { + return cached.clone(); + } + + let tx = match self.fetch_tx(txid) { + Some(tx) => tx, + None => { + self.output_cache.insert(txid.to_string(), vec![]); + return vec![]; + } + }; + + let mut addrs = Vec::new(); + if let Some(outputs) = tx.get("vout").and_then(|v| v.as_array()) { + for vout in outputs { + let addr = vout + .pointer("/scriptPubKey/address") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let value = vout.get("value").and_then(|v| v.as_f64()).unwrap_or(0.0); + let index = vout.get("n").and_then(|v| v.as_u64()).unwrap_or(0); + let script_type = if !addr.is_empty() { + script_type_from_address(&addr) + } else { + // Fallback: normalise the RPC type string. + let raw = vout + .pointer("/scriptPubKey/type") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + normalize_rpc_script_type(raw).into() + }; + addrs.push(OutputInfo { + address: addr, + value, + index, + script_type, + }); + } + } + + self.output_cache.insert(txid.to_string(), addrs.clone()); + addrs + } + + /// Find a wallet transaction that spends the output `txid:vout`. + /// + /// Searches across all known wallet transaction IDs. Returns the + /// spending txid if found. + pub fn find_spending_tx(&mut self, txid: &str, vout: u32) -> Option { + let txids: Vec = self.our_txids.iter().cloned().collect(); + for candidate in &txids { + if candidate == txid { + continue; + } + let tx = self.fetch_tx(candidate)?; + if let Some(vins) = tx.get("vin").and_then(|v| v.as_array()) { + for vin in vins { + let parent = vin.get("txid").and_then(|v| v.as_str()).unwrap_or(""); + let v = vin.get("vout").and_then(|v| v.as_u64()).unwrap_or(u64::MAX); + if parent == txid && v == vout as u64 { + return Some(candidate.clone()); + } + } + } + } + None + } +} + +/// Determine script type by actually decoding the address and inspecting +/// the resulting script. +/// +/// Unlike the old prefix-based heuristic this handles all cases correctly: +/// +/// * `bc1q` / `tb1q` / `bcrt1q` with a 20-byte program → **p2wpkh** +/// * `bc1q` / `tb1q` / `bcrt1q` with a 32-byte program → **p2wsh** +/// * `bc1p` / `tb1p` / `bcrt1p` → **p2tr** +/// * Base58 `1`/`m`/`n` (version 0x00/0x6f) → **p2pkh** +/// * Base58 `3`/`2` (version 0x05/0xc4) → **p2sh** (we *cannot* know if it +/// wraps p2wpkh, p2wsh, or bare multisig without the redeem script) +pub fn script_type_from_address(address: &str) -> String { + // `assume_checked` skips network validation, allowing the function to + // work for mainnet, testnet, signet and regtest addresses uniformly. + if let Ok(addr) = + Address::from_str(address).map(|a: Address| a.assume_checked()) + { + let script = addr.script_pubkey(); + if script.is_p2pkh() { + return "p2pkh".into(); + } else if script.is_p2sh() { + // Without the redeemScript (only available at spend time) + // we cannot distinguish p2sh-p2wpkh from p2sh-p2wsh or + // bare p2sh multisig. Report as generic "p2sh". + return "p2sh".into(); + } else if script.is_p2wpkh() { + return "p2wpkh".into(); + } else if script.is_p2wsh() { + return "p2wsh".into(); + } else if script.is_p2tr() { + return "p2tr".into(); + } + } + + "unknown".into() +} + +/// Normalise the `scriptPubKey.type` string that Bitcoin Core returns in +/// `getrawtransaction` / `decoderawtransaction` to the canonical short names +/// used throughout stealth-core. +fn normalize_rpc_script_type(raw: &str) -> &str { + match raw { + "witness_v0_keyhash" => "p2wpkh", + "witness_v0_scripthash" => "p2wsh", + "witness_v1_taproot" => "p2tr", + "pubkeyhash" => "p2pkh", + "scripthash" => "p2sh", + "pubkey" => "p2pk", + "multisig" => "multisig", + "nulldata" => "op_return", + other => other, + } +} diff --git a/core/src/lib.rs b/core/src/lib.rs new file mode 100644 index 0000000..d92741d --- /dev/null +++ b/core/src/lib.rs @@ -0,0 +1,44 @@ +//! # stealth-core +//! +//! Detects Bitcoin UTXO privacy vulnerabilities by analysing a wallet's +//! transaction history on a Bitcoin Core node via RPC. +//! +//! The library connects to a running bitcoind using the +//! [`corepc_client`] crate, fetches the wallet's transaction history and +//! current UTXO set, and runs **17 independent vulnerability detectors** +//! through [`TxGraph::detect_all`]. +//! +//! Primary public scanning API: [`TxGraph::detect_all`]. +//! +//! Results are returned as a structured [`Report`] that can be serialised +//! to JSON. +//! +//! ## Detected vulnerabilities +//! +//! | # | Vulnerability | Default severity | +//! |---|---------------|------------------| +//! | 1 | Address reuse | HIGH | +//! | 2 | Common-input-ownership heuristic (CIOH) | HIGH – CRITICAL | +//! | 3 | Dust UTXO reception | MEDIUM – HIGH | +//! | 4 | Dust spent alongside normal inputs | HIGH | +//! | 5 | Identifiable change outputs | MEDIUM | +//! | 6 | UTXOs born from consolidation transactions | MEDIUM | +//! | 7 | Mixed script types in inputs | HIGH | +//! | 8 | Cross-origin cluster merge | HIGH | +//! | 9 | UTXO age / lookback-depth spread | LOW | +//! | 10 | Exchange-origin batch withdrawal | MEDIUM | +//! | 11 | Tainted UTXO merge | HIGH | +//! | 12 | Behavioural fingerprinting | MEDIUM | +//! | 13 | Dust attack detection | CRITICAL | +//! | 14 | Peel chain detection | HIGH – CRITICAL | +//! | 15 | Deterministic input→output links | HIGH | +//! | 16 | Unnecessary input (excess CIOH exposure) | MEDIUM | +//! | 17 | Toxic change consolidation | HIGH | + +mod detect; +mod graph; +pub mod scanner; +mod types; + +pub use graph::TxGraph; +pub use types::*; diff --git a/core/src/scanner.rs b/core/src/scanner.rs new file mode 100644 index 0000000..a8a2bd8 --- /dev/null +++ b/core/src/scanner.rs @@ -0,0 +1,233 @@ +use std::collections::{HashMap, HashSet}; +use std::path::PathBuf; + +use corepc_client::client_sync::{v29::Client, v29::ImportDescriptorsRequest, Auth}; +use serde::{Deserialize, Serialize}; + +use crate::graph::{script_type_from_address, TxGraph, UtxoEntry}; +use crate::types::*; + +/// RPC connection configuration for a running bitcoind. +#[derive(Debug, Clone)] +pub struct RpcConfig { + pub url: String, + pub auth: RpcAuth, +} + +/// Authentication method for the bitcoind RPC. +#[derive(Debug, Clone)] +pub enum RpcAuth { + UserPass { user: String, pass: String }, + CookieFile(PathBuf), + None, +} + +/// What to scan. +#[derive(Debug, Clone)] +pub enum ScanTarget { + Descriptor(String), + Descriptors(Vec), + Utxos(Vec), +} + +/// A raw UTXO to analyse. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct UtxoInput { + pub txid: String, + pub vout: u32, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub value_sats: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub address: Option, +} + +/// Errors that can occur during a scan. +#[derive(Debug, thiserror::Error)] +pub enum ScanError { + #[error("rpc connection failed: {0}")] + RpcConnection(String), + #[error("wallet creation failed: {0}")] + WalletCreation(String), + #[error("descriptor import failed: {0}")] + DescriptorImport(String), + #[error("scan execution failed: {0}")] + Execution(String), +} + +impl RpcConfig { + fn connect(&self) -> Result { + match &self.auth { + RpcAuth::None => Ok(Client::new(&self.url)), + auth => { + let core_auth = match auth { + RpcAuth::UserPass { user, pass } => Auth::UserPass(user.clone(), pass.clone()), + RpcAuth::CookieFile(path) => Auth::CookieFile(path.clone()), + RpcAuth::None => unreachable!(), + }; + Client::new_with_auth(&self.url, core_auth) + .map_err(|e| ScanError::RpcConnection(e.to_string())) + } + } + } + + fn connect_wallet(&self, wallet_name: &str) -> Result { + let wallet_url = format!("{}/wallet/{}", self.url.trim_end_matches('/'), wallet_name); + match &self.auth { + RpcAuth::None => Ok(Client::new(&wallet_url)), + auth => { + let core_auth = match auth { + RpcAuth::UserPass { user, pass } => Auth::UserPass(user.clone(), pass.clone()), + RpcAuth::CookieFile(path) => Auth::CookieFile(path.clone()), + RpcAuth::None => unreachable!(), + }; + Client::new_with_auth(&wallet_url, core_auth) + .map_err(|e| ScanError::RpcConnection(e.to_string())) + } + } + } +} + +/// Run a full privacy scan against a bitcoind node. +pub fn scan(config: &RpcConfig, target: ScanTarget) -> Result { + match target { + ScanTarget::Descriptor(d) => scan_descriptors(config, vec![d]), + ScanTarget::Descriptors(ds) => scan_descriptors(config, ds), + ScanTarget::Utxos(utxos) => scan_utxos(config, utxos), + } +} + +fn scan_descriptors(config: &RpcConfig, descriptors: Vec) -> Result { + let base_client = config.connect()?; + + let wallet_name = format!( + "stealth_scan_{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() + ); + + // Create a blank, watch-only descriptor wallet. + // createwallet(name, disable_private_keys=true, blank=true) + base_client + .call::( + "createwallet", + &[ + serde_json::Value::String(wallet_name.clone()), + serde_json::Value::Bool(true), + serde_json::Value::Bool(true), + ], + ) + .map_err(|e| ScanError::WalletCreation(e.to_string()))?; + + let wallet_client = config.connect_wallet(&wallet_name)?; + + // Import descriptors with timestamp=0 for full blockchain rescan. + let requests: Vec = descriptors + .iter() + .map(|d| ImportDescriptorsRequest::new(d.as_str(), serde_json::json!(0))) + .collect(); + + let import_result = wallet_client.import_descriptors(&requests); + if let Err(e) = import_result { + let _ = base_client.unload_wallet(&wallet_name); + return Err(ScanError::DescriptorImport(e.to_string())); + } + + let result = TxGraph::build(wallet_client) + .map_err(|e| ScanError::Execution(e.to_string())) + .map(|mut graph| graph.detect_all(None, None)); + + let _ = base_client.unload_wallet(&wallet_name); + result +} + +fn scan_utxos(config: &RpcConfig, utxos: Vec) -> Result { + let client = config.connect()?; + + let mut our_addrs = HashSet::new(); + let mut addr_map = HashMap::new(); + let mut utxo_entries = Vec::new(); + let mut our_txids = HashSet::new(); + let mut addr_txs: HashMap> = HashMap::new(); + let mut tx_addrs: HashMap> = HashMap::new(); + + for utxo in &utxos { + our_txids.insert(utxo.txid.clone()); + + // Resolve address: use provided or fetch from node. + let address = if let Some(addr) = &utxo.address { + addr.clone() + } else { + resolve_utxo_address(&client, &utxo.txid, utxo.vout)? + }; + + let value = utxo.value_sats.map(|s| s as f64 / 1e8).unwrap_or(0.0); + + if !address.is_empty() { + our_addrs.insert(address.clone()); + addr_map + .entry(address.clone()) + .or_insert_with(|| AddressInfo { + script_type: script_type_from_address(&address), + internal: false, + index: 0, + }); + + let wtx = WalletTx { + txid: utxo.txid.clone(), + address: address.clone(), + category: "receive".to_string(), + amount: value, + confirmations: 0, + }; + addr_txs.entry(address.clone()).or_default().push(wtx); + tx_addrs + .entry(utxo.txid.clone()) + .or_default() + .insert(address.clone()); + } + + utxo_entries.push(UtxoEntry { + txid: utxo.txid.clone(), + vout: utxo.vout, + address, + amount: value, + confirmations: 0, + }); + } + + let mut graph = TxGraph { + addr_map, + our_addrs, + utxos: utxo_entries, + our_txids, + addr_txs, + tx_addrs, + client, + tx_cache: HashMap::new(), + input_cache: HashMap::new(), + output_cache: HashMap::new(), + }; + + Ok(graph.detect_all(None, None)) +} + +fn resolve_utxo_address(client: &Client, txid_str: &str, vout: u32) -> Result { + let txid: bitcoin::Txid = txid_str + .parse() + .map_err(|e| ScanError::Execution(format!("invalid txid '{txid_str}': {e}")))?; + let raw = client + .get_raw_transaction_verbose(txid) + .map_err(|e| ScanError::Execution(e.to_string()))?; + let json = serde_json::to_value(&raw).unwrap_or_default(); + let addr = json + .get("vout") + .and_then(|v| v.as_array()) + .and_then(|a| a.get(vout as usize)) + .and_then(|v| v.pointer("/scriptPubKey/address")) + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + Ok(addr) +} diff --git a/core/src/types.rs b/core/src/types.rs new file mode 100644 index 0000000..7f58381 --- /dev/null +++ b/core/src/types.rs @@ -0,0 +1,152 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +/// Severity levels for privacy vulnerability findings. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(rename_all = "UPPERCASE")] +pub enum Severity { + Low, + Medium, + High, + Critical, +} + +impl core::fmt::Display for Severity { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Severity::Low => write!(f, "LOW"), + Severity::Medium => write!(f, "MEDIUM"), + Severity::High => write!(f, "HIGH"), + Severity::Critical => write!(f, "CRITICAL"), + } + } +} + +/// The category of privacy vulnerability detected. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum VulnerabilityType { + AddressReuse, + Cioh, + Dust, + DustSpending, + ChangeDetection, + Consolidation, + ScriptTypeMixing, + ClusterMerge, + UtxoAgeSpread, + DormantUtxos, + ExchangeOrigin, + TaintedUtxoMerge, + DirectTaint, + BehavioralFingerprint, + DustAttack, + PeelChain, + DeterministicLink, + UnnecessaryInput, + ToxicChange, +} + +impl core::fmt::Display for VulnerabilityType { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + let s = serde_json::to_value(self) + .ok() + .and_then(|v| v.as_str().map(String::from)) + .unwrap_or_else(|| format!("{:?}", self)); + write!(f, "{s}") + } +} + +/// A single privacy vulnerability finding. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Finding { + #[serde(rename = "type")] + pub vulnerability_type: VulnerabilityType, + pub severity: Severity, + pub description: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub details: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub correction: Option, +} + +/// Aggregate statistics about the scan. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Stats { + pub transactions_analyzed: usize, + pub addresses_derived: usize, + pub utxos_current: usize, +} + +/// Summary of the scan results. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Summary { + pub findings: usize, + pub warnings: usize, + pub clean: bool, +} + +/// The complete vulnerability scan report. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Report { + pub stats: Stats, + pub findings: Vec, + pub warnings: Vec, + pub summary: Summary, +} + +impl Report { + /// Construct a report from collected findings and warnings. + pub fn new(stats: Stats, findings: Vec, warnings: Vec) -> Self { + let summary = Summary { + findings: findings.len(), + warnings: warnings.len(), + clean: findings.is_empty() && warnings.is_empty(), + }; + Report { + stats, + findings, + warnings, + summary, + } + } +} + +/// Metadata about a derived address. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AddressInfo { + /// The script type (e.g. "p2wpkh", "p2tr", "p2sh", "p2wsh", "p2pkh"). + pub script_type: String, + /// Whether this is a change (internal) address. + pub internal: bool, + /// The derivation index. + pub index: usize, +} + +/// Information about a transaction input, resolved from the parent transaction. +#[derive(Debug, Clone)] +pub struct InputInfo { + pub address: String, + pub value: f64, + pub funding_txid: String, + pub funding_vout: u32, +} + +/// Information about a transaction output. +#[derive(Debug, Clone)] +pub struct OutputInfo { + pub address: String, + pub value: f64, + pub index: u64, + pub script_type: String, +} + +/// A wallet transaction entry (from `listtransactions`). +#[derive(Debug, Clone)] +pub struct WalletTx { + pub txid: String, + pub address: String, + pub category: String, + pub amount: f64, + pub confirmations: i64, +} From f88540ea39b73f76dcc85f34cf56c298e914a6b2 Mon Sep 17 00:00:00 2001 From: Renato Britto Date: Tue, 24 Mar 2026 20:24:28 -0300 Subject: [PATCH 3/5] test(core): add integration tests for stealth-core vulnerabilities detection --- core/tests/integration.rs | 1047 +++++++++++++++++++++++++++++++++++++ 1 file changed, 1047 insertions(+) create mode 100644 core/tests/integration.rs diff --git a/core/tests/integration.rs b/core/tests/integration.rs new file mode 100644 index 0000000..8e626d0 --- /dev/null +++ b/core/tests/integration.rs @@ -0,0 +1,1047 @@ +//! Integration tests for stealth-core. +//! +//! Each test spins up a fresh regtest Bitcoin Core via `corepc-node`, +//! reproduces one or more privacy vulnerabilities, then runs the +//! detector to verify it fires the expected finding(s). + +use std::collections::{BTreeMap, HashSet}; + +use corepc_node::client::bitcoin::{Address, Amount}; +use corepc_node::{AddressType, Input, Node, Output}; +use stealth_core::{TxGraph, VulnerabilityType}; + +// ─── helpers ──────────────────────────────────────────────────────────────── + +fn node() -> Node { + let exe = corepc_node::exe_path().expect("bitcoind not found"); + let mut conf = corepc_node::Conf::default(); + conf.args.push("-txindex"); + Node::with_conf(exe, &conf).expect("failed to start bitcoind") +} + +fn mine(node: &Node, n: usize, addr: &Address) { + node.client.generate_to_address(n, addr).unwrap(); +} + +fn has_finding(graph: &mut TxGraph, vtype: VulnerabilityType) -> bool { + let report = graph.detect_all(None, None); + report + .findings + .iter() + .any(|f| f.vulnerability_type == vtype) +} + +fn has_finding_with( + graph: &mut TxGraph, + vtype: VulnerabilityType, + known_risky: Option<&HashSet>, + known_exchange: Option<&HashSet>, +) -> bool { + let report = graph.detect_all(known_risky, known_exchange); + report + .findings + .iter() + .any(|f| f.vulnerability_type == vtype) +} + +// ─── 1. Address Reuse ─────────────────────────────────────────────────────── + +#[test] +fn detect_address_reuse() { + let node = node(); + let da = node.client.new_address().unwrap(); + mine(&node, 110, &da); + + let alice = node.create_wallet("alice").unwrap(); + let bob = node.create_wallet("bob").unwrap(); + let ba = bob.new_address().unwrap(); + node.client.send_to_address(&ba, Amount::ONE_BTC).unwrap(); + mine(&node, 1, &da); + + // Reuse the same alice address twice + let reused = alice.new_address().unwrap(); + bob.send_to_address(&reused, Amount::from_sat(1_000_000)) + .unwrap(); + bob.send_to_address(&reused, Amount::from_sat(2_000_000)) + .unwrap(); + mine(&node, 1, &da); + + let mut graph = TxGraph::build(alice).unwrap(); + assert!(has_finding(&mut graph, VulnerabilityType::AddressReuse)); +} + +// ─── 2. Common Input Ownership Heuristic (CIOH) ──────────────────────────── + +#[test] +fn detect_cioh() { + let node = node(); + let da = node.client.new_address().unwrap(); + mine(&node, 110, &da); + + let alice = node.create_wallet("alice").unwrap(); + let bob = node.create_wallet("bob").unwrap(); + let ba = bob.new_address().unwrap(); + node.client + .send_to_address(&ba, Amount::from_btc(2.0).unwrap()) + .unwrap(); + mine(&node, 1, &da); + + // Give alice multiple small UTXOs (each to a different address) + for _ in 0..5 { + let a = alice.new_address().unwrap(); + bob.send_to_address(&a, Amount::from_sat(500_000)).unwrap(); + } + mine(&node, 1, &da); + + // Alice consolidates them into one tx (multi-input -> CIOH) + let utxos = alice.list_unspent().unwrap(); + let small: Vec<_> = utxos.0.iter().filter(|u| u.amount < 0.006).collect(); + assert!(small.len() >= 2, "need at least 2 small utxos"); + + let inputs: Vec = small + .iter() + .map(|u| Input { + txid: u.txid.parse().unwrap(), + vout: u.vout as u64, + sequence: None, + }) + .collect(); + let total_sats: u64 = small.iter().map(|u| (u.amount * 1e8).round() as u64).sum(); + let fee_sats: u64 = 10_000; + let dest = bob.new_address().unwrap(); + let outputs = vec![Output::new(dest, Amount::from_sat(total_sats - fee_sats))]; + + let raw = alice.create_raw_transaction(&inputs, &outputs).unwrap(); + let tx = raw.transaction().unwrap(); + let signed = alice.sign_raw_transaction_with_wallet(&tx).unwrap(); + assert!(signed.complete); + let stx = signed.into_model().unwrap().tx; + alice.send_raw_transaction(&stx).unwrap(); + mine(&node, 1, &da); + + let mut graph = TxGraph::build(alice).unwrap(); + assert!(has_finding(&mut graph, VulnerabilityType::Cioh)); +} + +// ─── 3. Dust UTXO Detection ──────────────────────────────────────────────── + +#[test] +fn detect_dust() { + let node = node(); + let da = node.client.new_address().unwrap(); + mine(&node, 110, &da); + + let alice = node.create_wallet("alice").unwrap(); + let bob = node.create_wallet("bob").unwrap(); + let ba = bob.new_address().unwrap(); + node.client.send_to_address(&ba, Amount::ONE_BTC).unwrap(); + mine(&node, 1, &da); + + // Create 1000-sat dust output to alice via raw tx + let dust_addr = alice.new_address().unwrap(); + let bob_utxos = bob.list_unspent().unwrap(); + let big = bob_utxos + .0 + .iter() + .max_by(|a, b| a.amount.partial_cmp(&b.amount).unwrap()) + .unwrap(); + + let big_sats = (big.amount * 1e8).round() as u64; + let dust_sats: u64 = 1_000; + let fee_sats: u64 = 10_000; + let change_sats = big_sats - dust_sats - fee_sats; + + let change_addr = bob.new_address().unwrap(); + let raw = bob + .create_raw_transaction( + &[Input { + txid: big.txid.parse().unwrap(), + vout: big.vout as u64, + sequence: None, + }], + &[ + Output::new(dust_addr, Amount::from_sat(dust_sats)), + Output::new(change_addr, Amount::from_sat(change_sats)), + ], + ) + .unwrap(); + let tx = raw.transaction().unwrap(); + let signed = bob.sign_raw_transaction_with_wallet(&tx).unwrap(); + let stx = signed.into_model().unwrap().tx; + bob.send_raw_transaction(&stx).unwrap(); + mine(&node, 1, &da); + + let mut graph = TxGraph::build(alice).unwrap(); + assert!(has_finding(&mut graph, VulnerabilityType::Dust)); +} + +// ─── 4. Dust Spending with Normal Inputs ──────────────────────────────────── + +#[test] +fn detect_dust_spending() { + let node = node(); + let da = node.client.new_address().unwrap(); + mine(&node, 110, &da); + + let alice = node.create_wallet("alice").unwrap(); + let bob = node.create_wallet("bob").unwrap(); + let ba = bob.new_address().unwrap(); + node.client + .send_to_address(&ba, Amount::from_btc(2.0).unwrap()) + .unwrap(); + mine(&node, 1, &da); + + // Give alice a normal UTXO + let alice_normal = alice.new_address().unwrap(); + bob.send_to_address(&alice_normal, Amount::from_btc(0.5).unwrap()) + .unwrap(); + mine(&node, 1, &da); + + // Give alice a dust UTXO via raw tx + let dust_addr = alice.new_address().unwrap(); + let bob_utxos = bob.list_unspent().unwrap(); + let big = bob_utxos + .0 + .iter() + .max_by(|a, b| a.amount.partial_cmp(&b.amount).unwrap()) + .unwrap(); + let big_sats = (big.amount * 1e8).round() as u64; + let dust_sats: u64 = 1_000; + let fee_sats: u64 = 10_000; + + let change_addr = bob.new_address().unwrap(); + let raw = bob + .create_raw_transaction( + &[Input { + txid: big.txid.parse().unwrap(), + vout: big.vout as u64, + sequence: None, + }], + &[ + Output::new(dust_addr, Amount::from_sat(dust_sats)), + Output::new( + change_addr, + Amount::from_sat(big_sats - dust_sats - fee_sats), + ), + ], + ) + .unwrap(); + let tx = raw.transaction().unwrap(); + let signed = bob.sign_raw_transaction_with_wallet(&tx).unwrap(); + let stx = signed.into_model().unwrap().tx; + bob.send_raw_transaction(&stx).unwrap(); + mine(&node, 1, &da); + + // Now alice spends dust + normal together + let utxos = alice.list_unspent().unwrap(); + let dust_u = utxos + .0 + .iter() + .find(|u| (u.amount * 1e8).round() as u64 <= 1000) + .expect("dust utxo"); + let normal_u = utxos + .0 + .iter() + .find(|u| u.amount > 0.001) + .expect("normal utxo"); + + let total_sats = (dust_u.amount * 1e8).round() as u64 + (normal_u.amount * 1e8).round() as u64; + let dest = bob.new_address().unwrap(); + let raw = alice + .create_raw_transaction( + &[ + Input { + txid: dust_u.txid.parse().unwrap(), + vout: dust_u.vout as u64, + sequence: None, + }, + Input { + txid: normal_u.txid.parse().unwrap(), + vout: normal_u.vout as u64, + sequence: None, + }, + ], + &[Output::new(dest, Amount::from_sat(total_sats - 10_000))], + ) + .unwrap(); + let tx = raw.transaction().unwrap(); + let signed = alice.sign_raw_transaction_with_wallet(&tx).unwrap(); + let stx = signed.into_model().unwrap().tx; + alice.send_raw_transaction(&stx).unwrap(); + mine(&node, 1, &da); + + let mut graph = TxGraph::build(alice).unwrap(); + assert!(has_finding(&mut graph, VulnerabilityType::DustSpending)); +} + +// ─── 5. Change Detection ─────────────────────────────────────────────────── + +#[test] +fn detect_change_detection() { + let node = node(); + let da = node.client.new_address().unwrap(); + mine(&node, 110, &da); + + let alice = node.create_wallet("alice").unwrap(); + let bob = node.create_wallet("bob").unwrap(); + + // Fund alice with a clean 1 BTC UTXO + let aa = alice.new_address().unwrap(); + node.client.send_to_address(&aa, Amount::ONE_BTC).unwrap(); + mine(&node, 1, &da); + + // Alice sends a round 0.05 BTC to bob via send_to_address. + // Bitcoin Core will automatically create a change output. + let bob_addr = bob.new_address().unwrap(); + alice + .send_to_address(&bob_addr, Amount::from_sat(5_000_000)) + .unwrap(); + mine(&node, 1, &da); + + let mut graph = TxGraph::build(alice).unwrap(); + assert!(has_finding(&mut graph, VulnerabilityType::ChangeDetection)); +} + +// ─── 6. Consolidation Origin ─────────────────────────────────────────────── + +#[test] +fn detect_consolidation() { + let node = node(); + let da = node.client.new_address().unwrap(); + mine(&node, 110, &da); + + let alice = node.create_wallet("alice").unwrap(); + let bob = node.create_wallet("bob").unwrap(); + let ba = bob.new_address().unwrap(); + node.client + .send_to_address(&ba, Amount::from_btc(2.0).unwrap()) + .unwrap(); + mine(&node, 1, &da); + + // Give alice 4 small UTXOs + for _ in 0..4 { + let a = alice.new_address().unwrap(); + bob.send_to_address(&a, Amount::from_sat(300_000)).unwrap(); + } + mine(&node, 1, &da); + + // Alice consolidates into one address (>=3 inputs, <=2 outputs) + let utxos = alice.list_unspent().unwrap(); + let small: Vec<_> = utxos + .0 + .iter() + .filter(|u| u.amount > 0.002 && u.amount < 0.004) + .collect(); + assert!(small.len() >= 3, "need at least 3 small utxos"); + + let inputs: Vec = small + .iter() + .map(|u| Input { + txid: u.txid.parse().unwrap(), + vout: u.vout as u64, + sequence: None, + }) + .collect(); + let total_sats: u64 = small.iter().map(|u| (u.amount * 1e8).round() as u64).sum(); + let consol_addr = alice.new_address().unwrap(); + let raw = alice + .create_raw_transaction( + &inputs, + &[Output::new( + consol_addr, + Amount::from_sat(total_sats - 10_000), + )], + ) + .unwrap(); + let tx = raw.transaction().unwrap(); + let signed = alice.sign_raw_transaction_with_wallet(&tx).unwrap(); + let stx = signed.into_model().unwrap().tx; + alice.send_raw_transaction(&stx).unwrap(); + mine(&node, 1, &da); + + let mut graph = TxGraph::build(alice).unwrap(); + assert!(has_finding(&mut graph, VulnerabilityType::Consolidation)); +} + +// ─── 7. Script Type Mixing ───────────────────────────────────────────────── + +#[test] +fn detect_script_type_mixing() { + let node = node(); + let da = node.client.new_address().unwrap(); + mine(&node, 110, &da); + + let alice = node.create_wallet("alice").unwrap(); + let bob = node.create_wallet("bob").unwrap(); + let ba = bob.new_address().unwrap(); + node.client + .send_to_address(&ba, Amount::from_btc(2.0).unwrap()) + .unwrap(); + mine(&node, 1, &da); + + // Give alice one P2WPKH and one P2TR utxo + let wpkh_addr = alice.new_address_with_type(AddressType::Bech32).unwrap(); + let tr_addr = alice.new_address_with_type(AddressType::Bech32m).unwrap(); + bob.send_to_address(&wpkh_addr, Amount::from_sat(500_000)) + .unwrap(); + bob.send_to_address(&tr_addr, Amount::from_sat(500_000)) + .unwrap(); + mine(&node, 1, &da); + + // Alice spends both types together + let utxos = alice.list_unspent().unwrap(); + assert!(utxos.0.len() >= 2, "need at least 2 utxos"); + + let inputs: Vec = utxos + .0 + .iter() + .map(|u| Input { + txid: u.txid.parse().unwrap(), + vout: u.vout as u64, + sequence: None, + }) + .collect(); + let total_sats: u64 = utxos + .0 + .iter() + .map(|u| (u.amount * 1e8).round() as u64) + .sum(); + let dest = bob.new_address().unwrap(); + let raw = alice + .create_raw_transaction( + &inputs, + &[Output::new(dest, Amount::from_sat(total_sats - 10_000))], + ) + .unwrap(); + let tx = raw.transaction().unwrap(); + let signed = alice.sign_raw_transaction_with_wallet(&tx).unwrap(); + let stx = signed.into_model().unwrap().tx; + alice.send_raw_transaction(&stx).unwrap(); + mine(&node, 1, &da); + + let mut graph = TxGraph::build(alice).unwrap(); + assert!(has_finding(&mut graph, VulnerabilityType::ScriptTypeMixing)); +} + +// ─── 8. Cluster Merge ────────────────────────────────────────────────────── + +#[test] +fn detect_cluster_merge() { + let node = node(); + let da = node.client.new_address().unwrap(); + mine(&node, 110, &da); + + let alice = node.create_wallet("alice").unwrap(); + let bob = node.create_wallet("bob").unwrap(); + let carol = node.create_wallet("carol").unwrap(); + // Fund bob and carol + let ba = bob.new_address().unwrap(); + let ca = carol.new_address().unwrap(); + node.client + .send_to_address(&ba, Amount::from_btc(2.0).unwrap()) + .unwrap(); + node.client + .send_to_address(&ca, Amount::from_btc(2.0).unwrap()) + .unwrap(); + mine(&node, 1, &da); + + // Bob sends to alice_addr_1, Carol sends to alice_addr_2 + let a1 = alice.new_address().unwrap(); + let a2 = alice.new_address().unwrap(); + bob.send_to_address(&a1, Amount::from_sat(400_000)).unwrap(); + carol + .send_to_address(&a2, Amount::from_sat(400_000)) + .unwrap(); + mine(&node, 1, &da); + + // Alice spends both together -> cluster merge + let utxos = alice.list_unspent().unwrap(); + assert!(utxos.0.len() >= 2, "need at least 2 utxos"); + + let inputs: Vec = utxos + .0 + .iter() + .map(|u| Input { + txid: u.txid.parse().unwrap(), + vout: u.vout as u64, + sequence: None, + }) + .collect(); + let total_sats: u64 = utxos + .0 + .iter() + .map(|u| (u.amount * 1e8).round() as u64) + .sum(); + let dest = bob.new_address().unwrap(); + let raw = alice + .create_raw_transaction( + &inputs, + &[Output::new(dest, Amount::from_sat(total_sats - 10_000))], + ) + .unwrap(); + let tx = raw.transaction().unwrap(); + let signed = alice.sign_raw_transaction_with_wallet(&tx).unwrap(); + let stx = signed.into_model().unwrap().tx; + alice.send_raw_transaction(&stx).unwrap(); + mine(&node, 1, &da); + + let mut graph = TxGraph::build(alice).unwrap(); + assert!(has_finding(&mut graph, VulnerabilityType::ClusterMerge)); +} + +// ─── 9. Lookback Depth / UTXO Age ────────────────────────────────────────── + +#[test] +fn detect_utxo_age_spread() { + let node = node(); + let da = node.client.new_address().unwrap(); + mine(&node, 110, &da); + + let alice = node.create_wallet("alice").unwrap(); + + // Old UTXO + let old_addr = alice.new_address().unwrap(); + node.client + .send_to_address(&old_addr, Amount::from_sat(1_000_000)) + .unwrap(); + mine(&node, 20, &da); + + // New UTXO + let new_addr = alice.new_address().unwrap(); + node.client + .send_to_address(&new_addr, Amount::from_sat(1_000_000)) + .unwrap(); + mine(&node, 1, &da); + + let mut graph = TxGraph::build(alice).unwrap(); + assert!(has_finding(&mut graph, VulnerabilityType::UtxoAgeSpread)); +} + +// ─── 10. Exchange Origin ─────────────────────────────────────────────────── + +#[test] +fn detect_exchange_origin() { + let node = node(); + let da = node.client.new_address().unwrap(); + mine(&node, 110, &da); + + let alice = node.create_wallet("alice").unwrap(); + let exchange = node.create_wallet("exchange").unwrap(); + let bob = node.create_wallet("bob").unwrap(); + // Fund exchange + let ea = exchange.new_address().unwrap(); + node.client + .send_to_address(&ea, Amount::from_btc(5.0).unwrap()) + .unwrap(); + mine(&node, 1, &da); + + // Exchange batch withdrawal to 8 addresses (alice gets some, bob gets some) + let mut amounts: BTreeMap = BTreeMap::new(); + for i in 0..5u64 { + let a = alice.new_address().unwrap(); + amounts.insert(a, Amount::from_sat(1_000_000 + i * 100_000)); + } + for i in 0..3u64 { + let b = bob.new_address().unwrap(); + amounts.insert(b, Amount::from_sat(1_000_000 + i * 200_000)); + } + let send_result = exchange.send_many(amounts).unwrap(); + mine(&node, 1, &da); + + let exchange_txids: HashSet = [send_result.0.clone()].into_iter().collect(); + let mut graph = TxGraph::build(alice).unwrap(); + assert!(has_finding_with( + &mut graph, + VulnerabilityType::ExchangeOrigin, + None, + Some(&exchange_txids) + )); +} + +// ─── 11. Tainted UTXOs ───────────────────────────────────────────────────── + +#[test] +fn detect_tainted_utxo_merge() { + let node = node(); + let da = node.client.new_address().unwrap(); + mine(&node, 110, &da); + + let alice = node.create_wallet("alice").unwrap(); + let risky = node.create_wallet("risky").unwrap(); + let bob = node.create_wallet("bob").unwrap(); + + // Fund + let ra = risky.new_address().unwrap(); + let ba = bob.new_address().unwrap(); + node.client + .send_to_address(&ra, Amount::from_btc(2.0).unwrap()) + .unwrap(); + node.client + .send_to_address(&ba, Amount::from_btc(2.0).unwrap()) + .unwrap(); + mine(&node, 1, &da); + + // Risky sends to alice + let ta = alice.new_address().unwrap(); + let taint_result = risky + .send_to_address(&ta, Amount::from_sat(1_000_000)) + .unwrap(); + let taint_txid = taint_result.0.clone(); + + // Bob sends clean to alice + let ca = alice.new_address().unwrap(); + bob.send_to_address(&ca, Amount::from_sat(1_000_000)) + .unwrap(); + mine(&node, 1, &da); + + // Alice spends both together (tainted + clean) + let utxos = alice.list_unspent().unwrap(); + assert!(utxos.0.len() >= 2); + + let inputs: Vec = utxos + .0 + .iter() + .map(|u| Input { + txid: u.txid.parse().unwrap(), + vout: u.vout as u64, + sequence: None, + }) + .collect(); + let total_sats: u64 = utxos + .0 + .iter() + .map(|u| (u.amount * 1e8).round() as u64) + .sum(); + let carol = node.create_wallet("carol").unwrap(); + let dest = carol.new_address().unwrap(); + let raw = alice + .create_raw_transaction( + &inputs, + &[Output::new(dest, Amount::from_sat(total_sats - 10_000))], + ) + .unwrap(); + let tx = raw.transaction().unwrap(); + let signed = alice.sign_raw_transaction_with_wallet(&tx).unwrap(); + let stx = signed.into_model().unwrap().tx; + alice.send_raw_transaction(&stx).unwrap(); + mine(&node, 1, &da); + + let risky_txids: HashSet = [taint_txid].into_iter().collect(); + let mut graph = TxGraph::build(alice).unwrap(); + assert!(has_finding_with( + &mut graph, + VulnerabilityType::TaintedUtxoMerge, + Some(&risky_txids), + None + )); +} + +// ─── 12. Behavioral Fingerprint ──────────────────────────────────────────── + +#[test] +fn detect_behavioral_fingerprint() { + let node = node(); + let da = node.client.new_address().unwrap(); + mine(&node, 110, &da); + + let alice = node.create_wallet("alice").unwrap(); + let carol = node.create_wallet("carol").unwrap(); + + // Fund alice generously + let aa = alice.new_address().unwrap(); + node.client + .send_to_address(&aa, Amount::from_btc(5.0).unwrap()) + .unwrap(); + mine(&node, 1, &da); + + // Alice sends 5 round-amount payments (behavioral pattern) + for i in 1u64..=5 { + let dest = carol.new_address().unwrap(); + alice + .send_to_address(&dest, Amount::from_sat(i * 1_000_000)) + .unwrap(); + mine(&node, 1, &da); + } + + let mut graph = TxGraph::build(alice).unwrap(); + let report = graph.detect_all(None, None); + assert!(report + .findings + .iter() + .any(|f| f.vulnerability_type == VulnerabilityType::BehavioralFingerprint)); +} + +// ─── Full Report Smoke Test ───────────────────────────────────────────────── + +#[test] +fn full_report_generates() { + let node = node(); + let da = node.client.new_address().unwrap(); + mine(&node, 110, &da); + + let alice = node.create_wallet("alice").unwrap(); + let aa = alice.new_address().unwrap(); + node.client.send_to_address(&aa, Amount::ONE_BTC).unwrap(); + mine(&node, 1, &da); + + let mut graph = TxGraph::build(alice).unwrap(); + let report = graph.detect_all(None, None); + + assert_eq!( + report.summary.findings + report.summary.warnings, + report.findings.len() + report.warnings.len() + ); + assert_eq!(report.stats.utxos_current, 1); +} + +// ─── 13. Dust Attack Detection ───────────────────────────────────────────── + +#[test] +fn detect_dust_attack() { + let node = node(); + let da = node.client.new_address().unwrap(); + mine(&node, 110, &da); + + let alice = node.create_wallet("alice").unwrap(); + let attacker = node.create_wallet("attacker").unwrap(); + + // Fund attacker + let aa = attacker.new_address().unwrap(); + node.client + .send_to_address(&aa, Amount::from_btc(1.0).unwrap()) + .unwrap(); + mine(&node, 1, &da); + + // Attacker creates a dust attack: 1 input, 12 outputs (all tiny) to + // various addresses including some of alice's + let attacker_utxos = attacker.list_unspent().unwrap(); + let big = attacker_utxos + .0 + .iter() + .max_by(|a, b| a.amount.partial_cmp(&b.amount).unwrap()) + .unwrap(); + let big_sats = (big.amount * 1e8).round() as u64; + let dust_sats: u64 = 546; + let n_dust: u64 = 12; + let fee_sats: u64 = 10_000; + + // Create 12 tiny outputs — 5 to alice, 7 to random other wallets + let mut outputs_vec = Vec::new(); + for _ in 0..5 { + let a = alice.new_address().unwrap(); + outputs_vec.push(Output::new(a, Amount::from_sat(dust_sats))); + } + // Create "other" wallets for diversity + for i in 0..7 { + let other_name = format!("other_{}", i); + let other = node.create_wallet(&other_name).unwrap(); + let oa = other.new_address().unwrap(); + outputs_vec.push(Output::new(oa, Amount::from_sat(dust_sats))); + } + + let change_sats = big_sats - (dust_sats * n_dust) - fee_sats; + let change_addr = attacker.new_address().unwrap(); + outputs_vec.push(Output::new(change_addr, Amount::from_sat(change_sats))); + + let raw = attacker + .create_raw_transaction( + &[Input { + txid: big.txid.parse().unwrap(), + vout: big.vout as u64, + sequence: None, + }], + &outputs_vec, + ) + .unwrap(); + let tx = raw.transaction().unwrap(); + let signed = attacker.sign_raw_transaction_with_wallet(&tx).unwrap(); + let stx = signed.into_model().unwrap().tx; + attacker.send_raw_transaction(&stx).unwrap(); + mine(&node, 1, &da); + + let mut graph = TxGraph::build(alice).unwrap(); + assert!(has_finding(&mut graph, VulnerabilityType::DustAttack)); +} + +// ─── 14. Peel Chain Detection ────────────────────────────────────────────── + +#[test] +fn detect_peel_chain() { + let node = node(); + let da = node.client.new_address().unwrap(); + mine(&node, 110, &da); + + let alice = node.create_wallet("alice").unwrap(); + let bob = node.create_wallet("bob").unwrap(); + + // Fund alice + let aa = alice.new_address().unwrap(); + node.client + .send_to_address(&aa, Amount::from_btc(1.0).unwrap()) + .unwrap(); + mine(&node, 1, &da); + + // Alice creates a peel chain: 3 consecutive 2-output transactions + // where the large output feeds the next transaction + for i in 0..3 { + let utxos = alice.list_unspent().unwrap(); + let big = utxos + .0 + .iter() + .max_by(|a, b| a.amount.partial_cmp(&b.amount).unwrap()) + .unwrap(); + let big_sats = (big.amount * 1e8).round() as u64; + let peel_amount: u64 = 50_000 + i * 10_000; // Small "peeled" payment + let fee_sats: u64 = 10_000; + let change_sats = big_sats - peel_amount - fee_sats; + + let peel_addr = bob.new_address().unwrap(); + let change_addr = alice.new_address().unwrap(); + let raw = alice + .create_raw_transaction( + &[Input { + txid: big.txid.parse().unwrap(), + vout: big.vout as u64, + sequence: None, + }], + &[ + Output::new(peel_addr, Amount::from_sat(peel_amount)), + Output::new(change_addr, Amount::from_sat(change_sats)), + ], + ) + .unwrap(); + let tx = raw.transaction().unwrap(); + let signed = alice.sign_raw_transaction_with_wallet(&tx).unwrap(); + let stx = signed.into_model().unwrap().tx; + alice.send_raw_transaction(&stx).unwrap(); + mine(&node, 1, &da); + } + + let mut graph = TxGraph::build(alice).unwrap(); + assert!(has_finding(&mut graph, VulnerabilityType::PeelChain)); +} + +// ─── 15. Deterministic Link Detection ────────────────────────────────────── + +#[test] +fn detect_deterministic_links() { + let node = node(); + let da = node.client.new_address().unwrap(); + mine(&node, 110, &da); + + let alice = node.create_wallet("alice").unwrap(); + let bob = node.create_wallet("bob").unwrap(); + let ba = bob.new_address().unwrap(); + node.client + .send_to_address(&ba, Amount::from_btc(2.0).unwrap()) + .unwrap(); + mine(&node, 1, &da); + + // Give alice two UTXOs: 700k and 400k sats + let a1 = alice.new_address().unwrap(); + let a2 = alice.new_address().unwrap(); + bob.send_to_address(&a1, Amount::from_sat(700_000)).unwrap(); + bob.send_to_address(&a2, Amount::from_sat(400_000)).unwrap(); + mine(&node, 1, &da); + + // Alice spends both into two outputs: 600k and 400k. + // Only one valid interpretation: 700k→600k, 400k→400k + // (400k < 600k so it can't fund the 600k output = deterministic link) + let utxos = alice.list_unspent().unwrap(); + let inputs: Vec = utxos + .0 + .iter() + .map(|u| Input { + txid: u.txid.parse().unwrap(), + vout: u.vout as u64, + sequence: None, + }) + .collect(); + + let dest1 = bob.new_address().unwrap(); + let dest2 = bob.new_address().unwrap(); + let raw = alice + .create_raw_transaction( + &inputs, + &[ + Output::new(dest1, Amount::from_sat(600_000)), + Output::new(dest2, Amount::from_sat(400_000)), + ], + ) + .unwrap(); + let tx = raw.transaction().unwrap(); + let signed = alice.sign_raw_transaction_with_wallet(&tx).unwrap(); + let stx = signed.into_model().unwrap().tx; + alice.send_raw_transaction(&stx).unwrap(); + mine(&node, 1, &da); + + let mut graph = TxGraph::build(alice).unwrap(); + assert!(has_finding( + &mut graph, + VulnerabilityType::DeterministicLink + )); +} + +// ─── 16. Unnecessary Input Detection ─────────────────────────────────────── + +#[test] +fn detect_unnecessary_input() { + let node = node(); + let da = node.client.new_address().unwrap(); + mine(&node, 110, &da); + + let alice = node.create_wallet("alice").unwrap(); + let bob = node.create_wallet("bob").unwrap(); + let ba = bob.new_address().unwrap(); + node.client + .send_to_address(&ba, Amount::from_btc(3.0).unwrap()) + .unwrap(); + mine(&node, 1, &da); + + // Give alice a big UTXO (1 BTC) and a small UTXO (0.01 BTC) + let a1 = alice.new_address().unwrap(); + let a2 = alice.new_address().unwrap(); + bob.send_to_address(&a1, Amount::from_btc(1.0).unwrap()) + .unwrap(); + bob.send_to_address(&a2, Amount::from_sat(1_000_000)) + .unwrap(); + mine(&node, 1, &da); + + // Alice sends 0.005 BTC (500k sats) using BOTH inputs — unnecessary + // because the 1 BTC input alone is enough + let utxos = alice.list_unspent().unwrap(); + let inputs: Vec = utxos + .0 + .iter() + .map(|u| Input { + txid: u.txid.parse().unwrap(), + vout: u.vout as u64, + sequence: None, + }) + .collect(); + assert!(inputs.len() >= 2); + + let total_sats: u64 = utxos + .0 + .iter() + .map(|u| (u.amount * 1e8).round() as u64) + .sum(); + let payment_sats: u64 = 500_000; + let fee_sats: u64 = 10_000; + let change_sats = total_sats - payment_sats - fee_sats; + + let dest = bob.new_address().unwrap(); + let change_addr = alice.new_address().unwrap(); + let raw = alice + .create_raw_transaction( + &inputs, + &[ + Output::new(dest, Amount::from_sat(payment_sats)), + Output::new(change_addr, Amount::from_sat(change_sats)), + ], + ) + .unwrap(); + let tx = raw.transaction().unwrap(); + let signed = alice.sign_raw_transaction_with_wallet(&tx).unwrap(); + let stx = signed.into_model().unwrap().tx; + alice.send_raw_transaction(&stx).unwrap(); + mine(&node, 1, &da); + + let mut graph = TxGraph::build(alice).unwrap(); + assert!(has_finding(&mut graph, VulnerabilityType::UnnecessaryInput)); +} + +// ─── 17. Toxic Change Detection ──────────────────────────────────────────── + +#[test] +fn detect_toxic_change() { + let node = node(); + let da = node.client.new_address().unwrap(); + mine(&node, 110, &da); + + let alice = node.create_wallet("alice").unwrap(); + let bob = node.create_wallet("bob").unwrap(); + let ba = bob.new_address().unwrap(); + node.client + .send_to_address(&ba, Amount::from_btc(3.0).unwrap()) + .unwrap(); + mine(&node, 1, &da); + + // Give alice a UTXO that will produce toxic change + let aa = alice.new_address().unwrap(); + bob.send_to_address(&aa, Amount::from_sat(100_000)).unwrap(); + mine(&node, 1, &da); + + // Alice sends, leaving tiny change (5000 sats — in toxic range) + let utxos = alice.list_unspent().unwrap(); + let big = utxos + .0 + .iter() + .max_by(|a, b| a.amount.partial_cmp(&b.amount).unwrap()) + .unwrap(); + let big_sats = (big.amount * 1e8).round() as u64; + let fee_sats: u64 = 10_000; + let toxic_change: u64 = 5_000; + let payment_sats = big_sats - fee_sats - toxic_change; + + let dest = bob.new_address().unwrap(); + let change_addr = alice.new_address().unwrap(); + let raw = alice + .create_raw_transaction( + &[Input { + txid: big.txid.parse().unwrap(), + vout: big.vout as u64, + sequence: None, + }], + &[ + Output::new(dest, Amount::from_sat(payment_sats)), + Output::new(change_addr.clone(), Amount::from_sat(toxic_change)), + ], + ) + .unwrap(); + let tx = raw.transaction().unwrap(); + let signed = alice.sign_raw_transaction_with_wallet(&tx).unwrap(); + let stx = signed.into_model().unwrap().tx; + alice.send_raw_transaction(&stx).unwrap(); + mine(&node, 1, &da); + + // Now give alice another big UTXO + let aa2 = alice.new_address().unwrap(); + bob.send_to_address(&aa2, Amount::from_btc(1.0).unwrap()) + .unwrap(); + mine(&node, 1, &da); + + // Alice spends toxic change + big UTXO together (the vulnerability) + let utxos2 = alice.list_unspent().unwrap(); + let inputs2: Vec = utxos2 + .0 + .iter() + .map(|u| Input { + txid: u.txid.parse().unwrap(), + vout: u.vout as u64, + sequence: None, + }) + .collect(); + assert!(inputs2.len() >= 2); + + let total2: u64 = utxos2 + .0 + .iter() + .map(|u| (u.amount * 1e8).round() as u64) + .sum(); + let dest2 = bob.new_address().unwrap(); + let raw2 = alice + .create_raw_transaction( + &inputs2, + &[Output::new(dest2, Amount::from_sat(total2 - 10_000))], + ) + .unwrap(); + let tx2 = raw2.transaction().unwrap(); + let signed2 = alice.sign_raw_transaction_with_wallet(&tx2).unwrap(); + let stx2 = signed2.into_model().unwrap().tx; + alice.send_raw_transaction(&stx2).unwrap(); + mine(&node, 1, &da); + + let mut graph = TxGraph::build(alice).unwrap(); + assert!(has_finding(&mut graph, VulnerabilityType::ToxicChange)); +} From b88e61c55a4e2095123f7b88eabf7e7def8e3427 Mon Sep 17 00:00:00 2001 From: Renato Britto Date: Tue, 24 Mar 2026 20:25:18 -0300 Subject: [PATCH 4/5] refactor: remove python detection scripts for Bitcoin privacy vulnerability --- backend/script/README.md | 29 - backend/script/bitcoin_rpc.py | 140 ---- backend/script/config.ini | 17 - backend/script/detect.py | 1289 --------------------------------- backend/script/miner | 604 --------------- backend/script/reproduce.py | 418 ----------- backend/script/setup.sh | 157 ---- 7 files changed, 2654 deletions(-) delete mode 100644 backend/script/README.md delete mode 100644 backend/script/bitcoin_rpc.py delete mode 100644 backend/script/config.ini delete mode 100644 backend/script/detect.py delete mode 100755 backend/script/miner delete mode 100644 backend/script/reproduce.py delete mode 100755 backend/script/setup.sh diff --git a/backend/script/README.md b/backend/script/README.md deleted file mode 100644 index 7f3efbf..0000000 --- a/backend/script/README.md +++ /dev/null @@ -1,29 +0,0 @@ -pass: "aW2u~fYiuLu3)%a" - -METAMASK: -1.twenty -2.series -3.camera -4.invite -5.dismiss -6.gentle -7.dose -8.hotel -9.circle -10.eight -11.rotate -12.assault - -ENKRYPT: -damage -scare -aerobic -eagle -club -typical -cricket -kick -jaguar -paddle -void -dinner \ No newline at end of file diff --git a/backend/script/bitcoin_rpc.py b/backend/script/bitcoin_rpc.py deleted file mode 100644 index 0e06552..0000000 --- a/backend/script/bitcoin_rpc.py +++ /dev/null @@ -1,140 +0,0 @@ -""" -bitcoin_rpc.py — Thin wrapper around bitcoin-cli for Python tests. -Connection settings are read from config.ini in the same directory. -""" - -import json -import subprocess -import os -import configparser - -# ── Load config ────────────────────────────────────────────────────────────── - -def _load_config(): - cfg = configparser.ConfigParser() - config_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.ini") - cfg.read(config_path) - return cfg["bitcoin"] if "bitcoin" in cfg else {} - -def _build_base_args(section): - cli_bin = section.get("cli", "bitcoin-cli") - network = section.get("network", "regtest").strip().lower() - - args = [cli_bin] - - # Datadir — resolve relative paths from this file's directory - datadir = section.get("datadir", "").strip() - if datadir: - if not os.path.isabs(datadir): - datadir = os.path.join(os.path.dirname(os.path.abspath(__file__)), datadir) - args.append(f"-datadir={datadir}") - - network_flags = { - "regtest": "-regtest", - "testnet": "-testnet", - "signet": "-signet", - } - if network in network_flags: - args.append(network_flags[network]) - - for key, flag in [("rpchost", "-rpcconnect"), ("rpcport", "-rpcport"), - ("rpcuser", "-rpcuser"), ("rpcpassword", "-rpcpassword")]: - value = section.get(key, "").strip() - if value: - args.append(f"{flag}={value}") - - return args - -_cfg = _load_config() -_BASE_ARGS = _build_base_args(_cfg) - -def cli(*args, wallet=None): - """Call bitcoin-cli [network] [wallet] and return parsed JSON or string.""" - cmd = list(_BASE_ARGS) - if wallet: - cmd.append(f"-rpcwallet={wallet}") - cmd.extend(str(a) for a in args) - - result = subprocess.run(cmd, capture_output=True, text=True, timeout=60) - if result.returncode != 0: - raise RuntimeError(f"bitcoin-cli error: {result.stderr.strip()}\n cmd: {' '.join(cmd)}") - - output = result.stdout.strip() - if not output: - return None - try: - return json.loads(output) - except json.JSONDecodeError: - return output - - -def mine_blocks(n=1): - """Mine n blocks on regtest using generatetoaddress.""" - miner_addr = cli("getnewaddress", "", "bech32", wallet="miner") - cli("generatetoaddress", n, miner_addr) - return int(cli("getblockcount")) - - -def get_tx(txid): - """Get decoded transaction.""" - return cli("getrawtransaction", txid, "true") - - -def get_utxos(wallet_name, min_conf=0): - """List unspent outputs for a wallet.""" - return cli("listunspent", min_conf, wallet=wallet_name) - - -def get_balance(wallet_name): - """Get wallet balance.""" - return float(cli("getbalance", wallet=wallet_name)) - - -def send_raw(hex_tx): - """Broadcast a raw transaction.""" - return cli("sendrawtransaction", hex_tx) - - -def create_funded_psbt(wallet_name, inputs, outputs, options=None): - """Create a funded PSBT.""" - args = ["walletcreatefundedpsbt", json.dumps(inputs), json.dumps(outputs), 0] - if options: - args.append(json.dumps(options)) - return cli(*args, wallet=wallet_name) - - -def process_psbt(wallet_name, psbt): - """Sign a PSBT.""" - return cli("walletprocesspsbt", psbt, wallet=wallet_name) - - -def finalize_psbt(psbt): - """Finalize a PSBT.""" - return cli("finalizepsbt", psbt) - - -def create_raw_tx(inputs, outputs): - """Create a raw transaction.""" - return cli("createrawtransaction", json.dumps(inputs), json.dumps(outputs)) - - -def sign_raw_tx(wallet_name, hex_tx): - """Sign a raw transaction.""" - return cli("signrawtransactionwithwallet", hex_tx, wallet=wallet_name) - - -def get_block_count(): - """Get current block height.""" - return int(cli("getblockcount")) - - -def get_new_address(wallet_name, addr_type="bech32"): - """Get a new address.""" - return cli("getnewaddress", "", addr_type, wallet=wallet_name) - - -def send_to_address(wallet_name, address, amount): - """Send BTC to an address.""" - return cli("sendtoaddress", address, f"{amount:.8f}", wallet=wallet_name) - - diff --git a/backend/script/config.ini b/backend/script/config.ini deleted file mode 100644 index f863e5f..0000000 --- a/backend/script/config.ini +++ /dev/null @@ -1,17 +0,0 @@ -[bitcoin] -# Network to connect to: regtest | testnet | signet | mainnet -network = regtest - -# Path to the bitcoin-cli binary (use full path if not on PATH) -cli = bitcoin-cli - -# Data directory for bitcoind (matches setup.sh). -# Relative paths are resolved from the directory containing this file. -datadir = bitcoin-data - -# Optional: override RPC connection details. -# Leave these blank to use cookie auth from the datadir. -rpchost = -rpcport = -rpcuser = -rpcpassword = diff --git a/backend/script/detect.py b/backend/script/detect.py deleted file mode 100644 index 1a144f1..0000000 --- a/backend/script/detect.py +++ /dev/null @@ -1,1289 +0,0 @@ -#!/usr/bin/env python3 -""" -detect.py -========= -Blockchain privacy vulnerability detector. - -INPUT: One or more output descriptors (or --wallet to read them). -OUTPUT: Every privacy vulnerability found for that descriptor's address set. - -The detector creates a temporary watch-only wallet, imports descriptors with -a full rescan, then analyses all historical transactions touching any derived -address. It never scans the entire chain — only transactions the wallet knows. - -Usage: - python3 detect.py --wallet alice - python3 detect.py "wpkh([fp/84h/1h/0h]tpub.../0/*)#checksum" "wpkh([fp/84h/1h/0h]tpub.../1/*)#checksum" - python3 detect.py --wallet alice --known-risky-wallets risky --known-exchange-wallets exchange -""" - -import sys -import os -import json -import argparse -from collections import defaultdict - -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -from bitcoin_rpc import cli, get_tx - -FINDINGS = [] -WARNINGS = [] - -def section(title): - print(f"[{title}]", file=sys.stderr) - -def finding(msg): - FINDINGS.append(msg) - -def warn(msg): - WARNINGS.append(msg) - -def ok(msg): - print(f"ok: {msg}", file=sys.stderr) - -def info(msg): - print(f" {msg}", file=sys.stderr) - - -# ═══════════════════════════════════════════════════════════════════════════════ -# 1. WALLET + ADDRESS RESOLUTION -# ═══════════════════════════════════════════════════════════════════════════════ - -def resolve_descriptors(args): - """Get the descriptor list from args: either --wallet or positional descriptors.""" - descs = [] - if args.wallet: - result = cli("listdescriptors", wallet=args.wallet) - for d in result["descriptors"]: - descs.append({ - "desc": d["desc"], - "internal": d.get("internal", False), - "active": d.get("active", True), - "range_end": d.get("range", [0, 999])[1] if isinstance(d.get("range"), list) else d.get("range", 999), - }) - else: - for raw in args.descriptors: - base = raw.split("#")[0] - if "/0/*" in base: - candidates = [(base, False), (base.replace("/0/*", "/1/*"), True)] - elif "/1/*" in base: - candidates = [(base.replace("/1/*", "/0/*"), False), (base, True)] - else: - candidates = [(base, False)] - for desc, internal in candidates: - try: - normalized = cli("getdescriptorinfo", desc)["descriptor"] - except Exception: - normalized = desc - descs.append({ - "desc": normalized, - "internal": internal, - "active": True, - "range_end": 999, - }) - return descs - - -def derive_all_addresses(descriptors): - """Derive addresses from all descriptors, return {address -> (desc_type, internal, index)}.""" - addr_map = {} # address -> metadata - for dinfo in descriptors: - desc = dinfo["desc"] - rng = min(dinfo["range_end"], 999) - # Detect descriptor type - dtype = "unknown" - if desc.startswith("wpkh("): dtype = "p2wpkh" - elif desc.startswith("tr("): dtype = "p2tr" - elif desc.startswith("sh(wpkh("): dtype = "p2sh-p2wpkh" - elif desc.startswith("pkh("): dtype = "p2pkh" - - try: - addrs = cli("deriveaddresses", desc, f"[0,{rng}]") - if addrs: - for i, a in enumerate(addrs): - addr_map[a] = { - "type": dtype, - "internal": dinfo["internal"], - "index": i, - } - except Exception as e: - info(f"Could not derive from {desc[:40]}…: {e}") - return addr_map - - -def build_scan_wallet(descriptors, wallet_name="_detect_scan"): - """Create a temporary watch-only wallet with descriptors, do full rescan.""" - # Clean up if exists - try: - cli("unloadwallet", wallet_name) - except Exception: - pass - - try: - cli("createwallet", wallet_name, "true", "true", "", "false", "true") - except Exception: - try: - cli("loadwallet", wallet_name) - except Exception: - pass - - import_batch = [] - for d in descriptors: - import_batch.append({ - "desc": d["desc"], - "timestamp": 0, # full rescan - "internal": d["internal"], - "active": d["active"], - "range": [0, d["range_end"]], - }) - - result = cli("importdescriptors", json.dumps(import_batch), wallet=wallet_name) - # Check results - for r in (result or []): - if not r.get("success"): - info(f"Import warning: {r.get('error', {}).get('message', 'unknown')}") - - return wallet_name - - -def get_all_transactions(wallet_name, count=10000): - """Get full transaction history for the wallet.""" - txs = cli("listtransactions", "*", count, 0, "true", wallet=wallet_name) - return txs or [] - - -def get_all_utxos(wallet_name): - """Get all UTXOs (confirmed and unconfirmed).""" - return cli("listunspent", 0, 9999999, wallet=wallet_name) or [] - - -# ═══════════════════════════════════════════════════════════════════════════════ -# 2. TRANSACTION GRAPH BUILDER -# ═══════════════════════════════════════════════════════════════════════════════ - -class TxGraph: - """Indexed view of all transactions touching our address set.""" - - def __init__(self, addr_map, wallet_txs, utxos): - self.addr_map = addr_map # {address -> metadata} - self.our_addrs = set(addr_map.keys()) - self.utxos = utxos # current UTXOs - self.tx_cache = {} # txid -> decoded tx - self._input_cache = {} # txid -> parsed input addresses - self._output_cache = {} # txid -> parsed output addresses - self.our_txids = set() # txids we participate in - - # Index: address -> list of (txid, direction, value) - self.addr_txs = defaultdict(list) # address -> [{txid, direction, amount}] - # Index: txid -> list of our addresses involved - self.tx_addrs = defaultdict(set) - - # Build from wallet tx list - for wtx in wallet_txs: - txid = wtx.get("txid", "") - addr = wtx.get("address", "") - cat = wtx.get("category", "") # send/receive - amount = wtx.get("amount", 0) - if txid: - self.our_txids.add(txid) - if addr and txid: - self.addr_txs[addr].append({ - "txid": txid, "category": cat, "amount": amount, - "confirmations": wtx.get("confirmations", 0), - "blockheight": wtx.get("blockheight", 0), - }) - self.tx_addrs[txid].add(addr) - - def fetch_tx(self, txid): - """Get decoded transaction (cached).""" - if txid not in self.tx_cache: - try: - self.tx_cache[txid] = get_tx(txid) - except Exception: - return None - return self.tx_cache[txid] - - def get_input_addresses(self, txid): - """Get all input addresses for a transaction (cached).""" - if txid in self._input_cache: - return self._input_cache[txid] - tx = self.fetch_tx(txid) - if not tx: - self._input_cache[txid] = [] - return [] - addrs = [] - for vin in tx.get("vin", []): - if vin.get("coinbase"): - continue - parent = self.fetch_tx(vin["txid"]) - if parent: - vout_data = parent["vout"][vin["vout"]] - addr = vout_data.get("scriptPubKey", {}).get("address", "") - value = vout_data.get("value", 0) - addrs.append({"address": addr, "value": value, "txid": vin["txid"], "vout": vin["vout"]}) - self._input_cache[txid] = addrs - return addrs - - def get_output_addresses(self, txid): - """Get all output addresses for a transaction (cached).""" - if txid in self._output_cache: - return self._output_cache[txid] - tx = self.fetch_tx(txid) - if not tx: - self._output_cache[txid] = [] - return [] - addrs = [] - for vout in tx.get("vout", []): - addr = vout.get("scriptPubKey", {}).get("address", "") - addrs.append({ - "address": addr, - "value": vout["value"], - "n": vout["n"], - "type": vout.get("scriptPubKey", {}).get("type", "unknown"), - }) - self._output_cache[txid] = addrs - return addrs - - def is_ours(self, address): - return address in self.our_addrs - - def get_script_type(self, address): - """Return the script type metadata for one of our addresses.""" - meta = self.addr_map.get(address) - if meta: - return meta["type"] - # Heuristic from prefix (supports mainnet, testnet/signet, regtest) - if address.startswith(("tb1q", "bc1q", "bcrt1q")): - return "p2wpkh" - if address.startswith(("tb1p", "bc1p", "bcrt1p")): - return "p2tr" - if address.startswith(("2", "3")): - return "p2sh-p2wpkh" - return "unknown" - - -# ═══════════════════════════════════════════════════════════════════════════════ -# 3. VULNERABILITY DETECTORS -# -# Each detector receives the TxGraph and reports findings. -# ═══════════════════════════════════════════════════════════════════════════════ - -def detect_01_address_reuse(g: TxGraph): - """Detect addresses that appear as recipients in multiple transactions.""" - section("1 · Address Reuse") - reused = {} - for addr in g.our_addrs: - # Count distinct TXIDs where this address received funds - receive_txids = set() - for entry in g.addr_txs.get(addr, []): - if entry["category"] == "receive": - receive_txids.add(entry["txid"]) - if len(receive_txids) >= 2: - reused[addr] = receive_txids - - if not reused: - ok("No address reuse detected.") - return - - for addr, txids in reused.items(): - meta = g.addr_map.get(addr, {}) - role = "change" if meta.get("internal") else "receive" - tx_list = [] - for txid in sorted(txids): - tx = g.fetch_tx(txid) - tx_list.append({"txid": txid, "confirmations": tx.get("confirmations", 0) if tx else 0}) - finding({ - "type": "ADDRESS_REUSE", - "severity": "HIGH", - "description": f"Address {addr} ({role}) reused across {len(txids)} transactions", - "details": { - "address": addr, - "role": role, - "tx_count": len(txids), - "txids": tx_list, - }, - "correction": ( - "Generate a fresh address for every payment received. " - "Enable HD wallet derivation (BIP-32/44/84) so your wallet produces a new address automatically. " - "If the address is a static donation or payment address, consider a Lightning invoice or a " - "payment-code scheme (BIP-47) that hides the on-chain address." - ), - }) - - -def detect_02_cioh(g: TxGraph): - """Detect multi-input transactions (CIOH) and verify input ownership.""" - section("2 · Common Input Ownership Heuristic (CIOH)") - found_any = False - - for txid in g.our_txids: - tx = g.fetch_tx(txid) - if not tx or len(tx.get("vin", [])) < 2: - continue - - input_addrs = g.get_input_addresses(txid) - if len(input_addrs) < 2: - continue - - # Classify inputs: ours vs external - our_inputs = [ia for ia in input_addrs if g.is_ours(ia["address"])] - ext_inputs = [ia for ia in input_addrs if not g.is_ours(ia["address"])] - total_inputs = len(input_addrs) - n_ours = len(our_inputs) - - if n_ours < 2: - # Only 1 of ours — CIOH doesn't expose us - continue - - found_any = True - n_outputs = len(tx.get("vout", [])) - ownership_pct = n_ours / total_inputs * 100 - - severity = "CRITICAL" if n_ours == total_inputs else "HIGH" - finding({ - "type": "CIOH", - "severity": severity, - "description": f"TX {txid} merges {n_ours}/{total_inputs} of your inputs ({round(ownership_pct)}% ownership)", - "details": { - "txid": txid, - "total_inputs": total_inputs, - "our_inputs": n_ours, - "external_inputs": len(ext_inputs), - "ownership_pct": round(ownership_pct), - "our_addresses": [ - { - "address": ia["address"], - "role": "change" if g.addr_map.get(ia["address"], {}).get("internal") else "receive", - "amount_btc": round(ia["value"], 8), - } - for ia in our_inputs - ], - }, - "correction": ( - "Use coin control to select only one UTXO per transaction when the payment amount allows it. " - "If consolidation is unavoidable, do it privately via a CoinJoin round so the link between " - "inputs is indistinguishable from other participants. " - "Alternatively, use the Lightning Network for small payments to avoid creating on-chain multi-input transactions." - ), - }) - - if not found_any: - ok("No multi-input transactions with ≥2 of your addresses detected.") - - -def detect_03_dust(g: TxGraph): - """Detect dust UTXOs (current and historical).""" - section("3 · Dust UTXO Detection") - DUST_SATS = 1000 - STRICT_DUST = 546 - - found = [] - for utxo in g.utxos: - sats = int(round(utxo["amount"] * 1e8)) - if sats <= DUST_SATS and g.is_ours(utxo.get("address", "")): - found.append(utxo) - - # Also check historical: any tx that sent dust to our addresses - hist_dust = [] - for txid in g.our_txids: - outputs = g.get_output_addresses(txid) - for out in outputs: - sats = int(round(out["value"] * 1e8)) - if sats <= DUST_SATS and g.is_ours(out["address"]): - hist_dust.append({"txid": txid, "address": out["address"], "sats": sats}) - - if not found and not hist_dust: - ok("No dust UTXOs detected.") - return - - if found: - for u in found: - sats = int(round(u["amount"] * 1e8)) - label = "STRICT_DUST" if sats <= STRICT_DUST else "dust-class" - finding({ - "type": "DUST", - "severity": "HIGH" if label == "STRICT_DUST" else "MEDIUM", - "description": f"Dust UTXO at {u['address']} ({sats} sats, {label}, unspent)", - "details": { - "status": "unspent", - "address": u["address"], - "sats": sats, - "label": label, - "txid": u["txid"], - "vout": u["vout"], - }, - "correction": ( - "Do not spend this dust output — doing so links your other inputs to this address via CIOH. " - "Use your wallet's coin freeze / UTXO management feature to exclude it from future transactions. " - "If the wallet does not support freezing, consider processing it through a CoinJoin round " - "so the tracking token is obfuscated before it touches any of your real UTXOs." - ), - }) - - # Deduplicate historical - seen = set() - unique_hist = [] - for h in hist_dust: - key = (h["txid"], h["address"]) - if key not in seen: - seen.add(key) - unique_hist.append(h) - - if unique_hist: - current_keys = {(u["txid"], u.get("address", "")) for u in found} - for h in unique_hist: - if (h["txid"], h["address"]) not in current_keys: - finding({ - "type": "DUST", - "severity": "LOW", - "description": f"Historical dust output at {h['address']} ({h['sats']} sats, already spent)", - "details": { - "status": "spent", - "address": h["address"], - "sats": h["sats"], - "txid": h["txid"], - }, - "correction": ( - "This dust has already been spent, so the tracking link is already on-chain. " - "Going forward, reject unsolicited dust by enabling automatic dust rejection in your wallet, " - "or use wallet software that warns before spending dust-class UTXOs." - ), - }) - - -def detect_04_dust_spending(g: TxGraph): - """Detect transactions that spend dust alongside normal inputs.""" - section("4 · Dust Spent with Normal Inputs") - DUST_SATS = 1000 - found_any = False - - for txid in g.our_txids: - input_addrs = g.get_input_addresses(txid) - if not input_addrs or len(input_addrs) < 2: - continue - - dust_inputs = [] - normal_inputs = [] - for ia in input_addrs: - if not g.is_ours(ia["address"]): - continue - sats = int(round(ia["value"] * 1e8)) - if sats <= DUST_SATS: - dust_inputs.append(ia) - elif sats > 10000: # > 10k sats = clearly normal - normal_inputs.append(ia) - - if dust_inputs and normal_inputs: - found_any = True - finding({ - "type": "DUST_SPENDING", - "severity": "HIGH", - "description": f"TX {txid} spends {len(dust_inputs)} dust input(s) alongside {len(normal_inputs)} normal input(s)", - "details": { - "txid": txid, - "dust_inputs": [{"address": d["address"], "sats": int(round(d["value"] * 1e8))} for d in dust_inputs], - "normal_inputs": [{"address": n["address"], "amount_btc": round(n["value"], 8)} for n in normal_inputs], - }, - "correction": ( - "Freeze dust UTXOs in your wallet to prevent them from being automatically selected as inputs. " - "Never manually include a dust UTXO in a transaction that also spends normal UTXOs, " - "as this permanently links those addresses. " - "If the dust must be reclaimed, do so in isolation via a dedicated CoinJoin or by sweeping only " - "the dust in a separate, low-value transaction with no other inputs." - ), - }) - - if not found_any: - ok("No dust spending mixed with normal inputs detected.") - - -def detect_05_change_detection(g: TxGraph): - """Detect transactions where change output is easily distinguishable.""" - section("5 · Probable Change Output Detection") - found_any = False - - for txid in g.our_txids: - tx = g.fetch_tx(txid) - if not tx: - continue - outputs = g.get_output_addresses(txid) - input_addrs = g.get_input_addresses(txid) - if not outputs or len(outputs) < 2: - continue - - # We only care about sends (where at least 1 input is ours) - our_in = [ia for ia in input_addrs if g.is_ours(ia["address"])] - if not our_in: - continue - - # Identify which outputs are ours (change) vs external (payment) - our_outs = [o for o in outputs if g.is_ours(o["address"])] - ext_outs = [o for o in outputs if not g.is_ours(o["address"])] - - if not our_outs or not ext_outs: - continue # can't distinguish change if all outputs are ours or all external - - # Check change-detection heuristics - problems = [] - - for change in our_outs: - ch_sats = int(round(change["value"] * 1e8)) - ch_round = ch_sats % 100000 == 0 or ch_sats % 1000000 == 0 - - for payment in ext_outs: - pay_sats = int(round(payment["value"] * 1e8)) - pay_round = pay_sats % 100000 == 0 or pay_sats % 1000000 == 0 - - # Heuristic 1: payment is round, change is not - if pay_round and not ch_round: - problems.append(f"Round payment ({pay_sats} sats) vs non-round change ({ch_sats} sats)") - - # Heuristic 2: change has same script type as input - in_types = set(g.get_script_type(ia["address"]) for ia in our_in) - ch_type = g.get_script_type(change["address"]) - if ch_type in in_types and change["type"] != payment["type"]: - problems.append( - f"Change script type ({change['type']}) matches input type — different from payment ({payment['type']})" - ) - - # Heuristic 3: change address is internal (derivation /1/*) - ch_meta = g.addr_map.get(change["address"], {}) - if ch_meta.get("internal"): - problems.append("Change uses an internal (BIP-44 /1/*) derivation path — standard wallet change pattern") - - if problems: - found_any = True - finding({ - "type": "CHANGE_DETECTION", - "severity": "MEDIUM", - "description": f"TX {txid} has identifiable change output(s) ({len(problems)} heuristic(s) matched)", - "details": { - "txid": txid, - "reasons": problems[:6], - "change_outputs": [{"address": co["address"], "amount_btc": round(co["value"], 8)} for co in our_outs], - }, - "correction": ( - "Use PayJoin (BIP-78) so the receiver also contributes an input, breaking the payment/change heuristic. " - "Alternatively, select a UTXO that exactly covers the payment amount (no change output needed). " - "Ensure your change address uses the same script type as the payment address. " - "Avoid sending round amounts so the change amount is not the obvious 'leftover'." - ), - }) - - if not found_any: - ok("No easily identifiable change outputs detected.") - - -def detect_06_consolidation_origin(g: TxGraph): - """Detect UTXOs that originate from a prior consolidation transaction.""" - section("6 · UTXOs from Prior Consolidation") - CONSOLIDATION_THRESHOLD = 3 # ≥3 inputs with ≤2 outputs = consolidation - found_any = False - - for utxo in g.utxos: - if not g.is_ours(utxo.get("address", "")): - continue - parent = g.fetch_tx(utxo["txid"]) - if not parent: - continue - n_in = len(parent.get("vin", [])) - n_out = len(parent.get("vout", [])) - if n_in >= CONSOLIDATION_THRESHOLD and n_out <= 2: - found_any = True - # Check how many of the consolidation inputs were ours - parent_inputs = g.get_input_addresses(utxo["txid"]) - our_parent_in = [ia for ia in parent_inputs if g.is_ours(ia["address"])] - finding({ - "type": "CONSOLIDATION", - "severity": "MEDIUM", - "description": f"UTXO {utxo['txid']}:{utxo['vout']} ({utxo['amount']:.8f} BTC) born from a {n_in}-input consolidation", - "details": { - "txid": utxo["txid"], - "vout": utxo["vout"], - "amount_btc": round(utxo["amount"], 8), - "consolidation_inputs": n_in, - "consolidation_outputs": n_out, - "our_inputs_in_consolidation": len(our_parent_in), - }, - "correction": ( - "Avoid consolidating many UTXOs into one in a single transaction, as it permanently links all " - "those addresses under CIOH. If fee savings require consolidation, do it during a period of low " - "fees and through a CoinJoin (e.g., Whirlpool or JoinMarket) so the link between inputs is " - "indistinguishable from other participants. " - "Consider keeping UTXOs separate and using coin selection strategies that minimize on-chain footprint." - ), - }) - - if not found_any: - ok("No UTXOs from prior consolidation detected.") - - -def detect_07_script_type_mixing(g: TxGraph): - """Detect transactions mixing different script types in inputs.""" - section("7 · Script Type Mixing in Inputs") - found_any = False - - for txid in g.our_txids: - input_addrs = g.get_input_addresses(txid) - if len(input_addrs) < 2: - continue - - our_in = [ia for ia in input_addrs if g.is_ours(ia["address"])] - if len(our_in) < 2: - continue - - types = set() - for ia in input_addrs: - types.add(g.get_script_type(ia["address"])) - - types.discard("unknown") - if len(types) >= 2: - found_any = True - finding({ - "type": "SCRIPT_TYPE_MIXING", - "severity": "HIGH", - "description": f"TX {txid} mixes input script types: {sorted(types)}", - "details": { - "txid": txid, - "script_types": sorted(types), - "inputs": [ - {"address": ia["address"], "script_type": g.get_script_type(ia["address"]), "ours": g.is_ours(ia["address"])} - for ia in input_addrs - ], - }, - "correction": ( - "Migrate all funds to a single address type — preferably Taproot (P2TR / bc1p) which offers the " - "largest anonymity set going forward. " - "Never mix P2PKH, P2SH-P2WPKH, P2WPKH, and P2TR inputs in the same transaction; each type " - "combination is a rare fingerprint. " - "Sweep legacy-type UTXOs to a fresh Taproot wallet through a CoinJoin to avoid the cross-type link." - ), - }) - - if not found_any: - ok("No script type mixing detected.") - - -def detect_08_cluster_merge(g: TxGraph): - """Detect transactions that merge UTXOs from different funding sources (clusters).""" - section("8 · Cluster Merge (Cross-Origin Input Mixing)") - found_any = False - - for txid in g.our_txids: - input_addrs = g.get_input_addresses(txid) - if len(input_addrs) < 2: - continue - - our_in = [ia for ia in input_addrs if g.is_ours(ia["address"])] - if len(our_in) < 2: - continue - - # Trace each of our inputs one hop back to find their funding sources - funding_sources = {} # our_input_txid:vout -> set of grandparent source txids - for ia in our_in: - parent_tx = g.fetch_tx(ia["txid"]) - if not parent_tx: - continue - gp_sources = set() - for p_vin in parent_tx.get("vin", []): - if p_vin.get("coinbase"): - gp_sources.add("coinbase") - else: - gp_sources.add(p_vin["txid"][:16]) - funding_sources[f"{ia['txid'][:16]}:{ia['vout']}"] = gp_sources - - # Check if funding sources differ - all_sources = list(funding_sources.values()) - if len(all_sources) >= 2: - # Are the source sets disjoint? (different clusters) - merged_clusters = False - for i in range(len(all_sources)): - for j in range(i + 1, len(all_sources)): - if all_sources[i].isdisjoint(all_sources[j]): - merged_clusters = True - - if merged_clusters: - found_any = True - finding({ - "type": "CLUSTER_MERGE", - "severity": "HIGH", - "description": f"TX {txid} merges UTXOs from {len(funding_sources)} different funding chains", - "details": { - "txid": txid, - "funding_sources": {k: sorted(v) for k, v in funding_sources.items()}, - }, - "correction": ( - "Use coin control to spend UTXOs from only one funding source per transaction. " - "Keep UTXOs received from different counterparties in separate wallets or accounts " - "so they are never accidentally merged. " - "If you must merge UTXOs from different origins, pass them through a CoinJoin first " - "to break the chain-analysis link before combining them." - ), - }) - - if not found_any: - ok("No cross-origin cluster merges detected.") - - -def detect_09_lookback_depth(g: TxGraph): - """Detect UTXOs with significantly different ages (dormancy patterns).""" - section("9 · UTXO Age / Lookback Depth") - - if not g.utxos: - ok("No UTXOs to analyze.") - return - - our_utxos = [u for u in g.utxos if g.is_ours(u.get("address", ""))] - if not our_utxos: - ok("No UTXOs belonging to the descriptor.") - return - - # Get confirmation counts - aged = [] - for u in our_utxos: - confs = u.get("confirmations", 0) - aged.append({"utxo": u, "confirmations": confs}) - - if len(aged) < 2: - ok("Only one UTXO, no age comparison possible.") - return - - aged.sort(key=lambda x: x["confirmations"], reverse=True) - oldest = aged[0] - newest = aged[-1] - spread = oldest["confirmations"] - newest["confirmations"] - - if spread < 10: - ok(f"UTXO age spread is small ({spread} blocks). No dormancy pattern.") - return - - finding({ - "type": "UTXO_AGE_SPREAD", - "severity": "LOW", - "description": f"UTXO age spread of {spread} blocks between oldest and newest", - "details": { - "spread_blocks": spread, - "oldest": {"txid": oldest["utxo"]["txid"], "confirmations": oldest["confirmations"], "amount_btc": round(oldest["utxo"]["amount"], 8)}, - "newest": {"txid": newest["utxo"]["txid"], "confirmations": newest["confirmations"], "amount_btc": round(newest["utxo"]["amount"], 8)}, - }, - "correction": ( - "Prefer spending older UTXOs first (FIFO coin selection) to normalize the age distribution of your " - "UTXO set and avoid leaving very old coins as obvious dormancy markers. " - "Alternatively, route very old UTXOs through a CoinJoin to reset their history before spending. " - "Avoid holding large numbers of long-dormant coins in the same wallet as freshly received funds." - ), - }) - - OLD_THRESHOLD = 100 # blocks - old_utxos = [a for a in aged if a["confirmations"] >= OLD_THRESHOLD] - if old_utxos: - warn({ - "type": "DORMANT_UTXOS", - "severity": "LOW", - "description": f"{len(old_utxos)} UTXO(s) have ≥{OLD_THRESHOLD} confirmations (dormant/hoarded coins pattern)", - "details": { - "count": len(old_utxos), - "threshold_blocks": OLD_THRESHOLD, - }, - }) - - -def detect_10_exchange_origin(g: TxGraph, known_exchange_wallets=None): - """Detect UTXOs that likely originated from exchange batch withdrawals.""" - section("10 · Probable Exchange Origin") - - # Build set of known exchange txids if wallet names provided - exchange_txids = set() - if known_exchange_wallets: - for ew in known_exchange_wallets: - try: - etxs = cli("listtransactions", "*", 10000, 0, "true", wallet=ew) - for etx in (etxs or []): - if etx.get("txid"): - exchange_txids.add(etx["txid"]) - except Exception: - pass - - BATCH_THRESHOLD = 5 # ≥5 outputs = likely batch withdrawal - found_any = False - - for txid in g.our_txids: - tx = g.fetch_tx(txid) - if not tx: - continue - - n_out = len(tx.get("vout", [])) - if n_out < BATCH_THRESHOLD: - continue - - # Check: do we RECEIVE in this tx? (we're a recipient, not sender) - our_inputs = [ia for ia in g.get_input_addresses(txid) if g.is_ours(ia["address"])] - our_outputs = [o for o in g.get_output_addresses(txid) if g.is_ours(o["address"])] - - if our_inputs: - # We're a sender in a many-output TX — that's OUR batch, not exchange - continue - - if not our_outputs: - continue - - # Heuristics for exchange batch - signals = [] - - # 1. High output count - signals.append(f"High output count: {n_out}") - - # 2. Many unique addresses - unique_addrs = set() - for vout in tx["vout"]: - a = vout.get("scriptPubKey", {}).get("address", "") - if a: - unique_addrs.add(a) - if len(unique_addrs) >= BATCH_THRESHOLD: - signals.append(f"{len(unique_addrs)} unique recipient addresses") - - # 3. Known exchange wallet - if txid in exchange_txids: - signals.append("TX matches known exchange wallet history") - - # 4. Large input relative to individual outputs - input_addrs = g.get_input_addresses(txid) - input_total = sum(ia["value"] for ia in input_addrs) - output_vals = sorted(v.get("value", 0) for v in tx["vout"]) - if output_vals: - median_out = output_vals[len(output_vals) // 2] - if median_out > 0: - ratio = input_total / median_out - if ratio > 10: - signals.append(f"Input/median-output ratio: {ratio:.0f}x (hot wallet pattern)") - - if len(signals) >= 2: - found_any = True - finding({ - "type": "EXCHANGE_ORIGIN", - "severity": "MEDIUM", - "description": f"TX {txid} looks like an exchange batch withdrawal ({len(signals)} signal(s))", - "details": { - "txid": txid, - "signals": signals, - "received_outputs": [{"address": o["address"], "amount_btc": round(o["value"], 8)} for o in our_outputs], - }, - "correction": ( - "Withdraw via Lightning Network instead of on-chain to avoid the exchange-origin fingerprint entirely. " - "If an on-chain withdrawal is required, request it at a non-standard time or amount to reduce " - "correlation with a specific batch. " - "After withdrawal, pass the UTXO through a CoinJoin before using it for other payments, so the " - "exchange link is severed from your subsequent spending history." - ), - }) - - if not found_any: - ok("No exchange-origin batch patterns detected.") - - -def detect_11_tainted_utxos(g: TxGraph, known_risky_wallets=None): - """Detect UTXOs that have taint from known risky sources.""" - section("11 · Tainted UTXOs / Risky Source Exposure") - - if not known_risky_wallets: - info("No --known-risky-wallets provided. Skipping taint analysis.") - info("(Provide wallet names to enable: --known-risky-wallets risky)") - ok("Taint detection requires known-risky wallet metadata.") - return - - # Build set of risky TXIDs - risky_txids = set() - for rw in known_risky_wallets: - try: - rtxs = cli("listtransactions", "*", 10000, 0, "true", wallet=rw) - for rtx in (rtxs or []): - if rtx.get("txid"): - risky_txids.add(rtx["txid"]) - except Exception: - info(f"Could not read wallet '{rw}'") - - if not risky_txids: - info("No transactions found in risky wallets.") - return - - found_any = False - - for txid in g.our_txids: - input_addrs = g.get_input_addresses(txid) - our_in = [ia for ia in input_addrs if g.is_ours(ia["address"])] - if not our_in or len(input_addrs) < 2: - continue - - tainted = [] - clean = [] - for ia in input_addrs: - # An input is tainted if its funding TX is in a risky wallet's history - if ia["txid"] in risky_txids: - tainted.append(ia) - else: - clean.append(ia) - - if tainted and clean: - found_any = True - taint_pct = len(tainted) / len(input_addrs) * 100 - finding({ - "type": "TAINTED_UTXO_MERGE", - "severity": "HIGH", - "description": f"TX {txid} merges {len(tainted)} tainted + {len(clean)} clean inputs ({round(taint_pct)}% taint)", - "details": { - "txid": txid, - "tainted_inputs": [{"address": t["address"], "amount_btc": round(t["value"], 8), "source_txid": t["txid"]} for t in tainted], - "clean_inputs": [{"address": c["address"], "amount_btc": round(c["value"], 8)} for c in clean], - "taint_pct": round(taint_pct), - }, - "correction": ( - "Immediately freeze tainted UTXOs in your wallet to prevent them from being spent alongside clean funds. " - "Never merge inputs from known risky sources with unrelated UTXOs — this propagates the taint to all outputs. " - "Seek legal/compliance guidance on whether the tainted funds can be returned or must be reported. " - "If the funds are legitimately yours, process the tainted UTXO separately and consider disclosing " - "its origin to any counterparty that may receive it downstream." - ), - }) - - # Also check: did we receive directly from a risky source? - for txid in g.our_txids: - if txid in risky_txids: - our_outs = [o for o in g.get_output_addresses(txid) if g.is_ours(o["address"])] - if our_outs: - found_any = True - warn({ - "type": "DIRECT_TAINT", - "severity": "HIGH", - "description": f"TX {txid} is directly from a known risky source", - "details": { - "txid": txid, - "received_outputs": [{"address": o["address"], "amount_btc": round(o["value"], 8)} for o in our_outs], - }, - }) - - if not found_any: - ok("No tainted UTXO merges detected.") - - -def detect_12_behavioral_fingerprint(g: TxGraph): - """ - Analyze the descriptor's transaction set for patterns that make the user - identifiable through behavioral consistency. - - We evaluate OBJECTIVE, measurable features that chain analysis firms - actually use to cluster and fingerprint wallets. - """ - section("12 · Behavioral Fingerprint Analysis") - - # Collect send transactions (where we have inputs) - send_txids = [] - for txid in g.our_txids: - input_addrs = g.get_input_addresses(txid) - our_in = [ia for ia in input_addrs if g.is_ours(ia["address"])] - if our_in: - send_txids.append(txid) - - if len(send_txids) < 3: - ok(f"Only {len(send_txids)} send transactions — not enough data for fingerprinting.") - return - - # ── Feature extraction ── - output_counts = [] - payment_amounts_sats = [] - change_amounts_sats = [] - input_script_types = [] - output_script_types = [] - rbf_signals = [] - locktime_values = [] - fee_rates = [] # sat/vB - n_inputs_list = [] - uses_round_amounts = 0 - total_payments = 0 - change_address_types_used = set() - payment_address_types_used = set() - version_numbers = set() - - for txid in send_txids: - tx = g.fetch_tx(txid) - if not tx: - continue - - n_in = len(tx.get("vin", [])) - n_out = len(tx.get("vout", [])) - n_inputs_list.append(n_in) - output_counts.append(n_out) - - # Version - version_numbers.add(tx.get("version", 2)) - - # Locktime - locktime_values.append(tx.get("locktime", 0)) - - # RBF signalling - for vin in tx.get("vin", []): - seq = vin.get("sequence", 0xffffffff) - rbf_signals.append(seq < 0xfffffffe) - - # Input script types - for ia in g.get_input_addresses(txid): - if g.is_ours(ia["address"]): - input_script_types.append(g.get_script_type(ia["address"])) - - # Output analysis - outputs = g.get_output_addresses(txid) - for out in outputs: - sats = int(round(out["value"] * 1e8)) - if g.is_ours(out["address"]): - # Change output - change_amounts_sats.append(sats) - change_address_types_used.add(out["type"]) - else: - # Payment output - payment_amounts_sats.append(sats) - output_script_types.append(out["type"]) - payment_address_types_used.add(out["type"]) - total_payments += 1 - if sats > 0 and (sats % 100000 == 0 or sats % 1000000 == 0): - uses_round_amounts += 1 - - # Fee rate - if "vsize" in tx and tx["vsize"] > 0: - # Compute fee from inputs - outputs - in_total = sum(ia["value"] for ia in g.get_input_addresses(txid)) - out_total = sum(v.get("value", 0) for v in tx["vout"]) - fee_sats = int(round((in_total - out_total) * 1e8)) - if fee_sats > 0: - fee_rates.append(fee_sats / tx["vsize"]) - - # ── Analysis ── - problems = [] - - # 1. Round amount usage pattern - if total_payments > 0: - round_pct = uses_round_amounts / total_payments * 100 - if round_pct > 60: - problems.append( - f"Round payment amounts: {round_pct:.0f}% of payments are round numbers. " - "This is a distinctive behavioral pattern that aids clustering." - ) - - # 2. Consistent output count (always 2 outputs = simple spend pattern) - if output_counts: - avg_outs = sum(output_counts) / len(output_counts) - if all(c == output_counts[0] for c in output_counts) and len(output_counts) >= 3: - problems.append( - f"Uniform output count: all {len(output_counts)} send TXs have exactly " - f"{output_counts[0]} outputs. Consistent structure aids fingerprinting." - ) - - # 3. Script type consistency or mixing - input_types_set = set(input_script_types) - if len(input_types_set) > 1: - problems.append( - f"Mixed input script types used across TXs: {input_types_set}. " - "Mixing address families is rare and highly identifying." - ) - elif len(input_types_set) == 1 and input_script_types: - t = input_types_set.pop() - if t == "p2pkh": - problems.append( - f"All inputs use legacy P2PKH — a very uncommon script type today. " - "This alone narrows your anonymity set significantly." - ) - - # 4. RBF signaling consistency - if rbf_signals: - rbf_pct = sum(rbf_signals) / len(rbf_signals) * 100 - if rbf_pct == 100: - problems.append( - f"RBF always enabled: 100% of inputs signal replace-by-fee. " - "While increasingly common, it's a distinguishing feature vs non-RBF wallets." - ) - elif rbf_pct == 0: - problems.append( - "RBF never enabled: 0% of inputs signal replace-by-fee. " - "This is uncommon in modern wallets and distinguishes your software." - ) - - # 5. Locktime pattern - if locktime_values: - nonzero_lt = [lt for lt in locktime_values if lt > 0] - if len(nonzero_lt) == len(locktime_values) and len(locktime_values) >= 3: - problems.append( - "Anti-fee-sniping locktime always set — consistent with Bitcoin Core / Electrum. " - "Absence or presence of this reveals your wallet software." - ) - elif not nonzero_lt and len(locktime_values) >= 3: - problems.append( - "Locktime always 0 — no anti-fee-sniping. " - "This distinguishes your wallet from Bitcoin Core / Electrum defaults." - ) - - # 6. Fee rate consistency - if len(fee_rates) >= 3: - avg_fee = sum(fee_rates) / len(fee_rates) - if avg_fee > 0: - variance = sum((f - avg_fee) ** 2 for f in fee_rates) / len(fee_rates) - stddev = variance ** 0.5 - cv = stddev / avg_fee # coefficient of variation - if cv < 0.15: - problems.append( - f"Very consistent fee rate: avg {avg_fee:.1f} sat/vB ± {stddev:.1f} " - f"(CV={cv:.2f}). Low variance suggests fixed-fee-rate wallet configuration." - ) - - # 7. Change address type pattern - if change_address_types_used and payment_address_types_used: - if change_address_types_used != payment_address_types_used: - # This leaks which outputs are change - problems.append( - f"Change uses different script type ({change_address_types_used}) " - f"than payments ({payment_address_types_used}) — trivially identifies change outputs." - ) - - # 8. Input count pattern (always 1 input = no consolidation; always many = distinctive) - if n_inputs_list and len(n_inputs_list) >= 3: - if all(n == 1 for n in n_inputs_list): - pass # normal, not distinctive - elif all(n == n_inputs_list[0] for n in n_inputs_list) and n_inputs_list[0] > 1: - problems.append( - f"Always uses exactly {n_inputs_list[0]} inputs per TX — unusual and identifying." - ) - - # ── Report ── - if not problems: - ok(f"Analyzed {len(send_txids)} transactions. No strong behavioral fingerprints detected.") - return - - finding({ - "type": "BEHAVIORAL_FINGERPRINT", - "severity": "MEDIUM", - "description": f"Behavioral fingerprint detected across {len(send_txids)} send transactions ({len(problems)} pattern(s))", - "details": { - "send_tx_count": len(send_txids), - "patterns": problems, - }, - "correction": ( - "Switch to wallet software that applies anti-fingerprinting defaults: anti-fee-sniping locktime, " - "randomized fee rates (not fixed sat/vB), and RBF enabled by default. " - "Avoid sending only round amounts — add small random satoshi offsets to payment values. " - "Standardize on a single modern script type (Taproot) so your input-type set is not distinctive. " - "Use batched payments sparingly and vary the number of outputs per transaction to prevent " - "structural fingerprinting from consistent output counts." - ), - }) - - -# ═══════════════════════════════════════════════════════════════════════════════ -# 4. MAIN -# ═══════════════════════════════════════════════════════════════════════════════ - -def main(): - parser = argparse.ArgumentParser( - description="Detect Bitcoin privacy vulnerabilities from output descriptors.", - epilog="Examples:\n" - " python3 detect.py --wallet alice\n" - ' python3 detect.py --wallet alice --known-risky-wallets risky\n' - ' python3 detect.py "wpkh(tpub.../0/*)#chk" "wpkh(tpub.../1/*)#chk"\n', - formatter_class=argparse.RawDescriptionHelpFormatter, - ) - parser.add_argument("descriptors", nargs="*", help="Output descriptors to scan") - parser.add_argument("--wallet", "-w", help="Read descriptors from an existing wallet") - parser.add_argument("--known-risky-wallets", nargs="*", default=None, - help="Wallet names whose TXIDs are considered tainted") - parser.add_argument("--known-exchange-wallets", nargs="*", default=None, - help="Wallet names whose TXIDs are considered exchange-origin") - parser.add_argument("--keep-scan-wallet", action="store_true", - help="Don't delete the temporary scan wallet after running") - args = parser.parse_args() - - if not args.wallet and not args.descriptors: - parser.error("Provide either --wallet or one or more descriptors.") - - # ── Step 1: Resolve descriptors ── - section("Setup: Resolving Descriptors") - descriptors = resolve_descriptors(args) - info(f"Found {len(descriptors)} descriptors") - for d in descriptors: - dtype = d["desc"].split("(")[0] - role = "internal/change" if d["internal"] else "external/receive" - info(f" {dtype:15} {role:20} range [0..{d['range_end']}]") - - # ── Step 2: Derive all addresses ── - section("Setup: Deriving Addresses") - addr_map = derive_all_addresses(descriptors) - info(f"Derived {len(addr_map)} addresses across all descriptor types") - - # Count by type - type_counts = defaultdict(int) - for meta in addr_map.values(): - type_counts[meta["type"]] += 1 - for t, c in sorted(type_counts.items()): - info(f" {t}: {c} addresses") - - # ── Step 3: Build watch-only wallet ── - section("Setup: Building Scan Wallet") - scan_wallet = "_detect_scan" - if args.wallet: - # If they gave us a wallet, just use it directly — faster, no rescan needed - scan_wallet = args.wallet - info(f"Using existing wallet '{scan_wallet}' directly (no rescan needed)") - else: - scan_wallet = build_scan_wallet(descriptors) - info(f"Created temporary watch-only wallet '{scan_wallet}' with full rescan") - - # ── Step 4: Gather transaction history ── - section("Setup: Loading Transaction History") - wallet_txs = get_all_transactions(scan_wallet) - utxos = get_all_utxos(scan_wallet) - info(f"Transaction history: {len(wallet_txs)} entries") - info(f"Current UTXOs: {len(utxos)}") - - if not wallet_txs: - print(json.dumps({"error": "No transactions found for these descriptors."})) - return - - # ── Step 5: Build transaction graph ── - g = TxGraph(addr_map, wallet_txs, utxos) - info(f"Unique transaction IDs: {len(g.our_txids)}") - - # ── Step 6: Run all detectors ── - detect_01_address_reuse(g) - detect_02_cioh(g) - detect_03_dust(g) - detect_04_dust_spending(g) - detect_05_change_detection(g) - detect_06_consolidation_origin(g) - detect_07_script_type_mixing(g) - detect_08_cluster_merge(g) - detect_09_lookback_depth(g) - detect_10_exchange_origin(g, args.known_exchange_wallets) - detect_11_tainted_utxos(g, args.known_risky_wallets) - detect_12_behavioral_fingerprint(g) - - # ── JSON output ── - report = { - "stats": { - "transactions_analyzed": len(g.our_txids), - "addresses_derived": len(addr_map), - }, - "findings": FINDINGS, - "warnings": WARNINGS, - "summary": { - "findings": len(FINDINGS), - "warnings": len(WARNINGS), - "clean": len(FINDINGS) == 0 and len(WARNINGS) == 0, - }, - } - print(json.dumps(report, indent=2)) - - # Cleanup - if not args.wallet and not args.keep_scan_wallet: - try: - cli("unloadwallet", "_detect_scan") - except Exception: - pass - - -if __name__ == "__main__": - main() diff --git a/backend/script/miner b/backend/script/miner deleted file mode 100755 index f46d88b..0000000 --- a/backend/script/miner +++ /dev/null @@ -1,604 +0,0 @@ -#!/usr/bin/env python3 -# Copyright (c) 2020-present The Bitcoin Core developers -# Distributed under the MIT software license, see the accompanying -# file COPYING or http://www.opensource.org/licenses/mit-license.php. - -import argparse -import json -import logging -import math -import os -import re -import shlex -import sys -import time -import subprocess - -PATH_BASE_CONTRIB_SIGNET = os.path.abspath(os.path.dirname(os.path.realpath(__file__))) -PATH_BASE_TEST_FUNCTIONAL = os.path.abspath(os.path.join(PATH_BASE_CONTRIB_SIGNET, "..", "..", "test", "functional")) -sys.path.insert(0, PATH_BASE_TEST_FUNCTIONAL) - -from test_framework.blocktools import get_witness_script, script_BIP34_coinbase_height, SIGNET_HEADER # noqa: E402 -from test_framework.messages import CBlock, CBlockHeader, COutPoint, CTransaction, CTxIn, CTxInWitness, CTxOut, from_binary, from_hex, ser_string, ser_uint256, tx_from_hex, MAX_SEQUENCE_NONFINAL # noqa: E402 -from test_framework.psbt import PSBT, PSBTMap, PSBT_GLOBAL_UNSIGNED_TX, PSBT_IN_FINAL_SCRIPTSIG, PSBT_IN_FINAL_SCRIPTWITNESS, PSBT_IN_NON_WITNESS_UTXO, PSBT_IN_SIGHASH_TYPE # noqa: E402 -from test_framework.script import CScript, CScriptOp # noqa: E402 - -logging.basicConfig( - format='%(asctime)s %(levelname)s %(message)s', - level=logging.INFO, - datefmt='%Y-%m-%d %H:%M:%S') - -PSBT_SIGNET_BLOCK = b"\xfc\x06signetb" # proprietary PSBT global field holding the block being signed -RE_MULTIMINER = re.compile(r"^(\d+)(-(\d+))?/(\d+)$") - -def signet_txs(block, challenge): - # assumes signet solution has not been added yet so does not need - # to be removed - - txs = block.vtx[:] - txs[0] = CTransaction(txs[0]) - txs[0].vout[-1].scriptPubKey += CScriptOp.encode_op_pushdata(SIGNET_HEADER) - hashes = [] - for tx in txs: - hashes.append(ser_uint256(tx.txid_int)) - mroot = block.get_merkle_root(hashes) - - sd = b"" - sd += block.nVersion.to_bytes(4, "little", signed=True) - sd += ser_uint256(block.hashPrevBlock) - sd += ser_uint256(mroot) - sd += block.nTime.to_bytes(4, "little") - - to_spend = CTransaction() - to_spend.version = 0 - to_spend.nLockTime = 0 - to_spend.vin = [CTxIn(COutPoint(0, 0xFFFFFFFF), b"\x00" + CScriptOp.encode_op_pushdata(sd), 0)] - to_spend.vout = [CTxOut(0, challenge)] - - spend = CTransaction() - spend.version = 0 - spend.nLockTime = 0 - spend.vin = [CTxIn(COutPoint(to_spend.txid_int, 0), b"", 0)] - spend.vout = [CTxOut(0, b"\x6a")] - - return spend, to_spend - -def decode_challenge_psbt(b64psbt): - psbt = PSBT.from_base64(b64psbt) - - assert len(psbt.tx.vin) == 1 - assert len(psbt.tx.vout) == 1 - assert PSBT_SIGNET_BLOCK in psbt.g.map - return psbt - -def get_block_from_psbt(psbt): - return from_binary(CBlock, psbt.g.map[PSBT_SIGNET_BLOCK]) - -def get_solution_from_psbt(psbt, emptyok=False): - scriptSig = psbt.i[0].map.get(PSBT_IN_FINAL_SCRIPTSIG, b"") - scriptWitness = psbt.i[0].map.get(PSBT_IN_FINAL_SCRIPTWITNESS, b"\x00") - if emptyok and len(scriptSig) == 0 and scriptWitness == b"\x00": - return None - return ser_string(scriptSig) + scriptWitness - -def finish_block(block, signet_solution, grind_cmd): - if signet_solution is None: - pass # Don't need to add a signet commitment if there's no signet signature needed - else: - block.vtx[0].vout[-1].scriptPubKey += CScriptOp.encode_op_pushdata(SIGNET_HEADER + signet_solution) - block.hashMerkleRoot = block.calc_merkle_root() - if grind_cmd is None: - block.solve() - else: - headhex = CBlockHeader.serialize(block).hex() - cmd = shlex.split(grind_cmd) + [headhex] - newheadhex = subprocess.run(cmd, stdout=subprocess.PIPE, input=b"", check=True).stdout.strip() - newhead = from_hex(CBlockHeader(), newheadhex.decode('utf8')) - block.nNonce = newhead.nNonce - return block - -def new_block(tmpl, reward_spk, *, blocktime=None, poolid=None): - scriptSig = script_BIP34_coinbase_height(tmpl["height"]) - if poolid is not None: - scriptSig = CScript(b"" + scriptSig + CScriptOp.encode_op_pushdata(poolid)) - - cbtx = CTransaction() - cbtx.nLockTime = tmpl["height"] - 1 - cbtx.vin = [CTxIn(COutPoint(0, 0xffffffff), scriptSig, MAX_SEQUENCE_NONFINAL)] - cbtx.vout = [CTxOut(tmpl["coinbasevalue"], reward_spk)] - cbtx.vin[0].nSequence = 2**32-2 - - block = CBlock() - block.nVersion = tmpl["version"] - block.hashPrevBlock = int(tmpl["previousblockhash"], 16) - block.nTime = tmpl["curtime"] if blocktime is None else blocktime - if block.nTime < tmpl["mintime"]: - block.nTime = tmpl["mintime"] - block.nBits = int(tmpl["bits"], 16) - block.nNonce = 0 - block.vtx = [cbtx] + [tx_from_hex(t["data"]) for t in tmpl["transactions"]] - - witnonce = 0 - witroot = block.calc_witness_merkle_root() - cbwit = CTxInWitness() - cbwit.scriptWitness.stack = [ser_uint256(witnonce)] - block.vtx[0].wit.vtxinwit = [cbwit] - block.vtx[0].vout.append(CTxOut(0, bytes(get_witness_script(witroot, witnonce)))) - - block.hashMerkleRoot = block.calc_merkle_root() - - return block - -def generate_psbt(block, signet_spk): - signet_spk_bin = bytes.fromhex(signet_spk) - signme, spendme = signet_txs(block, signet_spk_bin) - psbt = PSBT() - psbt.g = PSBTMap( {PSBT_GLOBAL_UNSIGNED_TX: signme.serialize(), - PSBT_SIGNET_BLOCK: block.serialize() - } ) - psbt.i = [ PSBTMap( {PSBT_IN_NON_WITNESS_UTXO: spendme.serialize(), - PSBT_IN_SIGHASH_TYPE: bytes([1,0,0,0])}) - ] - psbt.o = [ PSBTMap() ] - return psbt.to_base64() - -def get_poolid(args): - if args.poolid is not None: - return args.poolid.encode('utf8') - elif args.poolnum is not None: - return b"/signet:%d/" % (args.poolnum) - else: - return None - -def get_reward_addr_spk(args, height): - assert args.address is not None or args.descriptor is not None - - if hasattr(args, "reward_spk"): - return args.address, args.reward_spk - - if args.address is not None: - reward_addr = args.address - elif '*' not in args.descriptor: - reward_addr = args.address = json.loads(args.bcli("deriveaddresses", args.descriptor))[0] - else: - remove = [k for k in args.derived_addresses.keys() if k+20 <= height] - for k in remove: - del args.derived_addresses[k] - if height not in args.derived_addresses: - addrs = json.loads(args.bcli("deriveaddresses", args.descriptor, "[%d,%d]" % (height, height+20))) - for k, a in enumerate(addrs): - args.derived_addresses[height+k] = a - reward_addr = args.derived_addresses[height] - - reward_spk = bytes.fromhex(json.loads(args.bcli("getaddressinfo", reward_addr))["scriptPubKey"]) - if args.address is not None: - # will always be the same, so cache - args.reward_spk = reward_spk - - return reward_addr, reward_spk - -def do_genpsbt(args): - poolid = get_poolid(args) - tmpl = json.load(sys.stdin) - signet_spk = tmpl["signet_challenge"] - _, reward_spk = get_reward_addr_spk(args, tmpl["height"]) - block = new_block(tmpl, reward_spk, poolid=poolid) - psbt = generate_psbt(block, signet_spk) - print(psbt) - -def do_solvepsbt(args): - psbt = decode_challenge_psbt(sys.stdin.read()) - block = get_block_from_psbt(psbt) - signet_solution = get_solution_from_psbt(psbt, emptyok=True) - block = finish_block(block, signet_solution, args.grind_cmd) - print(block.serialize().hex()) - -def nbits_to_target(nbits): - shift = (nbits >> 24) & 0xff - return (nbits & 0x00ffffff) * 2**(8*(shift - 3)) - -def target_to_nbits(target): - tstr = "{0:x}".format(target) - if len(tstr) < 6: - tstr = ("000000"+tstr)[-6:] - if len(tstr) % 2 != 0: - tstr = "0" + tstr - if int(tstr[0],16) >= 0x8: - # avoid "negative" - tstr = "00" + tstr - fix = int(tstr[:6], 16) - sz = len(tstr)//2 - if tstr[6:] != "0"*(sz*2-6): - fix += 1 - - return int("%02x%06x" % (sz,fix), 16) - -def seconds_to_hms(s): - if s == 0: - return "0s" - neg = (s < 0) - if neg: - s = -s - out = "" - if s % 60 > 0: - out = "%ds" % (s % 60) - s //= 60 - if s % 60 > 0: - out = "%dm%s" % (s % 60, out) - s //= 60 - if s > 0: - out = "%dh%s" % (s, out) - if neg: - out = "-" + out - return out - -def trivial_challenge(spkhex): - """ - BIP325 allows omitting the signet commitment when scriptSig and - scriptWitness are both empty. This is the case for trivial - challenges such as OP_TRUE or a single data push. - """ - spk = bytes.fromhex(spkhex) - if len(spk) == 1 and 0x51 <= spk[0] <= 0x60: - # OP_TRUE/OP_1...OP_16 - return True - elif 2 <= len(spk) <= 76 and spk[0] + 1 == len(spk): - # Single fixed push of 1-75 bytes - return True - return False - -class Generate: - INTERVAL = 600.0*2016/2015 # 10 minutes, adjusted for the off-by-one bug - - - def __init__(self, multiminer=None, ultimate_target=None, poisson=False, max_interval=1800, - standby_delay=0, backup_delay=0, set_block_time=None, - poolid=None): - if multiminer is None: - multiminer = (0, 1, 1) - (self.multi_low, self.multi_high, self.multi_period) = multiminer - self.ultimate_target = ultimate_target - self.poisson = poisson - self.max_interval = max_interval - self.standby_delay = standby_delay - self.backup_delay = backup_delay - self.set_block_time = set_block_time - self.poolid = poolid - - def next_block_delta(self, last_nbits, last_hash): - # strategy: - # 1) work out how far off our desired target we are - # 2) cap it to a factor of 4 since that's the best we can do in a single retarget period - # 3) use that to work out the desired average interval in this retarget period - # 4) if doing poisson, use the last hash to pick a uniformly random number in [0,1), and work out a random multiplier to vary the average by - # 5) cap the resulting interval between 1 second and 1 hour to avoid extremes - - current_target = nbits_to_target(last_nbits) - retarget_factor = self.ultimate_target / current_target - retarget_factor = max(0.25, min(retarget_factor, 4.0)) - - avg_interval = self.INTERVAL * retarget_factor - - if self.poisson: - det_rand = int(last_hash[-8:], 16) * 2**-32 - this_interval_variance = -math.log1p(-det_rand) - else: - this_interval_variance = 1 - - this_interval = avg_interval * this_interval_variance - this_interval = max(1, min(this_interval, self.max_interval)) - - return this_interval - - def next_block_is_mine(self, last_hash): - det_rand = int(last_hash[-16:-8], 16) - return self.multi_low <= (det_rand % self.multi_period) < self.multi_high - - def next_block_time(self, now, bestheader, is_first_block): - if self.set_block_time is not None: - logging.debug("Setting start time to %d", self.set_block_time) - self.mine_time = self.set_block_time - self.action_time = now - self.is_mine = True - elif bestheader["height"] == 0: - time_delta = self.INTERVAL * 100 # plenty of time to mine 100 blocks - logging.info("Backdating time for first block to %d minutes ago" % (time_delta/60)) - self.mine_time = now - time_delta - self.action_time = now - self.is_mine = True - else: - time_delta = self.next_block_delta(int(bestheader["bits"], 16), bestheader["hash"]) - self.mine_time = bestheader["time"] + time_delta - - self.is_mine = self.next_block_is_mine(bestheader["hash"]) - - self.action_time = self.mine_time - if not self.is_mine: - self.action_time += self.backup_delay - - if self.standby_delay > 0: - self.action_time += self.standby_delay - elif is_first_block: - # for non-standby, always mine immediately on startup, - # even if the next block shouldn't be ours - self.action_time = now - - # don't want fractional times so round down - self.mine_time = int(self.mine_time) - self.action_time = int(self.action_time) - - # can't mine a block 2h in the future; 1h55m for some safety - self.action_time = max(self.action_time, self.mine_time - 6900) - - def gbt(self, bcli, bestblockhash, now): - tmpl = json.loads(bcli("getblocktemplate", '{"rules":["signet","segwit"]}')) - if tmpl["previousblockhash"] != bestblockhash: - logging.warning("GBT based off unexpected block (%s not %s), retrying", tmpl["previousblockhash"], bci["bestblockhash"]) - time.sleep(1) - return None - - if tmpl["mintime"] > self.mine_time: - logging.info("Updating block time from %d to %d", self.mine_time, tmpl["mintime"]) - self.mine_time = tmpl["mintime"] - if self.mine_time > now: - logging.error("GBT mintime is in the future: %d is %d seconds later than %d", self.mine_time, (self.mine_time-now), now) - return None - - return tmpl - - def mine(self, bcli, grind_cmd, tmpl, reward_spk): - block = new_block(tmpl, reward_spk, blocktime=self.mine_time, poolid=self.poolid) - - signet_spk = tmpl["signet_challenge"] - if trivial_challenge(signet_spk): - signet_solution = None - else: - psbt = generate_psbt(block, signet_spk) - input_stream = os.linesep.join([psbt, "true", "ALL"]).encode('utf8') - psbt_signed = json.loads(bcli("-stdin", "walletprocesspsbt", input=input_stream)) - if not psbt_signed.get("complete",False): - logging.debug("Generated PSBT: %s" % (psbt,)) - sys.stderr.write("PSBT signing failed\n") - return None - psbt = decode_challenge_psbt(psbt_signed["psbt"]) - signet_solution = get_solution_from_psbt(psbt) - - return finish_block(block, signet_solution, grind_cmd) - -def do_generate(args): - if args.set_block_time is not None: - max_blocks = 1 - elif args.max_blocks is not None: - if args.max_blocks < 1: - logging.error("--max_blocks must specify a positive integer") - return 1 - max_blocks = args.max_blocks - elif args.ongoing: - max_blocks = None - else: - max_blocks = 1 - - if args.set_block_time is not None and args.set_block_time < 0: - args.set_block_time = time.time() - logging.info("Treating negative block time as current time (%d)" % (args.set_block_time)) - - if args.min_nbits: - args.nbits = "1e0377ae" - logging.info("Using nbits=%s" % (args.nbits)) - - if args.set_block_time is None: - if args.nbits is None or len(args.nbits) != 8: - logging.error("Must specify --nbits (use calibrate command to determine value)") - return 1 - - if args.multiminer is None: - my_blocks = (0,1,1) - else: - if not args.ongoing: - logging.error("Cannot specify --multiminer without --ongoing") - return 1 - m = RE_MULTIMINER.match(args.multiminer) - if m is None: - logging.error("--multiminer argument must be k/m or j-k/m") - return 1 - start,_,stop,total = m.groups() - if stop is None: - stop = start - start, stop, total = map(int, (start, stop, total)) - if stop < start or start <= 0 or total < stop or total == 0: - logging.error("Inconsistent values for --multiminer") - return 1 - my_blocks = (start-1, stop, total) - - if args.max_interval < 960: - logging.error("--max-interval must be at least 960 (16 minutes)") - return 1 - - poolid = get_poolid(args) - - ultimate_target = nbits_to_target(int(args.nbits,16)) - - gen = Generate(multiminer=my_blocks, ultimate_target=ultimate_target, poisson=args.poisson, max_interval=args.max_interval, - standby_delay=args.standby_delay, backup_delay=args.backup_delay, set_block_time=args.set_block_time, poolid=poolid) - - mined_blocks = 0 - bestheader = {"hash": None} - lastheader = None - while max_blocks is None or mined_blocks < max_blocks: - - # current status? - bci = json.loads(args.bcli("getblockchaininfo")) - - if bestheader["hash"] != bci["bestblockhash"]: - bestheader = json.loads(args.bcli("getblockheader", bci["bestblockhash"])) - - if lastheader is None: - lastheader = bestheader["hash"] - elif bestheader["hash"] != lastheader: - next_delta = gen.next_block_delta(int(bestheader["bits"], 16), bestheader["hash"]) - next_delta += bestheader["time"] - time.time() - next_is_mine = gen.next_block_is_mine(bestheader["hash"]) - logging.info("Received new block at height %d; next in %s (%s)", bestheader["height"], seconds_to_hms(next_delta), ("mine" if next_is_mine else "backup")) - lastheader = bestheader["hash"] - - # when is the next block due to be mined? - now = time.time() - gen.next_block_time(now, bestheader, (mined_blocks == 0)) - - # ready to go? otherwise sleep and check for new block - if now < gen.action_time: - sleep_for = min(gen.action_time - now, 60) - if gen.mine_time < now: - # someone else might have mined the block, - # so check frequently, so we don't end up late - # mining the next block if it's ours - sleep_for = min(20, sleep_for) - minestr = "mine" if gen.is_mine else "backup" - logging.debug("Sleeping for %s, next block due in %s (%s)" % (seconds_to_hms(sleep_for), seconds_to_hms(gen.mine_time - now), minestr)) - time.sleep(sleep_for) - continue - - # gbt - tmpl = gen.gbt(args.bcli, bci["bestblockhash"], now) - if tmpl is None: - continue - - logging.debug("GBT template: %s", tmpl) - - # address for reward - reward_addr, reward_spk = get_reward_addr_spk(args, tmpl["height"]) - - # mine block - logging.debug("Mining block delta=%s start=%s mine=%s", seconds_to_hms(gen.mine_time-bestheader["time"]), gen.mine_time, gen.is_mine) - mined_blocks += 1 - block = gen.mine(args.bcli, args.grind_cmd, tmpl, reward_spk) - if block is None: - return 1 - - # submit block - r = args.bcli("-stdin", "submitblock", input=block.serialize().hex().encode('utf8')) - - # report - bstr = "block" if gen.is_mine else "backup block" - - next_delta = gen.next_block_delta(block.nBits, block.hash_hex) - next_delta += block.nTime - time.time() - next_is_mine = gen.next_block_is_mine(block.hash_hex) - - logging.debug("Block hash %s payout to %s", block.hash_hex, reward_addr) - logging.info("Mined %s at height %d; next in %s (%s)", bstr, tmpl["height"], seconds_to_hms(next_delta), ("mine" if next_is_mine else "backup")) - if r != "": - logging.warning("submitblock returned %s for height %d hash %s", r, tmpl["height"], block.hash_hex) - lastheader = block.hash_hex - -def do_calibrate(args): - if args.nbits is not None and args.seconds is not None: - sys.stderr.write("Can only specify one of --nbits or --seconds\n") - return 1 - if args.nbits is not None and len(args.nbits) != 8: - sys.stderr.write("Must specify 8 hex digits for --nbits\n") - return 1 - - TRIALS = 600 # gets variance down pretty low - TRIAL_BITS = 0x1e3ea75f # takes about 5m to do 600 trials - - header = CBlockHeader() - header.nBits = TRIAL_BITS - targ = nbits_to_target(header.nBits) - - start = time.time() - count = 0 - for i in range(TRIALS): - header.nTime = i - header.nNonce = 0 - headhex = header.serialize().hex() - cmd = shlex.split(args.grind_cmd) + [headhex] - newheadhex = subprocess.run(cmd, stdout=subprocess.PIPE, input=b"", check=True).stdout.strip() - - avg = (time.time() - start) * 1.0 / TRIALS - - if args.nbits is not None: - want_targ = nbits_to_target(int(args.nbits,16)) - want_time = avg*targ/want_targ - else: - want_time = args.seconds if args.seconds is not None else 25 - want_targ = int(targ*(avg/want_time)) - - print("nbits=%08x for %ds average mining time" % (target_to_nbits(want_targ), want_time)) - return 0 - -def bitcoin_cli(basecmd, args, **kwargs): - cmd = basecmd + ["-signet"] + args - logging.debug("Calling bitcoin-cli: %r", cmd) - out = subprocess.run(cmd, stdout=subprocess.PIPE, **kwargs, check=True).stdout - if isinstance(out, bytes): - out = out.decode('utf8') - return out.strip() - -def main(): - parser = argparse.ArgumentParser() - parser.add_argument("--cli", default="bitcoin-cli", type=str, help="bitcoin-cli command") - parser.add_argument("--debug", action="store_true", help="Print debugging info") - parser.add_argument("--quiet", action="store_true", help="Only print warnings/errors") - - cmds = parser.add_subparsers(help="sub-commands") - genpsbt = cmds.add_parser("genpsbt", help="Generate a block PSBT for signing") - genpsbt.set_defaults(fn=do_genpsbt) - - solvepsbt = cmds.add_parser("solvepsbt", help="Solve a signed block PSBT") - solvepsbt.set_defaults(fn=do_solvepsbt) - - generate = cmds.add_parser("generate", help="Mine blocks") - generate.set_defaults(fn=do_generate) - howmany = generate.add_mutually_exclusive_group() - howmany.add_argument("--ongoing", action="store_true", help="Keep mining blocks") - howmany.add_argument("--max-blocks", default=None, type=int, help="Max blocks to mine (default=1)") - howmany.add_argument("--set-block-time", default=None, type=int, help="Set block time (unix timestamp); implies --max-blocks=1") - nbit_target = generate.add_mutually_exclusive_group() - nbit_target.add_argument("--nbits", default=None, type=str, help="Target nBits (specify difficulty)") - nbit_target.add_argument("--min-nbits", action="store_true", help="Target minimum nBits (use min difficulty)") - generate.add_argument("--poisson", action="store_true", help="Simulate randomised block times") - generate.add_argument("--multiminer", default=None, type=str, help="Specify which set of blocks to mine (eg: 1-40/100 for the first 40%%, 2/3 for the second 3rd)") - generate.add_argument("--backup-delay", default=300, type=int, help="Seconds to delay before mining blocks reserved for other miners (default=300)") - generate.add_argument("--standby-delay", default=0, type=int, help="Seconds to delay before mining blocks (default=0)") - generate.add_argument("--max-interval", default=1800, type=int, help="Maximum interblock interval (seconds)") - - calibrate = cmds.add_parser("calibrate", help="Calibrate difficulty") - calibrate.set_defaults(fn=do_calibrate) - calibrate_by = calibrate.add_mutually_exclusive_group() - calibrate_by.add_argument("--nbits", type=str, default=None) - calibrate_by.add_argument("--seconds", type=int, default=None) - - for sp in [genpsbt, generate]: - payto = sp.add_mutually_exclusive_group(required=True) - payto.add_argument("--address", default=None, type=str, help="Address for block reward payment") - payto.add_argument("--descriptor", default=None, type=str, help="Descriptor for block reward payment") - pool = sp.add_mutually_exclusive_group() - pool.add_argument("--poolnum", default=None, type=int, help="Identify blocks that you mine") - pool.add_argument("--poolid", default=None, type=str, help="Identify blocks that you mine (eg: /signet:1/)") - - for sp in [solvepsbt, generate, calibrate]: - sp.add_argument("--grind-cmd", default=None, type=str, required=(sp==calibrate), help="Command to grind a block header for proof-of-work") - - args = parser.parse_args(sys.argv[1:]) - - args.bcli = lambda *a, input=b"", **kwargs: bitcoin_cli(shlex.split(args.cli), list(a), input=input, **kwargs) - - if hasattr(args, "address") and hasattr(args, "descriptor"): - args.derived_addresses = {} - - if args.debug: - logging.getLogger().setLevel(logging.DEBUG) - elif args.quiet: - logging.getLogger().setLevel(logging.WARNING) - else: - logging.getLogger().setLevel(logging.INFO) - - if hasattr(args, "fn"): - return args.fn(args) - else: - logging.error("Must specify command") - return 1 - -if __name__ == "__main__": - main() diff --git a/backend/script/reproduce.py b/backend/script/reproduce.py deleted file mode 100644 index eeb1449..0000000 --- a/backend/script/reproduce.py +++ /dev/null @@ -1,418 +0,0 @@ -#!/usr/bin/env python3 -""" -reproduce.py -============ -Reproduces 12 Bitcoin privacy vulnerabilities on a local custom Signet. -Each run creates NEW on-chain transactions that exhibit the vulnerability. -No detection logic — that lives in detect.py. - -Usage: - python3 reproduce.py # Create all 12 vulnerability scenarios - python3 reproduce.py -k 3 # Create only vulnerability 3 -""" - -import sys -import os -import json -import time - -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -from bitcoin_rpc import ( - cli, mine_blocks, get_tx, get_utxos, get_balance, - get_new_address, send_to_address, create_raw_tx, sign_raw_tx, - send_raw, get_block_count, create_funded_psbt, - process_psbt, finalize_psbt, -) - -# ═══════════════════════════════════════════════════════════════════════════════ -# Formatting helpers -# ═══════════════════════════════════════════════════════════════════════════════ -G = "\033[92m"; Y = "\033[93m"; C = "\033[96m"; B = "\033[1m"; R = "\033[0m" - -def header(num, title): - print(f"\n{'═'*78}") - print(f"{B}{C} REPRODUCE {num}: {title}{R}") - print(f"{'═'*78}") - -def ok(msg): - print(f" {G}✓{R} {msg}") - -def info(msg): - print(f" {Y}ℹ{R} {msg}") - -def ensure_funds(wallet, min_btc=0.5): - bal = get_balance(wallet) - if bal < min_btc: - addr = get_new_address(wallet, "bech32") - send_to_address("miner", addr, min_btc + 0.5) - mine_blocks(1) - -def mine_and_confirm(): - mine_blocks(1) - time.sleep(0.5) - -# ═══════════════════════════════════════════════════════════════════════════════ -# 1. Address Reuse -# ═══════════════════════════════════════════════════════════════════════════════ -def reproduce_01(): - header(1, "Address Reuse") - ensure_funds("bob", 1.0) - reused_addr = get_new_address("alice", "bech32") - txid1 = send_to_address("bob", reused_addr, 0.01) - txid2 = send_to_address("bob", reused_addr, 0.02) - mine_and_confirm() - ok(f"Sent to same address {reused_addr} twice: TX {txid1[:16]}… and {txid2[:16]}…") - -# ═══════════════════════════════════════════════════════════════════════════════ -# 2. Multi-input / CIOH -# ═══════════════════════════════════════════════════════════════════════════════ -def reproduce_02(): - header(2, "Multi-input / CIOH (Common Input Ownership Heuristic)") - ensure_funds("bob", 2.0) - for _ in range(5): - addr = get_new_address("alice", "bech32") - send_to_address("bob", addr, 0.005) - mine_and_confirm() - - utxos = get_utxos("alice", 1) - small = [u for u in utxos if 0.004 < u["amount"] < 0.006][:5] - if len(small) < 2: - info("Not enough small UTXOs, skipping consolidation step") - return - inputs = [{"txid": u["txid"], "vout": u["vout"]} for u in small] - dest = get_new_address("bob", "bech32") - total = sum(u["amount"] for u in small) - psbt_result = create_funded_psbt( - "alice", inputs, [{dest: round(total - 0.001, 8)}], - {"subtractFeeFromOutputs": [0], "add_inputs": False} - ) - signed = process_psbt("alice", psbt_result["psbt"]) - final = finalize_psbt(signed["psbt"]) - txid = send_raw(final["hex"]) - mine_and_confirm() - ok(f"Consolidated {len(small)} inputs in TX {txid[:16]}… (CIOH trigger)") - -# ═══════════════════════════════════════════════════════════════════════════════ -# 3. Dust UTXO Detection -# ═══════════════════════════════════════════════════════════════════════════════ -def reproduce_03(): - header(3, "Dust UTXO Detection") - ensure_funds("bob", 1.0) - dust1 = get_new_address("alice", "bech32") - dust2 = get_new_address("alice", "bech32") - bob_utxos = get_utxos("bob", 1) - big = max(bob_utxos, key=lambda u: u["amount"]) - change = get_new_address("bob", "bech32") - change_amt = round(big["amount"] - 0.00001000 - 0.00000546 - 0.0001, 8) - raw = create_raw_tx( - [{"txid": big["txid"], "vout": big["vout"]}], - [{dust1: 0.00001000}, {dust2: 0.00000546}, {change: change_amt}] - ) - signed = sign_raw_tx("bob", raw) - txid = send_raw(signed["hex"]) - mine_and_confirm() - ok(f"Created 1000-sat and 546-sat dust outputs to Alice in TX {txid[:16]}…") - -# ═══════════════════════════════════════════════════════════════════════════════ -# 4. Spending Dust with Normal Inputs -# ═══════════════════════════════════════════════════════════════════════════════ -def reproduce_04(): - header(4, "Spending Dust with Normal Inputs") - ensure_funds("alice", 0.5) - utxos = get_utxos("alice", 1) - dust_utxos = [u for u in utxos if u["amount"] <= 0.00001] - normal_utxos = [u for u in utxos if u["amount"] > 0.001] - - if not dust_utxos: - info("No dust UTXOs, creating one first…") - ensure_funds("bob", 1.0) - a = get_new_address("alice", "bech32") - bu = get_utxos("bob", 1) - big = max(bu, key=lambda u: u["amount"]) - ch = get_new_address("bob", "bech32") - raw = create_raw_tx( - [{"txid": big["txid"], "vout": big["vout"]}], - [{a: 0.00001000}, {ch: round(big["amount"] - 0.00001 - 0.0001, 8)}] - ) - signed = sign_raw_tx("bob", raw) - send_raw(signed["hex"]) - mine_and_confirm() - utxos = get_utxos("alice", 1) - dust_utxos = [u for u in utxos if u["amount"] <= 0.00001] - normal_utxos = [u for u in utxos if u["amount"] > 0.001] - - if not normal_utxos: - ensure_funds("alice", 0.5) - mine_and_confirm() - utxos = get_utxos("alice", 1) - normal_utxos = [u for u in utxos if u["amount"] > 0.001] - - dust = dust_utxos[0] - normal = normal_utxos[0] - dest = get_new_address("bob", "bech32") - total = dust["amount"] + normal["amount"] - raw = create_raw_tx( - [{"txid": dust["txid"], "vout": dust["vout"]}, - {"txid": normal["txid"], "vout": normal["vout"]}], - [{dest: round(total - 0.0001, 8)}] - ) - signed = sign_raw_tx("alice", raw) - txid = send_raw(signed["hex"]) - mine_and_confirm() - ok(f"Spent dust ({int(dust['amount']*1e8)} sats) + normal ({normal['amount']:.8f}) together in TX {txid[:16]}…") - -# ═══════════════════════════════════════════════════════════════════════════════ -# 5. Change Detection -# ═══════════════════════════════════════════════════════════════════════════════ -def reproduce_05(): - header(5, "Change Detection — Round Payment") - ensure_funds("alice", 1.0) - bob_addr = get_new_address("bob", "bech32") - txid = send_to_address("alice", bob_addr, 0.05) - mine_and_confirm() - ok(f"Alice paid Bob 0.05 BTC (round amount) in TX {txid[:16]}… — change output is obvious") - -# ═══════════════════════════════════════════════════════════════════════════════ -# 6. Consolidation Origin -# ═══════════════════════════════════════════════════════════════════════════════ -def reproduce_06(): - header(6, "Consolidation Origin") - ensure_funds("bob", 2.0) - for _ in range(4): - addr = get_new_address("alice", "bech32") - send_to_address("bob", addr, 0.003) - mine_and_confirm() - - utxos = get_utxos("alice", 1) - small = [u for u in utxos if 0.002 < u["amount"] < 0.004][:4] - if len(small) < 3: - info(f"Only {len(small)} small UTXOs, creating more…") - for _ in range(4): - addr = get_new_address("alice", "bech32") - send_to_address("bob", addr, 0.003) - mine_and_confirm() - utxos = get_utxos("alice", 1) - small = [u for u in utxos if 0.002 < u["amount"] < 0.004][:4] - - inputs = [{"txid": u["txid"], "vout": u["vout"]} for u in small] - consol_addr = get_new_address("alice", "bech32") - total = sum(u["amount"] for u in small) - raw = create_raw_tx(inputs, [{consol_addr: round(total - 0.0001, 8)}]) - signed = sign_raw_tx("alice", raw) - consol_txid = send_raw(signed["hex"]) - mine_and_confirm() - ok(f"Consolidated {len(small)} UTXOs → 1 in TX {consol_txid[:16]}…") - - # Now spend the consolidated output - utxos = get_utxos("alice", 1) - cu = [u for u in utxos if u["txid"] == consol_txid] - if cu: - dest = get_new_address("carol", "bech32") - raw = create_raw_tx( - [{"txid": cu[0]["txid"], "vout": cu[0]["vout"]}], - [{dest: round(cu[0]["amount"] - 0.0001, 8)}] - ) - signed = sign_raw_tx("alice", raw) - txid2 = send_raw(signed["hex"]) - mine_and_confirm() - ok(f"Spent consolidated UTXO in TX {txid2[:16]}… — carries full cluster history") - -# ═══════════════════════════════════════════════════════════════════════════════ -# 7. Script Type Mixing -# ═══════════════════════════════════════════════════════════════════════════════ -def reproduce_07(): - header(7, "Script Type Mixing") - ensure_funds("bob", 2.0) - wpkh = get_new_address("alice", "bech32") - tr = get_new_address("alice", "bech32m") - send_to_address("bob", wpkh, 0.005) - send_to_address("bob", tr, 0.005) - mine_and_confirm() - - utxos = get_utxos("alice", 1) - def is_wpkh(addr): - return addr and not addr.startswith(("tb1p","bc1p","bcrt1p")) and addr.startswith(("tb1q","bc1q","bcrt1q")) - def is_tr(addr): - return addr and addr.startswith(("tb1p","bc1p","bcrt1p")) - wu = next((u for u in utxos if is_wpkh(u.get("address","")) and u["amount"] >= 0.004), None) - tu = next((u for u in utxos if is_tr(u.get("address","")) and u["amount"] >= 0.004), None) - if not wu or not tu: - info("Could not find both UTXO types") - return - dest = get_new_address("bob", "bech32") - total = wu["amount"] + tu["amount"] - raw = create_raw_tx( - [{"txid": wu["txid"], "vout": wu["vout"]}, - {"txid": tu["txid"], "vout": tu["vout"]}], - [{dest: round(total - 0.0002, 8)}] - ) - signed = sign_raw_tx("alice", raw) - txid = send_raw(signed["hex"]) - mine_and_confirm() - ok(f"Mixed P2WPKH + P2TR inputs in TX {txid[:16]}… — script type fingerprint") - -# ═══════════════════════════════════════════════════════════════════════════════ -# 8. Cluster Merge -# ═══════════════════════════════════════════════════════════════════════════════ -def reproduce_08(): - header(8, "Cluster Merge") - ensure_funds("bob", 2.0) - ensure_funds("carol", 2.0) - a_addr = get_new_address("alice", "bech32") - b_addr = get_new_address("alice", "bech32") - txid_a = send_to_address("bob", a_addr, 0.004) - txid_b = send_to_address("carol", b_addr, 0.004) - mine_and_confirm() - - utxos = get_utxos("alice", 1) - ua = next((u for u in utxos if u["txid"] == txid_a), None) - ub = next((u for u in utxos if u["txid"] == txid_b), None) - if not ua: ua = next((u for u in utxos if u.get("address") == a_addr), None) - if not ub: ub = next((u for u in utxos if u.get("address") == b_addr), None) - if not ua or not ub: - info("Could not find both cluster UTXOs") - return - dest = get_new_address("bob", "bech32") - total = ua["amount"] + ub["amount"] - raw = create_raw_tx( - [{"txid": ua["txid"], "vout": ua["vout"]}, - {"txid": ub["txid"], "vout": ub["vout"]}], - [{dest: round(total - 0.0002, 8)}] - ) - signed = sign_raw_tx("alice", raw) - txid = send_raw(signed["hex"]) - mine_and_confirm() - ok(f"Merged Bob-cluster and Carol-cluster UTXOs in TX {txid[:16]}…") - -# ═══════════════════════════════════════════════════════════════════════════════ -# 9. Lookback Depth -# ═══════════════════════════════════════════════════════════════════════════════ -def reproduce_09(): - header(9, "Lookback Depth / UTXO Age") - old_addr = get_new_address("alice", "bech32") - send_to_address("miner", old_addr, 0.01) - mine_blocks(20) - new_addr = get_new_address("alice", "bech32") - send_to_address("miner", new_addr, 0.01) - mine_and_confirm() - ok(f"Created old UTXO (20+ blocks ago) and new UTXO (just now) for Alice") - -# ═══════════════════════════════════════════════════════════════════════════════ -# 10. Exchange Origin -# ═══════════════════════════════════════════════════════════════════════════════ -def reproduce_10(): - header(10, "Exchange Origin — Batch Withdrawal") - ensure_funds("exchange", 5.0) - batch = {} - wallets = ["alice", "bob", "carol", "alice", "bob", "carol", "alice", "bob"] - for i in range(8): - addr = get_new_address(wallets[i], "bech32") - batch[addr] = round(0.01 + i * 0.001, 8) - txid = cli("sendmany", "", json.dumps(batch), wallet="exchange") - mine_and_confirm() - ok(f"Exchange batch withdrawal to 8 recipients in TX {txid[:16]}…") - -# ═══════════════════════════════════════════════════════════════════════════════ -# 11. Tainted UTXOs -# ═══════════════════════════════════════════════════════════════════════════════ -def reproduce_11(): - header(11, "Tainted UTXOs / Dirty Money") - ensure_funds("risky", 2.0) - ensure_funds("bob", 1.0) - ta = get_new_address("alice", "bech32") - taint_txid = send_to_address("risky", ta, 0.01) - ca = get_new_address("alice", "bech32") - clean_txid = send_to_address("bob", ca, 0.01) - mine_and_confirm() - - utxos = get_utxos("alice", 1) - tu = next((u for u in utxos if u["txid"] == taint_txid), None) - cu = next((u for u in utxos if u["txid"] == clean_txid), None) - if not tu: tu = next((u for u in utxos if u.get("address") == ta), None) - if not cu: cu = next((u for u in utxos if u.get("address") == ca), None) - if not tu or not cu: - info("Could not locate tainted + clean UTXOs") - return - dest = get_new_address("carol", "bech32") - total = tu["amount"] + cu["amount"] - raw = create_raw_tx( - [{"txid": tu["txid"], "vout": tu["vout"]}, - {"txid": cu["txid"], "vout": cu["vout"]}], - [{dest: round(total - 0.0002, 8)}] - ) - signed = sign_raw_tx("alice", raw) - txid = send_raw(signed["hex"]) - mine_and_confirm() - ok(f"Merged tainted + clean UTXOs in TX {txid[:16]}… — taint propagation") - -# ═══════════════════════════════════════════════════════════════════════════════ -# 12. Behavioral Fingerprinting -# ═══════════════════════════════════════════════════════════════════════════════ -def reproduce_12(): - header(12, "Behavioral Fingerprinting") - ensure_funds("alice", 3.0) - ensure_funds("bob", 3.0) - - info("Alice's pattern: round amounts, always bech32…") - for i in range(5): - dest = get_new_address("carol", "bech32") - send_to_address("alice", dest, 0.01 * (i + 1)) - - mine_and_confirm() - - info("Bob's pattern: odd amounts, mixed address types…") - for i in range(5): - atype = "bech32m" if i % 2 == 0 else "bech32" - dest = get_new_address("carol", atype) - send_to_address("bob", dest, round(0.00723 * (i + 1) + 0.00011, 8)) - - mine_and_confirm() - ok("Created distinguishable behavioral patterns for Alice and Bob") - - -# ═══════════════════════════════════════════════════════════════════════════════ -# Main -# ═══════════════════════════════════════════════════════════════════════════════ -ALL = [ - (1, "Address Reuse", reproduce_01), - (2, "Multi-input / CIOH", reproduce_02), - (3, "Dust UTXO Detection", reproduce_03), - (4, "Dust Spending w/ Normal", reproduce_04), - (5, "Change Detection", reproduce_05), - (6, "Consolidation Origin", reproduce_06), - (7, "Script Type Mixing", reproduce_07), - (8, "Cluster Merge", reproduce_08), - (9, "Lookback Depth", reproduce_09), - (10, "Exchange Origin", reproduce_10), - (11, "Tainted UTXOs", reproduce_11), - (12, "Behavioral Fingerprint", reproduce_12), -] - -def main(): - filt = None - if "-k" in sys.argv: - idx = sys.argv.index("-k") - if idx + 1 < len(sys.argv): - filt = sys.argv[idx + 1] - - print(f"\n{B}{'═'*78}{R}") - print(f"{B}{C} REPRODUCE — Bitcoin Privacy Vulnerabilities{R}") - print(f"{B}{C} Custom Signet — {get_block_count()} blocks{R}") - print(f"{B}{'═'*78}{R}") - - for num, name, fn in ALL: - if filt and str(num) != filt: - continue - try: - fn() - except Exception as e: - print(f" \033[91m✗ ERROR in {name}: {e}\033[0m") - import traceback; traceback.print_exc() - - print(f"\n{B}{'═'*78}{R}") - print(f" {G}Done. All vulnerability scenarios have been created on-chain.{R}") - print(f" Now run: python3 detect.py ") - print(f"{B}{'═'*78}{R}\n") - -if __name__ == "__main__": - main() diff --git a/backend/script/setup.sh b/backend/script/setup.sh deleted file mode 100755 index c33e632..0000000 --- a/backend/script/setup.sh +++ /dev/null @@ -1,157 +0,0 @@ -#!/usr/bin/env bash -# ============================================================================= -# setup.sh — Bootstrap Bitcoin Core regtest for privacy vulnerability testing -# ============================================================================= -# Reproduces the full environment: -# • Stops any running bitcoind (both regtest and signet) -# • Optionally wipes the regtest data dir (pass --fresh to start from block 0) -# • Starts bitcoind with all config passed via CLI flags (no bitcoin.conf edits) -# • Creates wallets: miner alice bob carol exchange risky -# • Mines 110 blocks so coinbases mature and miner has spendable BTC -# -# Usage: -# ./setup.sh # keep existing chain state, reload wallets -# ./setup.sh --fresh # wipe regtest, start from genesis -# ============================================================================= -set -euo pipefail - -# ─── Config ─────────────────────────────────────────────────────────────────── -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -DATADIR="${SCRIPT_DIR}/bitcoin-data" -REGTEST_DIR="${DATADIR}/regtest" -WALLETS=(miner alice bob carol exchange risky) -INITIAL_BLOCKS=110 # must be >100 so coinbases mature - -# ─── Helpers ────────────────────────────────────────────────────────────────── -G="\033[92m"; Y="\033[93m"; R="\033[91m"; B="\033[1m"; C="\033[96m"; RST="\033[0m" -ok() { echo -e " ${G}✓${RST} $*"; } -info() { echo -e " ${Y}ℹ${RST} $*"; } -err() { echo -e " ${R}✗${RST} $*"; exit 1; } -bcli() { bitcoin-cli -datadir="$DATADIR" -regtest "$@"; } - -# ─── Parse args ─────────────────────────────────────────────────────────────── -FRESH=0 -for arg in "$@"; do - [[ "$arg" == "--fresh" ]] && FRESH=1 -done - -echo "" -echo -e "${B}${C}══════════════════════════════════════════════════════════${RST}" -echo -e "${B}${C} Bitcoin Regtest Setup — privacy vulnerability harness${RST}" -echo -e "${B}${C}══════════════════════════════════════════════════════════${RST}" -[[ $FRESH -eq 1 ]] && echo -e " ${Y}Mode: FRESH — regtest chain will be wiped${RST}" - -# ─── 1. Stop running daemons ────────────────────────────────────────────────── -echo "" -echo -e "${B}Step 1: Stop any running bitcoind${RST}" - -# Try to stop regtest instance (port 18443) -if bcli stop 2>/dev/null; then - ok "Stopped regtest bitcoind" - sleep 2 -else - info "No regtest bitcoind running (or already stopped)" -fi - -# Hard-kill any remaining bitcoind processes -if pgrep -x bitcoind > /dev/null 2>&1; then - info "Hard-killing remaining bitcoind processes …" - pkill -x bitcoind || true - sleep 2 -fi - -# ─── 2. Optionally wipe regtest chain ──────────────────────────────────────── -if [[ $FRESH -eq 1 ]]; then - echo "" - echo -e "${B}Step 2: Wipe regtest data dir${RST}" - rm -rf "$REGTEST_DIR" - ok "Wiped ${REGTEST_DIR}" -else - echo "" - info "Step 2: Keeping existing regtest chain (use --fresh to wipe)" -fi - -# ─── 3. Start bitcoind ──────────────────────────────────────────────────────── -echo "" -echo -e "${B}Step 3: Start bitcoind${RST}" -mkdir -p "$DATADIR" -bitcoind -daemon \ - -datadir="$DATADIR" \ - -regtest \ - -txindex=1 \ - -server=1 \ - -fallbackfee=0.00010 \ - -dustrelayfee=0.00000001 \ - -acceptnonstdtxn=1 -ok "bitcoind launched" - -# Wait for RPC to become ready -echo -n " … waiting for RPC" -for i in $(seq 1 30); do - sleep 1 - echo -n "." - if bcli getblockchaininfo > /dev/null 2>&1; then - echo "" - ok "RPC ready after ${i}s" - break - fi - if [[ $i -eq 30 ]]; then - echo "" - err "bitcoind did not respond within 30s — check logs at ${REGTEST_DIR}/debug.log" - fi -done - -BLOCKS=$(bcli getblockcount) -info "Chain height: ${BLOCKS} blocks" - -# ─── 4. Create / load wallets ───────────────────────────────────────────────── -echo "" -echo -e "${B}Step 4: Create wallets${RST}" -for w in "${WALLETS[@]}"; do - if bcli createwallet "$w" 2>/dev/null | grep -q '"name"'; then - ok "Created wallet: ${w}" - else - # Wallet DB already exists on disk — just load it - if bcli loadwallet "$w" 2>/dev/null | grep -q '"name"'; then - ok "Loaded existing wallet: ${w}" - else - # Already loaded (returned error -35) - info "Wallet already loaded: ${w}" - fi - fi -done - -# ─── 5. Mine initial blocks (only if fresh or chain has <110 blocks) ────────── -echo "" -echo -e "${B}Step 5: Mine initial blocks${RST}" -BLOCKS=$(bcli getblockcount) - -if [[ $BLOCKS -lt $INITIAL_BLOCKS ]]; then - NEED=$(( INITIAL_BLOCKS - BLOCKS )) - info "At block ${BLOCKS}, need ${NEED} more to reach ${INITIAL_BLOCKS}" - MINER_ADDR=$(bcli -rpcwallet=miner getnewaddress "" bech32) - bcli generatetoaddress "$NEED" "$MINER_ADDR" > /dev/null - BLOCKS=$(bcli getblockcount) - ok "Mined to block ${BLOCKS}" -else - ok "Already at block ${BLOCKS} — no mining needed" -fi - -MINER_BAL=$(bcli -rpcwallet=miner getbalance) -ok "Miner balance: ${MINER_BAL} BTC" - -# ─── 6. Summary ─────────────────────────────────────────────────────────────── -echo "" -echo -e "${B}${C}══════════════════════════════════════════════════════════${RST}" -echo -e "${B} Setup complete!${RST}" -echo -e "${B}${C}══════════════════════════════════════════════════════════${RST}" -echo -e " Chain: ${G}regtest${RST}" -echo -e " Blocks: ${G}$(bcli getblockcount)${RST}" -echo -e " Wallets: ${G}${WALLETS[*]}${RST}" -echo "" -echo -e " Next steps:" -echo -e " python3 reproduce.py # create 12 vulnerability scenarios" -echo -e " python3 detect.py --wallet alice \\" -echo -e " --known-risky-wallets risky \\" -echo -e " --known-exchange-wallets exchange" -echo "" From 0bee5dd14cd1d34fecf76ac75833b7dd4f065a12 Mon Sep 17 00:00:00 2001 From: Renato Britto Date: Tue, 24 Mar 2026 20:33:10 -0300 Subject: [PATCH 5/5] feat(cli): create stealth-cli --- Cargo.toml | 1 + cli/Cargo.toml | 22 +++++ cli/src/main.rs | 248 ++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 271 insertions(+) create mode 100644 cli/Cargo.toml create mode 100644 cli/src/main.rs diff --git a/Cargo.toml b/Cargo.toml index 610029d..e745b37 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,7 @@ [workspace] members = [ "core", + "cli", ] resolver = "2" diff --git a/cli/Cargo.toml b/cli/Cargo.toml new file mode 100644 index 0000000..d41179c --- /dev/null +++ b/cli/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "stealth-cli" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true +description = "Detects UTXO privacy vulnerabilities in wallets" +categories = ["cryptography::cryptocurrencies"] +keywords = ["bitcoin", "privacy", "cli"] +readme = "README.md" +exclude = ["tests"] + +[dependencies] +corepc-client = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +stealth-core = { workspace = true } + +[lints.rust] +missing_debug_implementations = "deny" diff --git a/cli/src/main.rs b/cli/src/main.rs new file mode 100644 index 0000000..5ff333e --- /dev/null +++ b/cli/src/main.rs @@ -0,0 +1,248 @@ +use std::path::PathBuf; +use std::process::ExitCode; +use std::{env, fs}; + +use stealth_core::scanner::{RpcAuth, RpcConfig, ScanTarget, UtxoInput}; + +fn main() -> ExitCode { + let args: Vec = env::args().skip(1).collect(); + + if args.is_empty() || args[0] == "--help" || args[0] == "-h" { + print_usage(); + return ExitCode::SUCCESS; + } + + if args[0] != "scan" { + eprintln!( + "error: unknown command '{}' (try 'stealth-cli --help')", + args[0] + ); + return ExitCode::from(2); + } + + match run_scan(&args[1..]) { + Ok(clean) => { + if clean { + ExitCode::SUCCESS + } else { + ExitCode::from(1) + } + } + Err(message) => { + eprintln!("error: {message}"); + ExitCode::from(2) + } + } +} + +fn run_scan(args: &[String]) -> Result { + let opts = parse_scan_args(args)?; + let config = opts.rpc_config()?; + let target = opts.scan_target()?; + + let report = stealth_core::scanner::scan(&config, target).map_err(|e| e.to_string())?; + + match opts.format.as_deref() { + Some("text") | None => print_text_report(&report), + Some("json") => { + let json = serde_json::to_string_pretty(&report) + .map_err(|e| format!("serialization failed: {e}"))?; + println!("{json}"); + } + Some(other) => return Err(format!("unsupported format '{other}' (use json or text)")), + } + + Ok(report.summary.clean) +} + +#[derive(Debug, Default)] +struct ScanOpts { + descriptor: Option, + descriptors_file: Option, + utxos_file: Option, + rpc_url: Option, + rpc_user: Option, + rpc_pass: Option, + rpc_cookie: Option, + format: Option, +} + +impl ScanOpts { + fn rpc_config(&self) -> Result { + let url = self + .rpc_url + .clone() + .or_else(|| env::var("STEALTH_RPC_URL").ok()) + .ok_or("--rpc-url or STEALTH_RPC_URL is required")?; + + let auth = match ( + self.rpc_user + .clone() + .or_else(|| env::var("STEALTH_RPC_USER").ok()), + self.rpc_pass + .clone() + .or_else(|| env::var("STEALTH_RPC_PASS").ok()), + self.rpc_cookie + .clone() + .or_else(|| env::var("STEALTH_RPC_COOKIE").ok().map(PathBuf::from)), + ) { + (Some(user), Some(pass), _) => RpcAuth::UserPass { user, pass }, + (_, _, Some(cookie)) => RpcAuth::CookieFile(cookie), + _ => RpcAuth::None, + }; + + Ok(RpcConfig { url, auth }) + } + + fn scan_target(&self) -> Result { + let mut sources = 0usize; + if self.descriptor.is_some() { + sources += 1; + } + if self.descriptors_file.is_some() { + sources += 1; + } + if self.utxos_file.is_some() { + sources += 1; + } + + if sources == 0 { + return Err( + "one input is required: --descriptor, --descriptors, or --utxos".to_owned(), + ); + } + if sources > 1 { + return Err( + "--descriptor, --descriptors, and --utxos are mutually exclusive".to_owned(), + ); + } + + if let Some(d) = &self.descriptor { + return Ok(ScanTarget::Descriptor(d.clone())); + } + if let Some(path) = &self.descriptors_file { + let content = fs::read_to_string(path) + .map_err(|e| format!("cannot read {}: {e}", path.display()))?; + let descriptors: Vec = serde_json::from_str(&content) + .map_err(|e| format!("invalid JSON in {}: {e}", path.display()))?; + return Ok(ScanTarget::Descriptors(descriptors)); + } + if let Some(path) = &self.utxos_file { + let content = fs::read_to_string(path) + .map_err(|e| format!("cannot read {}: {e}", path.display()))?; + let utxos: Vec = serde_json::from_str(&content) + .map_err(|e| format!("invalid JSON in {}: {e}", path.display()))?; + return Ok(ScanTarget::Utxos(utxos)); + } + + Err("no scan target specified".to_owned()) + } +} + +fn parse_scan_args(args: &[String]) -> Result { + let mut opts = ScanOpts::default(); + let mut i = 0; + + while i < args.len() { + match args[i].as_str() { + "--descriptor" => { + opts.descriptor = Some(take_value(args, &mut i, "--descriptor")?); + } + "--descriptors" => { + opts.descriptors_file = + Some(PathBuf::from(take_value(args, &mut i, "--descriptors")?)); + } + "--utxos" => { + opts.utxos_file = Some(PathBuf::from(take_value(args, &mut i, "--utxos")?)); + } + "--rpc-url" => { + opts.rpc_url = Some(take_value(args, &mut i, "--rpc-url")?); + } + "--rpc-user" => { + opts.rpc_user = Some(take_value(args, &mut i, "--rpc-user")?); + } + "--rpc-pass" => { + opts.rpc_pass = Some(take_value(args, &mut i, "--rpc-pass")?); + } + "--rpc-cookie" => { + opts.rpc_cookie = Some(PathBuf::from(take_value(args, &mut i, "--rpc-cookie")?)); + } + "--format" => { + opts.format = Some(take_value(args, &mut i, "--format")?); + } + other => return Err(format!("unknown flag '{other}'")), + } + i += 1; + } + + Ok(opts) +} + +fn take_value(args: &[String], i: &mut usize, flag: &str) -> Result { + *i += 1; + args.get(*i) + .cloned() + .ok_or_else(|| format!("{flag} requires a value")) +} + +fn print_text_report(report: &stealth_core::Report) { + println!( + "Scanned {} transactions, {} addresses, {} current UTXOs\n", + report.stats.transactions_analyzed, + report.stats.addresses_derived, + report.stats.utxos_current, + ); + + if report.summary.clean { + println!("No privacy issues found."); + return; + } + + if !report.findings.is_empty() { + println!("Findings ({}):", report.findings.len()); + for f in &report.findings { + println!( + " [{severity}] {vtype}: {desc}", + severity = f.severity, + vtype = f.vulnerability_type, + desc = f.description, + ); + } + println!(); + } + + if !report.warnings.is_empty() { + println!("Warnings ({}):", report.warnings.len()); + for w in &report.warnings { + println!( + " [{severity}] {vtype}: {desc}", + severity = w.severity, + vtype = w.vulnerability_type, + desc = w.description, + ); + } + } +} + +fn print_usage() { + eprintln!("stealth-cli – Bitcoin UTXO privacy vulnerability scanner\n"); + eprintln!("USAGE:"); + eprintln!(" stealth-cli scan [OPTIONS]\n"); + eprintln!("SCAN INPUT (one required, mutually exclusive):"); + eprintln!(" --descriptor Single output descriptor"); + eprintln!(" --descriptors JSON array of descriptors"); + eprintln!(" --utxos JSON array of {{txid,vout,...}}\n"); + eprintln!("RPC CONNECTION:"); + eprintln!(" --rpc-url bitcoind RPC endpoint"); + eprintln!(" --rpc-user RPC username"); + eprintln!(" --rpc-pass RPC password"); + eprintln!(" --rpc-cookie Path to .cookie file\n"); + eprintln!(" Env vars: STEALTH_RPC_URL, STEALTH_RPC_USER,"); + eprintln!(" STEALTH_RPC_PASS, STEALTH_RPC_COOKIE\n"); + eprintln!("OUTPUT:"); + eprintln!(" --format Output format (default: text)\n"); + eprintln!("EXIT CODES:"); + eprintln!(" 0 scan completed, no findings"); + eprintln!(" 1 scan completed, findings present"); + eprintln!(" 2 error"); +}