diff --git a/.gitignore b/.gitignore index 5910afc..09a9ffd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,3 @@ -backend/script/bitcoin-data/ node_modules/ dist/ .env @@ -13,3 +12,4 @@ dist/ .qwen **/__pycache__/ target/ +Cargo.lock \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock deleted file mode 100644 index 31afad8..0000000 --- a/Cargo.lock +++ /dev/null @@ -1,1735 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "anstream" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" -dependencies = [ - "anstyle", - "anstyle-parse", - "anstyle-query", - "anstyle-wincon", - "colorchoice", - "is_terminal_polyfill", - "utf8parse", -] - -[[package]] -name = "anstyle" -version = "1.0.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" - -[[package]] -name = "anstyle-parse" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" -dependencies = [ - "utf8parse", -] - -[[package]] -name = "anstyle-query" -version = "1.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "anstyle-wincon" -version = "3.0.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" -dependencies = [ - "anstyle", - "once_cell_polyfill", - "windows-sys 0.61.2", -] - -[[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 = "base64" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" - -[[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 = "clap" -version = "4.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" -dependencies = [ - "clap_builder", - "clap_derive", -] - -[[package]] -name = "clap_builder" -version = "4.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" -dependencies = [ - "anstream", - "anstyle", - "clap_lex", - "strsim", -] - -[[package]] -name = "clap_derive" -version = "4.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "clap_lex" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" - -[[package]] -name = "colorchoice" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" - -[[package]] -name = "const-random" -version = "0.1.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" -dependencies = [ - "const-random-macro", -] - -[[package]] -name = "const-random-macro" -version = "0.1.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" -dependencies = [ - "getrandom 0.2.17", - "once_cell", - "tiny-keccak", -] - -[[package]] -name = "crunchy" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" - -[[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 = "dlv-list" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f" -dependencies = [ - "const-random", -] - -[[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 = "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", - "futures-sink", -] - -[[package]] -name = "futures-core" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" - -[[package]] -name = "futures-io" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" - -[[package]] -name = "futures-sink" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" - -[[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-io", - "futures-sink", - "futures-task", - "memchr", - "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 = "hashbrown" -version = "0.14.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" - -[[package]] -name = "heck" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" - -[[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", - "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.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" -dependencies = [ - "memchr", - "serde", -] - -[[package]] -name = "is_terminal_polyfill" -version = "1.70.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" - -[[package]] -name = "itoa" -version = "1.0.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" - -[[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 = "libc" -version = "0.2.183" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" - -[[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 = "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 = "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 = "once_cell" -version = "1.21.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" - -[[package]] -name = "once_cell_polyfill" -version = "1.70.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" - -[[package]] -name = "ordered-multimap" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79" -dependencies = [ - "dlv-list", - "hashbrown", -] - -[[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.60.2", -] - -[[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 = "reqwest" -version = "0.12.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" -dependencies = [ - "base64", - "bytes", - "futures-channel", - "futures-core", - "futures-util", - "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 = "rust-ini" -version = "0.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7" -dependencies = [ - "cfg-if", - "ordered-multimap", -] - -[[package]] -name = "rustc-hash" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" - -[[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.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" -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 = "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 = "shlex" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" - -[[package]] -name = "signal-hook-registry" -version = "1.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" -dependencies = [ - "errno", - "libc", -] - -[[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-app" -version = "0.1.0" -dependencies = [ - "axum", - "clap", - "serde", - "serde_json", - "stealth-bitcoincore", - "stealth-core", - "tokio", - "tower", - "tower-http", -] - -[[package]] -name = "stealth-bitcoincore" -version = "0.1.0" -dependencies = [ - "reqwest", - "rust-ini", - "serde", - "serde_json", - "stealth-core", - "thiserror", - "urlencoding", -] - -[[package]] -name = "stealth-core" -version = "0.1.0" -dependencies = [ - "serde", - "serde_json", - "thiserror", -] - -[[package]] -name = "strsim" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" - -[[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 = "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 = "tiny-keccak" -version = "2.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" -dependencies = [ - "crunchy", -] - -[[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", - "signal-hook-registry", - "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-core", -] - -[[package]] -name = "tracing-core" -version = "0.1.36" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" -dependencies = [ - "once_cell", -] - -[[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 = "urlencoding" -version = "2.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" - -[[package]] -name = "utf8_iter" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" - -[[package]] -name = "utf8parse" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" - -[[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 = "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 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", -] - -[[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 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", -] - -[[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_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - -[[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_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - -[[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_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - -[[package]] -name = "windows_i686_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" - -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - -[[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_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - -[[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_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - -[[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.42" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2578b716f8a7a858b7f02d5bd870c14bf4ddbbcf3a4c05414ba6503640505e3" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.42" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e6cc098ea4d3bd6246687de65af3f920c430e236bee1e3bf2e441463f08a02f" -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 index 7f6669b..8d61bfd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,20 +1,34 @@ [workspace] members = [ - "crates/stealth-app", - "crates/stealth-bitcoincore", - "crates/stealth-core", + "model", + "bitcoincore", + "engine", ] resolver = "2" [workspace.package] -edition = "2024" -license = "MIT" version = "0.1.0" +edition = "2021" +authors = [ + "Breno Brito (brenorb)", + "Herberson Miranda (hsmiranda)", + "LORDBABUINO ", + "Renato Britto (satsfy) <0xsatsfy@gmail.com>", +] +license = "MIT" +repository = "https://github.com/stealth-bitcoin/stealth" +rust-version = "1.93.1" + [workspace.dependencies] -axum = "0.8" -clap = { version = "4.5", features = ["derive", "env"] } -serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" -thiserror = "2.0" -tokio = { version = "1.48", features = ["macros", "rt-multi-thread", "signal"] } +bitcoin = { version = "0.32.0", default-features = false, features = ["serde", "base64", "secp-recovery"] } +corepc-node = { version = "0.10.1", features = ["29_0"] } +serde = { version = "1.0.228", default-features = false, features = ["derive", "alloc"] } +serde_json = "1.0.145" +thiserror = "2.0.17" +stealth-engine = { path = "engine" } +stealth-model = { path = "model" } +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/README.md b/README.md index c38126e..1e9896e 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,12 @@ Stealth is currently transitioning from a controlled regtest environment to real The immediate focus is enabling analysis of real wallet data using a local Bitcoin node. +Stealth ships a Rust workspace with: + +- `stealth-engine` (analysis engine) +- `stealth-model` (domain model types and interfaces) +- `stealth-bitcoincore` (Bitcoin Core RPC gateway adapter) + ## Project Direction Stealth is evolving into a modular privacy heuristics engine for Bitcoin. @@ -69,36 +75,53 @@ Stealth identifies real-world privacy issues such as: Stealth's source-of-truth detector is: ``` -backend/script/detect.py +engine/src/detect.rs ``` -### Finding types +The report model and type names are defined in: -| Type | Meaning | -| ------------------------ | ----------------------------------------------- | -| `ADDRESS_REUSE` | Address received funds in multiple transactions | -| `CIOH` | Multi-input linkage across co-spent inputs | -| `DUST` | Dust output detection | -| `DUST_SPENDING` | Dust inputs linking clusters | -| `CHANGE_DETECTION` | Identifiable change output | -| `CONSOLIDATION` | Many-input transaction merging UTXOs | -| `SCRIPT_TYPE_MIXING` | Mixed script types in one spend | -| `CLUSTER_MERGE` | Previously separate funding chains merged | -| `UTXO_AGE_SPREAD` | Reveals dormancy and timing patterns | -| `EXCHANGE_ORIGIN` | Likely exchange withdrawal origin | -| `TAINTED_UTXO_MERGE` | Tainted inputs propagating risk | -| `BEHAVIORAL_FINGERPRINT` | Consistent identifiable patterns | +``` +model/src/types.rs +``` + +### Severity levels + +| Level | Meaning | +| ---------- | ----------------------------------------------------------------- | +| `LOW` | Weak or contextual signal; monitor behavior | +| `MEDIUM` | Meaningful privacy leakage under common heuristics | +| `HIGH` | Strong linkage/fingerprinting risk | +| `CRITICAL` | Very strong deanonymization signal requiring immediate mitigation | + +## Vulnerabilities detected + +Stealth currently runs **12 detectors** in `stealth-engine`. + +| # | Type | Default severity | What it indicates | +| --- | ------------------------ | ---------------- | ------------------------------------------------------ | +| 1 | `ADDRESS_REUSE` | HIGH | Same receive address used across multiple transactions | +| 2 | `CIOH` | HIGH - CRITICAL | Multi-input ownership linkage | +| 3 | `DUST` | MEDIUM - HIGH | Dust outputs received/spent | +| 4 | `DUST_SPENDING` | HIGH | Dust merged with normal inputs | +| 5 | `CHANGE_DETECTION` | MEDIUM | Identifiable change output patterns | +| 6 | `CONSOLIDATION` | MEDIUM | Consolidation transactions linking clusters | +| 7 | `SCRIPT_TYPE_MIXING` | HIGH | Mixed script types that fingerprint wallet behavior | +| 8 | `CLUSTER_MERGE` | HIGH | Previously separate clusters merged on-chain | +| 9 | `UTXO_AGE_SPREAD` | LOW | Broad age spread revealing timing behavior | +| 10 | `EXCHANGE_ORIGIN` | MEDIUM | Signals typical of exchange batch withdrawals | +| 11 | `TAINTED_UTXO_MERGE` | HIGH | Tainted and clean inputs merged | +| 12 | `BEHAVIORAL_FINGERPRINT` | MEDIUM | Repeating transaction patterns | ### Warning types -| Type | Meaning | -| --------------- | -------------------------------- | -| `DORMANT_UTXOS` | Dormant funds pattern | -| `DIRECT_TAINT` | Direct exposure to risky sources | +| Type | Typical severity | Meaning | +| --------------- | ---------------- | ----------------------------------------------- | +| `DORMANT_UTXOS` | LOW | Dormant/hoarded UTXO behavior | +| `DIRECT_TAINT` | HIGH | Funds directly received from known risky source | -## How to use +## How to use the frontend -1. Open the application +1. Run and open the application 2. Paste a wallet descriptor (`wpkh(...)`, `tr(...)`, etc.) 3. Click **Analyze** 4. Review: @@ -183,6 +206,17 @@ yarn dev ``` stealth/ +├── Cargo.toml # Rust workspace definition +├── engine/ # stealth-engine (detectors + graph + report model) +│ ├── src/ +│ │ ├── detect.rs # privacy detectors +│ │ ├── engine.rs # AnalysisEngine entry point +│ │ ├── graph.rs # Transaction graph builder +│ │ └── lib.rs # Crate root and re-exports +│ └── tests/ +│ └── integration.rs # Regtest integration tests +├── model/ # stealth-model (domain model types and interfaces) +├── bitcoincore/ # Bitcoin Core gateway implementation crate ├── frontend/ # React + Vite UI │ └── src/ │ ├── components/ # FindingCard, VulnerabilityBadge @@ -200,6 +234,16 @@ stealth/ └── slides/ # Slidev pitch presentation ``` +### Test Coverage + +Stealth test coverage includes end-to-end api tests, integration tests using bitcoind regtest in core/ and additional unit tests. + +You may run tests with: + +```bash +cargo test +``` + ## Privacy notice Stealth follows a local-first approach. diff --git a/bitcoincore/Cargo.toml b/bitcoincore/Cargo.toml new file mode 100644 index 0000000..0795131 --- /dev/null +++ b/bitcoincore/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "stealth-bitcoincore" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true +description = "Bitcoin Core RPC gateway for stealth-engine" + +[dependencies] +bitcoin = { workspace = true } +reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] } +ini = { package = "rust-ini", version = "0.21" } +serde = { workspace = true } +serde_json = { workspace = true } +stealth-model = { workspace = true } +thiserror = { workspace = true } +urlencoding = "2.1" diff --git a/bitcoincore/README.md b/bitcoincore/README.md new file mode 100644 index 0000000..f69b266 --- /dev/null +++ b/bitcoincore/README.md @@ -0,0 +1,138 @@ +# stealth-bitcoincore + +`stealth-bitcoincore` is the Bitcoin Core JSON-RPC gateway implementation for +[`stealth-engine`](../core/README.md). + +It implements `stealth_engine::gateway::BlockchainGateway` and is used by both: + +- `stealth-cli` (terminal scans) +- `stealth-api` (HTTP scans) + +## What it does + +This crate wraps the Bitcoin Core RPC surface needed by the analysis engine: + +- descriptor normalization and derivation +- descriptor import into temporary watch-only wallets +- wallet history + UTXO retrieval +- raw transaction expansion (including ancestry walk) + +The output is converted into `stealth-engine` gateway types (`WalletHistory`, +`DecodedTransaction`, `Utxo`, etc), so the engine can run detectors without +knowing anything about RPC transport details. + +## Authentication and configuration + +The gateway supports: + +- RPC user/password +- Bitcoin Core cookie auth + +`BitcoinCoreRpc::from_url(...)` does not auto-discover cookie files. If you +want internal cookie lookup, construct with `BitcoinCoreConfig` and set +`datadir`. + +### 1) Build from URL (used by API/CLI) + +```rust +use stealth_bitcoincore::BitcoinCoreRpc; + +let gateway = BitcoinCoreRpc::from_url( + "http://127.0.0.1:18443", + Some("rpcuser".to_owned()), + Some("rpcpassword".to_owned()), +)?; +# let _ = gateway; +# Ok::<(), stealth_model::error::AnalysisError>(()) +``` + +Pass explicit credentials (or parse a cookie file yourself and pass those +values here). + +### 2) Build from INI file + +```ini +[bitcoin] +network=regtest +datadir=/home/user/.bitcoin +rpchost=127.0.0.1 +rpcport=18443 +rpcuser=rpcuser +rpcpassword=rpcpassword +``` + +```rust +use stealth_bitcoincore::{BitcoinCoreConfig, BitcoinCoreRpc}; + +let config = BitcoinCoreConfig::from_ini_file("stealth.ini")?; +let gateway = BitcoinCoreRpc::new(config)?; +# let _ = gateway; +# Ok::<(), stealth_model::error::AnalysisError>(()) +``` + +Config defaults: + +- `network`: `regtest` +- `rpchost`: `127.0.0.1` +- `rpcport`: inferred from network (`8332` mainnet, `18332` testnet, `38332` signet, `18443` regtest) +- `datadir`: optional (required for cookie fallback) + +Cookie lookup: + +- mainnet: `/.cookie` +- other networks: `//.cookie`, then `/.cookie` + +## Using with the analysis engine + +```rust,ignore +use stealth_bitcoincore::BitcoinCoreRpc; +use stealth_engine::{AnalysisEngine, EngineSettings, ScanTarget}; + +let gateway = BitcoinCoreRpc::from_url( + "http://127.0.0.1:18443", + Some("rpcuser".to_owned()), + Some("rpcpassword".to_owned()), +)?; + +let engine = AnalysisEngine::new(&gateway, EngineSettings::default()); +let report = engine.analyze(ScanTarget::Descriptor( + "wpkh([f23f9fd2/84h/1h/0h]tpub.../0/*)".to_owned(), +))?; + +println!("clean: {}", report.summary.clean); +# Ok::<(), stealth_model::error::AnalysisError>(()) +``` + +## RPC methods used + +| Gateway behavior | Bitcoin Core RPC | +| --- | --- | +| Descriptor normalization | `getdescriptorinfo` | +| Address derivation | `deriveaddresses` | +| Temporary wallet creation | `createwallet` | +| Descriptor import | `importdescriptors` | +| Wallet tx list | `listtransactions` | +| UTXO list | `listunspent` | +| Raw tx decode/history expansion | `getrawtransaction` (verbose) | +| Wallet descriptor listing | `listdescriptors` | +| Wallet cleanup | `unloadwallet` | + +## Notes + +- Descriptor scans create a temporary wallet named `_stealth_scan_` + and unload it after collection. +- Most gateway failures are surfaced as + `AnalysisError::EnvironmentUnavailable(...)` with the underlying RPC/context + message. +- For robust transaction lookups beyond wallet-only data, running `bitcoind` + with `txindex=1` is recommended. + +## Development + +```bash +cargo test -p stealth-bitcoincore +``` + +## License + +[MIT](../LICENSE) diff --git a/crates/stealth-bitcoincore/src/lib.rs b/bitcoincore/src/lib.rs similarity index 58% rename from crates/stealth-bitcoincore/src/lib.rs rename to bitcoincore/src/lib.rs index 95769f7..b65494f 100644 --- a/crates/stealth-bitcoincore/src/lib.rs +++ b/bitcoincore/src/lib.rs @@ -3,17 +3,19 @@ use std::fs; use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; +use bitcoin::address::NetworkUnchecked; +use bitcoin::{Address, Txid}; use ini::Ini; use reqwest::blocking::Client; -use serde::Deserialize; use serde::de::DeserializeOwned; -use serde_json::{Value, json}; -use stealth_core::error::AnalysisError; -use stealth_core::gateway::BlockchainGateway; -use stealth_core::model::{ - DecodedTransaction, DescriptorType, ResolvedDescriptor, TxInputRef, TxOutput, Utxo, - WalletHistory, WalletTxCategory, WalletTxEntry, +use serde::Deserialize; +use serde_json::{json, Value}; +use stealth_model::error::AnalysisError; +use stealth_model::gateway::{ + BlockchainGateway, DecodedTransaction, DescriptorType, ResolvedDescriptor, TxInputRef, + TxOutput, Utxo, WalletHistory, WalletTxCategory, WalletTxEntry, }; +use stealth_model::types::btc_to_amount; #[derive(Debug, Clone, PartialEq, Eq)] pub struct BitcoinCoreConfig { @@ -93,13 +95,8 @@ impl BitcoinCoreConfig { if !candidate.exists() { continue; } - let contents = fs::read_to_string(&candidate) - .map_err(|error| AnalysisError::EnvironmentUnavailable(error.to_string()))?; - let mut parts = contents.trim().splitn(2, ':'); - let user = parts.next().unwrap_or_default().to_string(); - let password = parts.next().unwrap_or_default().to_string(); - if !user.is_empty() && !password.is_empty() { - return Ok((user, password)); + if let Ok(creds) = read_cookie_file(&candidate) { + return Ok(creds); } } @@ -109,6 +106,34 @@ impl BitcoinCoreConfig { } } +/// Read a Bitcoin Core `.cookie` file, returning `(user, password)`. +/// +/// The cookie format is a single line of `__cookie__:hex_password`. +pub fn read_cookie_file(path: &Path) -> Result<(String, String), AnalysisError> { + let contents = fs::read_to_string(path).map_err(|e| { + AnalysisError::EnvironmentUnavailable(format!( + "cannot read cookie file {}: {e}", + path.display() + )) + })?; + let mut parts = contents.trim().splitn(2, ':'); + let user = parts + .next() + .filter(|s| !s.is_empty()) + .ok_or_else(|| { + AnalysisError::EnvironmentUnavailable(format!("invalid cookie file {}", path.display())) + })? + .to_string(); + let pass = parts + .next() + .filter(|s| !s.is_empty()) + .ok_or_else(|| { + AnalysisError::EnvironmentUnavailable(format!("invalid cookie file {}", path.display())) + })? + .to_string(); + Ok((user, pass)) +} + pub struct BitcoinCoreRpc { config: BitcoinCoreConfig, client: Client, @@ -122,6 +147,27 @@ impl BitcoinCoreRpc { Ok(Self { config, client }) } + /// Construct a gateway from a URL and optional credentials. + /// + /// This mirrors the env-var based configuration used by the HTTP + /// API (`STEALTH_RPC_URL`, `STEALTH_RPC_USER`, `STEALTH_RPC_PASS`). + pub fn from_url( + url: &str, + user: Option, + password: Option, + ) -> Result { + let (host, port) = parse_host_port_from_url(url); + let config = BitcoinCoreConfig { + network: infer_network_from_port(port), + datadir: None, + rpchost: host, + rpcport: port, + rpcuser: user, + rpcpassword: password, + }; + Self::new(config) + } + fn rpc_url(&self, wallet: Option<&str>) -> String { let base = format!("http://{}:{}", self.config.rpchost, self.config.rpcport); wallet @@ -183,9 +229,9 @@ impl BitcoinCoreRpc { let utxos = self.list_unspent(wallet_name)?; let mut txids = wallet_txs .iter() - .map(|entry| entry.txid.clone()) + .map(|entry| entry.txid) .collect::>(); - txids.extend(utxos.iter().map(|utxo| utxo.txid.clone())); + txids.extend(utxos.iter().map(|utxo| utxo.txid)); let mut transactions = HashMap::new(); let mut queue = txids.into_iter().collect::>(); @@ -193,19 +239,21 @@ impl BitcoinCoreRpc { if transactions.contains_key(&txid) { continue; } - let tx = self.get_transaction(&txid)?; + let tx = self.decode_transaction(txid)?; for input in &tx.vin { if !input.coinbase && !transactions.contains_key(&input.previous_txid) { - queue.push(input.previous_txid.clone()); + queue.push(input.previous_txid); } } - transactions.insert(txid.clone(), tx); + transactions.insert(txid, tx); } Ok(WalletHistory { wallet_txs, utxos, transactions, + internal_addresses: HashSet::new(), + derived_addresses: HashSet::new(), }) } @@ -215,21 +263,25 @@ impl BitcoinCoreRpc { "listtransactions", vec![json!("*"), json!(10000), json!(0), json!(true)], )?; - Ok(entries + entries .into_iter() - .map(|entry| WalletTxEntry { - txid: entry.txid, - address: entry.address.unwrap_or_default(), - category: match entry.category.as_deref() { - Some("send") => WalletTxCategory::Send, - Some("receive") => WalletTxCategory::Receive, - _ => WalletTxCategory::Unknown, - }, - amount_btc: entry.amount, - confirmations: entry.confirmations.unwrap_or_default(), - blockheight: entry.blockheight.unwrap_or_default(), + .map(|entry| { + let address: Option> = + entry.address.as_deref().and_then(|s| s.parse().ok()); + Ok(WalletTxEntry { + txid: parse_txid(&entry.txid)?, + address, + category: match entry.category.as_deref() { + Some("send") => WalletTxCategory::Send, + Some("receive") => WalletTxCategory::Receive, + _ => WalletTxCategory::Unknown, + }, + amount: btc_to_amount(entry.amount.abs()), + confirmations: entry.confirmations.unwrap_or_default(), + blockheight: entry.blockheight.unwrap_or_default(), + }) }) - .collect()) + .collect() } fn list_unspent(&self, wallet_name: &str) -> Result, AnalysisError> { @@ -238,43 +290,59 @@ impl BitcoinCoreRpc { "listunspent", vec![json!(0), json!(9_999_999)], )?; - Ok(utxos + utxos .into_iter() .map(|utxo| { - let address = utxo.address.unwrap_or_default(); - Utxo { - txid: utxo.txid, + let address: Option> = + utxo.address.as_deref().and_then(|s| s.parse().ok()); + Ok(Utxo { + txid: parse_txid(&utxo.txid)?, vout: utxo.vout, - address: address.clone(), - amount_btc: utxo.amount, + script_type: address + .as_ref() + .map(DescriptorType::infer_from_address) + .unwrap_or(DescriptorType::Unknown), + address, + amount: btc_to_amount(utxo.amount), confirmations: utxo.confirmations.unwrap_or_default(), - script_type: DescriptorType::infer_from_address(&address), - } + }) }) - .collect()) + .collect() } - fn get_transaction(&self, txid: &str) -> Result { - let tx = - self.call::(None, "getrawtransaction", vec![json!(txid), json!(true)])?; + fn decode_transaction(&self, txid: Txid) -> Result { + let tx = self.call::( + None, + "getrawtransaction", + vec![json!(txid.to_string()), json!(true)], + )?; Ok(DecodedTransaction { - txid: tx.txid, + txid: parse_txid(&tx.txid)?, vin: tx .vin .into_iter() - .map(|input| TxInputRef { - previous_txid: input.txid.unwrap_or_default(), - previous_vout: input.vout.unwrap_or_default(), - sequence: input.sequence.unwrap_or(0xffff_ffff), - coinbase: input.coinbase.is_some(), + .map(|input| { + Ok(TxInputRef { + previous_txid: match &input.txid { + Some(s) => parse_txid(s)?, + // Bitcoin protocol: coinbase inputs reference all-zeros. + None => parse_txid( + "0000000000000000000000000000000000000000000000000000000000000000", + ) + .expect("zero txid is always valid"), + }, + previous_vout: input.vout.unwrap_or_default(), + sequence: input.sequence.unwrap_or(0xffff_ffff), + coinbase: input.coinbase.is_some(), + }) }) - .collect(), + .collect::, AnalysisError>>()?, vout: tx .vout .into_iter() .map(|output| { - let address = output + let address: Option> = output .script_pub_key .address .or_else(|| { @@ -283,17 +351,22 @@ impl BitcoinCoreRpc { .addresses .and_then(|mut items| items.pop()) }) - .unwrap_or_default(); + .and_then(|s| s.parse().ok()); TxOutput { n: output.n, - address: address.clone(), - value_btc: output.value, - script_type: output - .script_pub_key - .script_type - .as_deref() - .map(descriptor_type_from_script_pub_key) - .unwrap_or_else(|| DescriptorType::infer_from_address(&address)), + script_type: address + .as_ref() + .map(DescriptorType::infer_from_address) + .or_else(|| { + output + .script_pub_key + .script_type + .as_deref() + .map(descriptor_type_from_script_pub_key) + }) + .unwrap_or(DescriptorType::Unknown), + address, + value: btc_to_amount(output.value), } }) .collect(), @@ -335,12 +408,20 @@ impl BlockchainGateway for BitcoinCoreRpc { fn derive_addresses( &self, descriptor: &ResolvedDescriptor, - ) -> Result, AnalysisError> { - self.call( + ) -> Result>, AnalysisError> { + let strings: Vec = self.call( None, "deriveaddresses", vec![json!(descriptor.desc), json!([0, descriptor.range_end])], - ) + )?; + strings + .into_iter() + .map(|s| { + s.parse::>().map_err(|e| { + AnalysisError::EnvironmentUnavailable(format!("invalid address '{s}': {e}")) + }) + }) + .collect() } fn scan_descriptors( @@ -356,16 +437,27 @@ impl BlockchainGateway for BitcoinCoreRpc { ); self.create_watch_only_wallet(&wallet_name)?; + // RAII guard: ensure the temporary wallet is always unloaded, + // even if the body below returns an early error via `?`. + let _guard = WalletGuard { + rpc: self, + name: &wallet_name, + }; + let imports = descriptors .iter() .map(|descriptor| { - json!({ + let is_ranged = descriptor.desc.contains('*'); + let mut entry = json!({ "desc": descriptor.desc, "timestamp": 0, "internal": descriptor.internal, - "active": descriptor.active, - "range": [0, descriptor.range_end], - }) + "active": is_ranged && descriptor.active, + }); + if is_ranged { + entry["range"] = json!([0, descriptor.range_end]); + } + entry }) .collect::>(); @@ -375,15 +467,34 @@ impl BlockchainGateway for BitcoinCoreRpc { vec![json!(imports)], )?; if import_results.iter().any(|result| !result.success) { - self.unload_wallet(&wallet_name); - return Err(AnalysisError::EnvironmentUnavailable( - "descriptor import failed".into(), - )); + let errors: Vec<_> = import_results + .iter() + .filter(|r| !r.success) + .filter_map(|r| r.error.as_ref().map(|e| e.message.as_str())) + .collect(); + return Err(AnalysisError::EnvironmentUnavailable(format!( + "descriptor import failed: {}", + errors.join("; ") + ))); } - let history = self.load_history_for_wallet(&wallet_name); - self.unload_wallet(&wallet_name); - history + let mut history = self.load_history_for_wallet(&wallet_name)?; + + // Derive all addresses from every descriptor + let mut internal_addresses = HashSet::new(); + let mut derived_addresses = HashSet::new(); + for desc in descriptors { + if let Ok(addrs) = self.derive_addresses(desc) { + if desc.internal { + internal_addresses.extend(addrs.iter().cloned()); + } + derived_addresses.extend(addrs); + } + } + history.internal_addresses = internal_addresses; + history.derived_addresses = derived_addresses; + + Ok(history) } fn list_wallet_descriptors( @@ -411,13 +522,30 @@ impl BlockchainGateway for BitcoinCoreRpc { } fn scan_wallet(&self, wallet_name: &str) -> Result { - self.load_history_for_wallet(wallet_name) + let mut history = self.load_history_for_wallet(wallet_name)?; + + // Derive ALL addresses from every descriptor (both external and + // internal chains) so that `is_ours()` in TxGraph recognises + // every derived address. + if let Ok(descriptors) = self.list_wallet_descriptors(wallet_name) { + let mut internal_addresses = HashSet::new(); + let mut derived_addresses = HashSet::new(); + for desc in &descriptors { + if let Ok(addrs) = self.derive_addresses(desc) { + if desc.internal { + internal_addresses.extend(addrs.iter().cloned()); + } + derived_addresses.extend(addrs); + } + } + history.internal_addresses = internal_addresses; + history.derived_addresses = derived_addresses; + } + + Ok(history) } - fn known_wallet_txids( - &self, - wallet_names: &[String], - ) -> Result, AnalysisError> { + fn known_wallet_txids(&self, wallet_names: &[String]) -> Result, AnalysisError> { let mut txids = HashSet::new(); for wallet_name in wallet_names { txids.extend( @@ -428,6 +556,28 @@ impl BlockchainGateway for BitcoinCoreRpc { } Ok(txids) } + + fn get_transaction(&self, txid: Txid) -> Result { + self.decode_transaction(txid) + } +} + +/// RAII guard that calls `unloadwallet` when dropped, ensuring cleanup +/// even when an early `?` return skips the normal unload path. +struct WalletGuard<'a> { + rpc: &'a BitcoinCoreRpc, + name: &'a str, +} + +impl Drop for WalletGuard<'_> { + fn drop(&mut self) { + self.rpc.unload_wallet(self.name); + } +} + +fn parse_txid(s: &str) -> Result { + s.parse::() + .map_err(|e| AnalysisError::EnvironmentUnavailable(format!("invalid txid '{s}': {e}"))) } fn default_rpc_port(network: &str) -> u16 { @@ -443,12 +593,38 @@ fn descriptor_type_from_script_pub_key(script_type: &str) -> DescriptorType { match script_type { "witness_v0_keyhash" => DescriptorType::P2wpkh, "witness_v1_taproot" => DescriptorType::P2tr, - "scripthash" => DescriptorType::P2shP2wpkh, + "scripthash" => DescriptorType::P2sh, "pubkeyhash" => DescriptorType::P2pkh, _ => DescriptorType::Unknown, } } +fn parse_host_port_from_url(url: &str) -> (String, u16) { + let without_scheme = url + .strip_prefix("http://") + .or_else(|| url.strip_prefix("https://")) + .unwrap_or(url); + let authority = without_scheme.split('/').next().unwrap_or(without_scheme); + match authority.rsplit_once(':') { + Some((host, port_str)) => { + let port = port_str.parse::().unwrap_or(8332); + (host.to_owned(), port) + } + None => (authority.to_owned(), 8332), + } +} + +fn infer_network_from_port(port: u16) -> String { + match port { + 8332 => "mainnet", + 18332 => "testnet", + 38332 => "signet", + 18443 => "regtest", + _ => "regtest", + } + .to_owned() +} + #[derive(Debug, Deserialize)] struct JsonRpcEnvelope { result: Option, @@ -468,6 +644,14 @@ struct DescriptorInfo { #[derive(Debug, Deserialize)] struct ImportResult { success: bool, + #[serde(default)] + error: Option, +} + +#[derive(Debug, Deserialize)] +struct ImportError { + #[serde(default)] + message: String, } #[derive(Debug, Deserialize)] diff --git a/crates/stealth-app/Cargo.toml b/crates/stealth-app/Cargo.toml deleted file mode 100644 index 9db2343..0000000 --- a/crates/stealth-app/Cargo.toml +++ /dev/null @@ -1,18 +0,0 @@ -[package] -name = "stealth-app" -version.workspace = true -edition.workspace = true -license.workspace = true - -[dependencies] -axum.workspace = true -clap.workspace = true -serde.workspace = true -serde_json.workspace = true -stealth-bitcoincore = { path = "../stealth-bitcoincore" } -stealth-core = { path = "../stealth-core" } -tokio.workspace = true -tower-http = { version = "0.6", features = ["cors"] } - -[dev-dependencies] -tower = { version = "0.5", features = ["util"] } diff --git a/crates/stealth-app/src/bin/stealth-api.rs b/crates/stealth-app/src/bin/stealth-api.rs deleted file mode 100644 index d54947f..0000000 --- a/crates/stealth-app/src/bin/stealth-api.rs +++ /dev/null @@ -1,39 +0,0 @@ -use std::net::SocketAddr; -use std::path::PathBuf; -use std::sync::Arc; - -use clap::Parser; -use stealth_app::{build_router, build_runtime_service, default_bitcoin_config_path}; -use stealth_core::engine::EngineSettings; - -#[derive(Debug, Parser)] -struct ApiCli { - #[arg(long, default_value = "0.0.0.0")] - host: String, - #[arg(long, default_value_t = 8080)] - port: u16, - #[arg(long, default_value = "http://localhost:5173")] - cors_origin: String, - #[arg(long, default_value_os_t = default_bitcoin_config_path())] - config: PathBuf, - #[arg(long = "known-risky-wallet")] - known_risky_wallets: Vec, - #[arg(long = "known-exchange-wallet")] - known_exchange_wallets: Vec, -} - -#[tokio::main] -async fn main() -> Result<(), Box> { - let cli = ApiCli::parse(); - let settings = EngineSettings { - known_exchange_wallets: cli.known_exchange_wallets, - known_risky_wallets: cli.known_risky_wallets, - ..EngineSettings::default() - }; - let service = build_runtime_service(&cli.config, settings)?; - let router = build_router(Arc::new(service), Some(&cli.cors_origin)); - let addr: SocketAddr = format!("{}:{}", cli.host, cli.port).parse()?; - let listener = tokio::net::TcpListener::bind(addr).await?; - axum::serve(listener, router).await?; - Ok(()) -} diff --git a/crates/stealth-app/src/lib.rs b/crates/stealth-app/src/lib.rs deleted file mode 100644 index 4458728..0000000 --- a/crates/stealth-app/src/lib.rs +++ /dev/null @@ -1,189 +0,0 @@ -use std::path::{Path, PathBuf}; -use std::sync::Arc; - -use axum::extract::{Query, State}; -use axum::http::{HeaderValue, Method, StatusCode}; -use axum::response::{IntoResponse, Response}; -use axum::{Json, Router, routing::get}; -use serde::{Deserialize, Serialize}; -use stealth_bitcoincore::{BitcoinCoreConfig, BitcoinCoreRpc}; -use stealth_core::engine::{AnalysisEngine, EngineSettings, ScanTarget}; -use stealth_core::error::AnalysisError; -use stealth_core::gateway::BlockchainGateway; -use stealth_core::model::AnalysisReport; -use tower_http::cors::CorsLayer; - -pub trait ScanService: Send + Sync + 'static { - fn analyze_descriptor(&self, descriptor: String) -> Result; -} - -pub struct CoreScanService { - gateway: G, - settings: EngineSettings, -} - -impl CoreScanService { - pub fn new(gateway: G, settings: EngineSettings) -> Self { - Self { gateway, settings } - } -} - -impl ScanService for CoreScanService -where - G: BlockchainGateway + Send + Sync + 'static, -{ - fn analyze_descriptor(&self, descriptor: String) -> Result { - AnalysisEngine::new(&self.gateway, self.settings.clone()) - .analyze(ScanTarget::Descriptors(vec![descriptor])) - } -} - -pub fn default_bitcoin_config_path() -> PathBuf { - PathBuf::from("backend/script/config.ini") -} - -pub fn build_runtime_service( - config_path: &Path, - settings: EngineSettings, -) -> Result, AnalysisError> { - let config = BitcoinCoreConfig::from_ini_file(config_path)?; - let gateway = BitcoinCoreRpc::new(config)?; - Ok(CoreScanService::new(gateway, settings)) -} - -pub fn build_router(service: Arc, cors_origin: Option<&str>) -> Router -where - S: ScanService, -{ - let mut router = Router::new() - .route("/api/wallet/scan", get(scan_handler::)) - .with_state(service); - - if let Some(origin) = cors_origin { - if let Ok(header_value) = HeaderValue::from_str(origin) { - router = router.layer( - CorsLayer::new() - .allow_origin(header_value) - .allow_methods([Method::GET]) - .allow_headers([axum::http::header::CONTENT_TYPE, axum::http::header::ACCEPT]), - ); - } - } - - router -} - -#[derive(Debug, Deserialize)] -struct ScanQuery { - descriptor: Option, -} - -#[derive(Debug, Serialize)] -struct ErrorBody { - error: String, -} - -async fn scan_handler(State(service): State>, Query(query): Query) -> Response -where - S: ScanService, -{ - let Some(descriptor) = query.descriptor.map(|value| value.trim().to_string()) else { - return json_error( - StatusCode::BAD_REQUEST, - "descriptor query parameter is required".into(), - ); - }; - if descriptor.is_empty() { - return json_error( - StatusCode::BAD_REQUEST, - "descriptor query parameter is required".into(), - ); - } - - match service.analyze_descriptor(descriptor) { - Ok(report) => Json(report).into_response(), - Err(error) => map_error(error), - } -} - -fn map_error(error: AnalysisError) -> Response { - match error { - AnalysisError::EmptyDescriptor | AnalysisError::DescriptorNormalization { .. } => { - json_error(StatusCode::BAD_REQUEST, error.to_string()) - } - AnalysisError::AnalysisEmpty => json_error(StatusCode::NOT_FOUND, error.to_string()), - AnalysisError::EnvironmentUnavailable(_) => { - json_error(StatusCode::INTERNAL_SERVER_ERROR, error.to_string()) - } - } -} - -fn json_error(status: StatusCode, message: String) -> Response { - (status, Json(ErrorBody { error: message })).into_response() -} - -#[cfg(test)] -mod tests { - use std::sync::Arc; - - use axum::body::{Body, to_bytes}; - use axum::http::{Request, StatusCode}; - use serde_json::json; - use tower::util::ServiceExt; - - use super::*; - - struct MockService(Result); - - impl ScanService for MockService { - fn analyze_descriptor(&self, _descriptor: String) -> Result { - self.0.clone() - } - } - - #[tokio::test] - async fn returns_400_for_missing_descriptor() { - let app = build_router( - Arc::new(MockService(Ok(AnalysisReport::new( - 0, - 0, - Vec::new(), - Vec::new(), - )))), - None, - ); - - let response = app - .oneshot( - Request::builder() - .uri("/api/wallet/scan") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - - assert_eq!(response.status(), StatusCode::BAD_REQUEST); - } - - #[tokio::test] - async fn returns_json_report_for_successful_scan() { - let report = AnalysisReport::new(1, 2, Vec::new(), Vec::new()); - let app = build_router(Arc::new(MockService(Ok(report))), None); - - let response = app - .oneshot( - Request::builder() - .uri("/api/wallet/scan?descriptor=wpkh(test)") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - - assert_eq!(response.status(), StatusCode::OK); - let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); - let json = serde_json::from_slice::(&body).unwrap(); - assert_eq!(json["summary"]["clean"], json!(true)); - } -} diff --git a/crates/stealth-app/src/main.rs b/crates/stealth-app/src/main.rs deleted file mode 100644 index 99860e4..0000000 --- a/crates/stealth-app/src/main.rs +++ /dev/null @@ -1,48 +0,0 @@ -use std::path::PathBuf; - -use clap::Parser; -use stealth_app::default_bitcoin_config_path; -use stealth_bitcoincore::{BitcoinCoreConfig, BitcoinCoreRpc}; -use stealth_core::engine::{AnalysisEngine, EngineSettings, ScanTarget}; - -#[derive(Debug, Parser)] -struct Cli { - #[arg(long = "descriptor", short = 'd')] - descriptors: Vec, - #[arg(long)] - wallet: Option, - #[arg(long, default_value_os_t = default_bitcoin_config_path())] - config: PathBuf, - #[arg(long = "known-risky-wallet")] - known_risky_wallets: Vec, - #[arg(long = "known-exchange-wallet")] - known_exchange_wallets: Vec, - #[arg(long)] - pretty: bool, -} - -fn main() -> Result<(), Box> { - let cli = Cli::parse(); - let settings = EngineSettings { - known_exchange_wallets: cli.known_exchange_wallets, - known_risky_wallets: cli.known_risky_wallets, - ..EngineSettings::default() - }; - let config = BitcoinCoreConfig::from_ini_file(&cli.config)?; - let gateway = BitcoinCoreRpc::new(config)?; - let engine = AnalysisEngine::new(&gateway, settings); - - let report = if let Some(wallet_name) = cli.wallet { - engine.analyze(ScanTarget::WalletName(wallet_name))? - } else { - engine.analyze(ScanTarget::Descriptors(cli.descriptors))? - }; - - if cli.pretty { - println!("{}", serde_json::to_string_pretty(&report)?); - } else { - println!("{}", serde_json::to_string(&report)?); - } - - Ok(()) -} diff --git a/crates/stealth-bitcoincore/Cargo.toml b/crates/stealth-bitcoincore/Cargo.toml deleted file mode 100644 index 222222a..0000000 --- a/crates/stealth-bitcoincore/Cargo.toml +++ /dev/null @@ -1,14 +0,0 @@ -[package] -name = "stealth-bitcoincore" -version.workspace = true -edition.workspace = true -license.workspace = true - -[dependencies] -reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] } -ini = { package = "rust-ini", version = "0.21" } -serde.workspace = true -serde_json.workspace = true -stealth-core = { path = "../stealth-core" } -thiserror.workspace = true -urlencoding = "2.1" diff --git a/crates/stealth-core/Cargo.toml b/crates/stealth-core/Cargo.toml deleted file mode 100644 index 466f94b..0000000 --- a/crates/stealth-core/Cargo.toml +++ /dev/null @@ -1,10 +0,0 @@ -[package] -name = "stealth-core" -version.workspace = true -edition.workspace = true -license.workspace = true - -[dependencies] -serde.workspace = true -serde_json.workspace = true -thiserror.workspace = true diff --git a/crates/stealth-core/src/detectors.rs b/crates/stealth-core/src/detectors.rs deleted file mode 100644 index 0696cd5..0000000 --- a/crates/stealth-core/src/detectors.rs +++ /dev/null @@ -1,1042 +0,0 @@ -use std::collections::HashSet; - -use serde_json::json; - -use crate::config::{AnalysisConfig, DetectorId}; -use crate::graph::{TxGraph, btc_to_sats}; -use crate::model::{ - Finding, FindingDetails, FindingKind, Severity, TransactionParticipant, Warning, - WarningDetails, WarningKind, -}; - -#[derive(Debug, Default, Clone, PartialEq)] -pub struct DetectorResult { - pub findings: Vec, - pub warnings: Vec, -} - -impl DetectorResult { - pub fn extend(&mut self, other: Self) { - self.findings.extend(other.findings); - self.warnings.extend(other.warnings); - } -} - -pub struct DetectorContext<'a> { - pub graph: &'a TxGraph, - pub config: &'a AnalysisConfig, - pub known_exchange_txids: &'a HashSet, - pub known_risky_txids: &'a HashSet, -} - -pub fn run_all(ctx: &DetectorContext<'_>) -> DetectorResult { - let mut result = DetectorResult::default(); - let enabled = &ctx.config.enabled_detectors; - - for (detector, run) in [ - ( - DetectorId::AddressReuse, - detect_address_reuse as fn(&DetectorContext<'_>) -> DetectorResult, - ), - (DetectorId::Cioh, detect_cioh), - (DetectorId::Dust, detect_dust), - (DetectorId::DustSpending, detect_dust_spending), - (DetectorId::ChangeDetection, detect_change_detection), - (DetectorId::Consolidation, detect_consolidation), - (DetectorId::ScriptTypeMixing, detect_script_type_mixing), - (DetectorId::ClusterMerge, detect_cluster_merge), - (DetectorId::UtxoAgeSpread, detect_utxo_age_spread), - (DetectorId::ExchangeOrigin, detect_exchange_origin), - (DetectorId::TaintedUtxoMerge, detect_tainted_utxo_merge), - ( - DetectorId::BehavioralFingerprint, - detect_behavioral_fingerprint, - ), - ] { - if enabled.contains(&detector) { - result.extend(run(ctx)); - } - } - - result -} - -pub fn detect_address_reuse(ctx: &DetectorContext<'_>) -> DetectorResult { - let mut findings = Vec::new(); - - for address in ctx.graph.addresses() { - let receive_txids: Vec<_> = ctx - .graph - .wallet_entries(&address.address) - .iter() - .filter(|entry| matches!(entry.category, crate::model::WalletTxCategory::Receive)) - .map(|entry| entry.txid.clone()) - .collect(); - let distinct: HashSet<_> = receive_txids.into_iter().collect(); - if distinct.len() < 2 { - continue; - } - - let mut txids = distinct - .iter() - .map(|txid| { - let confirmations = ctx - .graph - .tx(txid) - .map(|tx| tx.confirmations) - .unwrap_or_default(); - json!({ - "txid": txid, - "confirmations": confirmations, - }) - }) - .collect::>(); - txids.sort_by(|left, right| left["txid"].as_str().cmp(&right["txid"].as_str())); - - findings.push(finding( - FindingKind::AddressReuse, - Severity::High, - format!( - "Address {} ({}) reused across {} transactions", - address.address, - ctx.graph.address_role(&address.address), - distinct.len() - ), - json!({ - "address": address.address, - "role": ctx.graph.address_role(&address.address), - "tx_count": distinct.len(), - "txids": txids, - }), - Some("Generate a fresh address for each receipt and avoid static reuse.".into()), - )); - } - - DetectorResult { - findings, - warnings: Vec::new(), - } -} - -pub fn detect_cioh(ctx: &DetectorContext<'_>) -> DetectorResult { - let mut findings = Vec::new(); - - for txid in ctx.graph.our_txids() { - let Some(tx) = ctx.graph.tx(txid) else { - continue; - }; - if tx.vin.len() < 2 { - continue; - } - - let inputs = ctx.graph.input_participants(txid); - if inputs.len() < 2 { - continue; - } - - let our_inputs: Vec<_> = inputs.iter().filter(|input| input.is_ours).collect(); - if our_inputs.len() < 2 { - continue; - } - let external_inputs = inputs.len() - our_inputs.len(); - let ownership_pct = (our_inputs.len() as f64 / inputs.len() as f64 * 100.0).round() as u64; - let severity = if our_inputs.len() == inputs.len() { - Severity::Critical - } else { - Severity::High - }; - - findings.push(finding( - FindingKind::Cioh, - severity, - format!( - "TX {txid} merges {}/{} of your inputs ({}% ownership)", - our_inputs.len(), - inputs.len(), - ownership_pct - ), - json!({ - "txid": txid, - "total_inputs": inputs.len(), - "our_inputs": our_inputs.len(), - "external_inputs": external_inputs, - "ownership_pct": ownership_pct, - "our_addresses": our_inputs.iter().map(|input| json!({ - "address": input.address, - "role": ctx.graph.address_role(&input.address), - "amount_btc": round_btc(input.value_btc), - })).collect::>(), - }), - Some( - "Use coin control or collaborative spending tools to avoid linking inputs.".into(), - ), - )); - } - - DetectorResult { - findings, - warnings: Vec::new(), - } -} - -pub fn detect_dust(ctx: &DetectorContext<'_>) -> DetectorResult { - let dust_sats = ctx.config.thresholds.dust_sats; - let strict_dust_sats = ctx.config.thresholds.strict_dust_sats; - let mut findings = Vec::new(); - - let current = ctx - .graph - .utxos() - .iter() - .filter(|utxo| { - ctx.graph.is_ours(&utxo.address) && btc_to_sats(utxo.amount_btc) <= dust_sats - }) - .collect::>(); - - for utxo in ¤t { - let sats = btc_to_sats(utxo.amount_btc); - let label = if sats <= strict_dust_sats { - "STRICT_DUST" - } else { - "dust-class" - }; - findings.push(finding( - FindingKind::Dust, - if sats <= strict_dust_sats { - Severity::High - } else { - Severity::Medium - }, - format!( - "Dust UTXO at {} ({} sats, {}, unspent)", - utxo.address, sats, label - ), - json!({ - "status": "unspent", - "address": utxo.address, - "sats": sats, - "label": label, - "txid": utxo.txid, - "vout": utxo.vout, - }), - Some("Freeze dust UTXOs instead of spending them alongside normal funds.".into()), - )); - } - - let current_keys = current - .iter() - .map(|utxo| (utxo.txid.clone(), utxo.address.clone())) - .collect::>(); - let mut historical_seen = HashSet::new(); - - for txid in ctx.graph.our_txids() { - for output in ctx.graph.output_participants(txid) { - if !output.is_ours || output.value_sats > dust_sats { - continue; - } - let key = (txid.clone(), output.address.clone()); - if current_keys.contains(&key) || !historical_seen.insert(key.clone()) { - continue; - } - findings.push(finding( - FindingKind::Dust, - Severity::Low, - format!( - "Historical dust output at {} ({} sats, already spent)", - output.address, output.value_sats - ), - json!({ - "status": "spent", - "address": output.address, - "sats": output.value_sats, - "txid": txid, - }), - Some("Reject unsolicited dust or isolate it before spending.".into()), - )); - } - } - - DetectorResult { - findings, - warnings: Vec::new(), - } -} - -pub fn detect_dust_spending(ctx: &DetectorContext<'_>) -> DetectorResult { - let dust_sats = ctx.config.thresholds.dust_sats; - let normal_min = ctx.config.thresholds.normal_input_min_sats; - let mut findings = Vec::new(); - - for txid in ctx.graph.our_txids() { - let inputs = ctx.graph.input_participants(txid); - if inputs.len() < 2 { - continue; - } - - let mut dust_inputs = Vec::new(); - let mut normal_inputs = Vec::new(); - - for input in inputs.iter().filter(|input| input.is_ours) { - if input.value_sats <= dust_sats { - dust_inputs.push(input); - } else if input.value_sats > normal_min { - normal_inputs.push(input); - } - } - - if dust_inputs.is_empty() || normal_inputs.is_empty() { - continue; - } - - findings.push(finding( - FindingKind::DustSpending, - Severity::High, - format!( - "TX {txid} spends {} dust input(s) alongside {} normal input(s)", - dust_inputs.len(), - normal_inputs.len() - ), - json!({ - "txid": txid, - "dust_inputs": dust_inputs.iter().map(|input| json!({ - "address": input.address, - "sats": input.value_sats, - })).collect::>(), - "normal_inputs": normal_inputs.iter().map(|input| json!({ - "address": input.address, - "amount_btc": round_btc(input.value_btc), - })).collect::>(), - }), - Some("Do not combine dust with normal inputs in the same spend.".into()), - )); - } - - DetectorResult { - findings, - warnings: Vec::new(), - } -} - -pub fn detect_change_detection(ctx: &DetectorContext<'_>) -> DetectorResult { - let mut findings = Vec::new(); - - for txid in ctx.graph.our_txids() { - let outputs = ctx.graph.output_participants(txid); - if outputs.len() < 2 { - continue; - } - let inputs = ctx.graph.input_participants(txid); - let our_inputs: Vec<_> = inputs.iter().filter(|input| input.is_ours).collect(); - if our_inputs.is_empty() { - continue; - } - - let our_outputs: Vec<_> = outputs.iter().filter(|output| output.is_ours).collect(); - let external_outputs: Vec<_> = outputs.iter().filter(|output| !output.is_ours).collect(); - if our_outputs.is_empty() || external_outputs.is_empty() { - continue; - } - - let input_types = our_inputs - .iter() - .map(|input| input.script_type) - .collect::>(); - let mut reasons = Vec::new(); - - for change in &our_outputs { - let change_round = is_round_amount(change.value_sats); - let change_internal = - ctx.graph - .derived_address(&change.address) - .is_some_and(|address| { - address.chain_role == crate::model::DescriptorChainRole::Internal - }); - - for payment in &external_outputs { - if is_round_amount(payment.value_sats) && !change_round { - reasons.push(format!( - "Round payment ({} sats) vs non-round change ({} sats)", - payment.value_sats, change.value_sats - )); - } - - if input_types.contains(&change.script_type) - && change.script_type != payment.script_type - { - reasons.push(format!( - "Change script type ({}) matches inputs and differs from payment ({})", - change.script_type.as_script_name(), - payment.script_type.as_script_name() - )); - } - - if change_internal { - reasons - .push("Change uses an internal (BIP-44 /1/*) derivation path".to_string()); - } - } - } - - if reasons.is_empty() { - continue; - } - - reasons.sort(); - reasons.dedup(); - - findings.push(finding( - FindingKind::ChangeDetection, - Severity::Medium, - format!( - "TX {txid} has identifiable change output(s) ({} heuristic(s) matched)", - reasons.len() - ), - json!({ - "txid": txid, - "reasons": reasons, - "change_outputs": our_outputs.iter().map(|output| json!({ - "address": output.address, - "amount_btc": round_btc(output.value_btc), - })).collect::>(), - }), - Some("Prefer payment construction that avoids trivially identifiable change.".into()), - )); - } - - DetectorResult { - findings, - warnings: Vec::new(), - } -} - -pub fn detect_consolidation(ctx: &DetectorContext<'_>) -> DetectorResult { - let mut findings = Vec::new(); - let min_inputs = ctx.config.thresholds.consolidation_min_inputs; - let max_outputs = ctx.config.thresholds.consolidation_max_outputs; - - for utxo in ctx - .graph - .utxos() - .iter() - .filter(|utxo| ctx.graph.is_ours(&utxo.address)) - { - let Some(parent) = ctx.graph.tx(&utxo.txid) else { - continue; - }; - if parent.vin.len() < min_inputs || parent.vout.len() > max_outputs { - continue; - } - let parent_inputs = ctx.graph.input_participants(&utxo.txid); - let our_parent_inputs = parent_inputs.iter().filter(|input| input.is_ours).count(); - findings.push(finding( - FindingKind::Consolidation, - Severity::Medium, - format!( - "UTXO {}:{} ({:.8} BTC) born from a {}-input consolidation", - utxo.txid, - utxo.vout, - utxo.amount_btc, - parent.vin.len() - ), - json!({ - "txid": utxo.txid, - "vout": utxo.vout, - "amount_btc": round_btc(utxo.amount_btc), - "consolidation_inputs": parent.vin.len(), - "consolidation_outputs": parent.vout.len(), - "our_inputs_in_consolidation": our_parent_inputs, - }), - Some("Avoid large one-shot consolidations unless you can hide the linkage.".into()), - )); - } - - DetectorResult { - findings, - warnings: Vec::new(), - } -} - -pub fn detect_script_type_mixing(ctx: &DetectorContext<'_>) -> DetectorResult { - let mut findings = Vec::new(); - - for txid in ctx.graph.our_txids() { - let inputs = ctx.graph.input_participants(txid); - if inputs.len() < 2 { - continue; - } - let our_inputs = inputs.iter().filter(|input| input.is_ours).count(); - if our_inputs < 2 { - continue; - } - - let mut types = inputs - .iter() - .map(|input| input.script_type) - .filter(|script_type| *script_type != crate::model::DescriptorType::Unknown) - .collect::>(); - types.sort(); - types.dedup(); - - if types.len() < 2 { - continue; - } - - findings.push(finding( - FindingKind::ScriptTypeMixing, - Severity::High, - format!("TX {txid} mixes input script types: {:?}", types), - json!({ - "txid": txid, - "script_types": types.iter().map(|script_type| script_type.as_script_name()).collect::>(), - "inputs": inputs.iter().map(|input| json!({ - "address": input.address, - "script_type": input.script_type.as_script_name(), - "ours": input.is_ours, - })).collect::>(), - }), - Some("Standardize on a single script family per spend.".into()), - )); - } - - DetectorResult { - findings, - warnings: Vec::new(), - } -} - -pub fn detect_cluster_merge(ctx: &DetectorContext<'_>) -> DetectorResult { - let mut findings = Vec::new(); - - for txid in ctx.graph.our_txids() { - let inputs = ctx.graph.input_participants(txid); - if inputs.len() < 2 { - continue; - } - let our_inputs = inputs - .iter() - .filter(|input| input.is_ours) - .collect::>(); - if our_inputs.len() < 2 { - continue; - } - - let mut funding_sources = serde_json::Map::new(); - let mut source_sets = Vec::new(); - - for input in our_inputs { - let Some(parent_txid) = input.funding_txid.as_deref() else { - continue; - }; - let Some(parent_tx) = ctx.graph.tx(parent_txid) else { - continue; - }; - let mut sources = parent_tx - .vin - .iter() - .map(|vin| { - if vin.coinbase { - "coinbase".to_string() - } else { - vin.previous_txid.chars().take(16).collect::() - } - }) - .collect::>(); - sources.sort(); - sources.dedup(); - let source_set = sources.iter().cloned().collect::>(); - source_sets.push(source_set); - funding_sources.insert( - format!( - "{}:{}", - parent_txid.chars().take(16).collect::(), - input.funding_vout.unwrap_or_default() - ), - json!(sources), - ); - } - - let mut merged = false; - for i in 0..source_sets.len() { - for j in (i + 1)..source_sets.len() { - if source_sets[i].is_disjoint(&source_sets[j]) { - merged = true; - } - } - } - - if !merged { - continue; - } - - findings.push(finding( - FindingKind::ClusterMerge, - Severity::High, - format!( - "TX {txid} merges UTXOs from {} different funding chains", - funding_sources.len() - ), - json!({ - "txid": txid, - "funding_sources": funding_sources, - }), - Some("Avoid co-spending UTXOs from unrelated provenance clusters.".into()), - )); - } - - DetectorResult { - findings, - warnings: Vec::new(), - } -} - -pub fn detect_utxo_age_spread(ctx: &DetectorContext<'_>) -> DetectorResult { - let our_utxos = ctx - .graph - .utxos() - .iter() - .filter(|utxo| ctx.graph.is_ours(&utxo.address)) - .collect::>(); - if our_utxos.len() < 2 { - return DetectorResult::default(); - } - - let mut ordered = our_utxos; - ordered.sort_by_key(|utxo| std::cmp::Reverse(utxo.confirmations)); - let oldest = ordered.first().expect("ordered has at least two"); - let newest = ordered.last().expect("ordered has at least two"); - let spread = oldest.confirmations.saturating_sub(newest.confirmations); - - let mut findings = Vec::new(); - let mut warnings = Vec::new(); - - if spread >= ctx.config.thresholds.utxo_age_spread_blocks { - findings.push(finding( - FindingKind::UtxoAgeSpread, - Severity::Low, - format!("UTXO age spread of {spread} blocks between oldest and newest"), - json!({ - "spread_blocks": spread, - "oldest": { - "txid": oldest.txid, - "confirmations": oldest.confirmations, - "amount_btc": round_btc(oldest.amount_btc), - }, - "newest": { - "txid": newest.txid, - "confirmations": newest.confirmations, - "amount_btc": round_btc(newest.amount_btc), - }, - }), - Some("Normalize UTXO ages or isolate long-dormant coins before spending.".into()), - )); - } - - let dormant = ordered - .iter() - .filter(|utxo| utxo.confirmations >= ctx.config.thresholds.dormant_utxo_blocks) - .count(); - if dormant > 0 { - warnings.push(warning( - WarningKind::DormantUtxos, - Severity::Low, - format!( - "{} UTXO(s) have ≥{} confirmations (dormant/hoarded coins pattern)", - dormant, ctx.config.thresholds.dormant_utxo_blocks - ), - json!({ - "count": dormant, - "threshold_blocks": ctx.config.thresholds.dormant_utxo_blocks, - }), - )); - } - - DetectorResult { findings, warnings } -} - -pub fn detect_exchange_origin(ctx: &DetectorContext<'_>) -> DetectorResult { - let mut findings = Vec::new(); - let threshold = ctx.config.thresholds.exchange_batch_outputs; - - for txid in ctx.graph.our_txids() { - let Some(tx) = ctx.graph.tx(txid) else { - continue; - }; - if tx.vout.len() < threshold { - continue; - } - - let our_inputs = ctx - .graph - .input_participants(txid) - .into_iter() - .filter(|input| input.is_ours) - .collect::>(); - if !our_inputs.is_empty() { - continue; - } - let our_outputs = ctx - .graph - .output_participants(txid) - .into_iter() - .filter(|output| output.is_ours) - .collect::>(); - if our_outputs.is_empty() { - continue; - } - - let mut signals = vec![format!("High output count: {}", tx.vout.len())]; - let unique_recipients = tx - .vout - .iter() - .filter(|output| !output.address.is_empty()) - .map(|output| output.address.clone()) - .collect::>(); - if unique_recipients.len() >= threshold { - signals.push(format!( - "{} unique recipient addresses", - unique_recipients.len() - )); - } - if ctx.known_exchange_txids.contains(txid) { - signals.push("TX matches known exchange wallet history".into()); - } - - let inputs = ctx.graph.input_participants(txid); - let input_total = inputs.iter().map(|input| input.value_btc).sum::(); - let mut output_values = tx - .vout - .iter() - .map(|output| output.value_btc) - .collect::>(); - output_values.sort_by(|left, right| left.total_cmp(right)); - if let Some(median) = output_values.get(output_values.len() / 2).copied() { - if median > 0.0 { - let ratio = input_total / median; - if ratio > 10.0 { - signals.push(format!("Input/median-output ratio: {:.0}x", ratio)); - } - } - } - - if signals.len() < 2 { - continue; - } - - findings.push(finding( - FindingKind::ExchangeOrigin, - Severity::Medium, - format!( - "TX {txid} looks like an exchange batch withdrawal ({} signal(s))", - signals.len() - ), - json!({ - "txid": txid, - "signals": signals, - "received_outputs": our_outputs.iter().map(|output| json!({ - "address": output.address, - "amount_btc": round_btc(output.value_btc), - })).collect::>(), - }), - Some( - "Treat exchange withdrawals as linkable entry points and remix before reuse." - .into(), - ), - )); - } - - DetectorResult { - findings, - warnings: Vec::new(), - } -} - -pub fn detect_tainted_utxo_merge(ctx: &DetectorContext<'_>) -> DetectorResult { - if ctx.known_risky_txids.is_empty() { - return DetectorResult::default(); - } - - let mut findings = Vec::new(); - let mut warnings = Vec::new(); - - for txid in ctx.graph.our_txids() { - let inputs = ctx.graph.input_participants(txid); - if inputs.len() < 2 || !inputs.iter().any(|input| input.is_ours) { - continue; - } - - let mut tainted = Vec::new(); - let mut clean = Vec::new(); - for input in &inputs { - let is_tainted = input - .funding_txid - .as_ref() - .is_some_and(|funding_txid| ctx.known_risky_txids.contains(funding_txid)); - if is_tainted { - tainted.push(input); - } else { - clean.push(input); - } - } - - if !tainted.is_empty() && !clean.is_empty() { - let taint_pct = (tainted.len() as f64 / inputs.len() as f64 * 100.0).round() as u64; - findings.push(finding( - FindingKind::TaintedUtxoMerge, - Severity::High, - format!( - "TX {txid} merges {} tainted + {} clean inputs ({}% taint)", - tainted.len(), - clean.len(), - taint_pct - ), - json!({ - "txid": txid, - "tainted_inputs": tainted.iter().map(|input| participant_json(input)).collect::>(), - "clean_inputs": clean.iter().map(|input| participant_json(input)).collect::>(), - "taint_pct": taint_pct, - }), - Some("Keep tainted and clean flows isolated to avoid propagating risk.".into()), - )); - } - } - - for txid in ctx.graph.our_txids() { - if !ctx.known_risky_txids.contains(txid) { - continue; - } - let our_outputs = ctx - .graph - .output_participants(txid) - .into_iter() - .filter(|output| output.is_ours) - .collect::>(); - if our_outputs.is_empty() { - continue; - } - warnings.push(warning( - WarningKind::DirectTaint, - Severity::High, - format!("TX {txid} is directly from a known risky source"), - json!({ - "txid": txid, - "received_outputs": our_outputs.iter().map(|output| json!({ - "address": output.address, - "amount_btc": round_btc(output.value_btc), - })).collect::>(), - }), - )); - } - - DetectorResult { findings, warnings } -} - -pub fn detect_behavioral_fingerprint(ctx: &DetectorContext<'_>) -> DetectorResult { - let send_txids = ctx - .graph - .our_txids() - .filter(|txid| { - ctx.graph - .input_participants(txid) - .iter() - .any(|input| input.is_ours) - }) - .cloned() - .collect::>(); - if send_txids.len() < 3 { - return DetectorResult::default(); - } - - 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::new(); - let mut n_inputs = Vec::new(); - let mut total_payments = 0usize; - let mut round_payments = 0usize; - let mut change_types = HashSet::new(); - let mut payment_types = HashSet::new(); - - for txid in &send_txids { - let Some(tx) = ctx.graph.tx(txid) else { - continue; - }; - output_counts.push(tx.vout.len()); - n_inputs.push(tx.vin.len()); - locktime_values.push(tx.locktime); - rbf_signals.extend(tx.vin.iter().map(|vin| vin.sequence < 0xffff_fffe)); - - let inputs = ctx.graph.input_participants(txid); - input_script_types.extend( - inputs - .iter() - .filter(|input| input.is_ours) - .map(|input| input.script_type), - ); - - for output in ctx.graph.output_participants(txid) { - if output.is_ours { - change_types.insert(output.script_type); - } else { - payment_types.insert(output.script_type); - total_payments += 1; - if is_round_amount(output.value_sats) { - round_payments += 1; - } - } - } - - if tx.vsize > 0 { - let in_total = inputs.iter().map(|input| input.value_btc).sum::(); - let out_total = tx.vout.iter().map(|output| output.value_btc).sum::(); - let fee_sats = ((in_total - out_total) * 100_000_000.0).round(); - if fee_sats > 0.0 { - fee_rates.push(fee_sats / tx.vsize as f64); - } - } - } - - let mut patterns = Vec::new(); - - if total_payments > 0 { - let round_pct = round_payments as f64 / total_payments as f64 * 100.0; - if round_pct > 60.0 { - patterns.push(format!( - "Round payment amounts: {:.0}% of payments are round numbers", - round_pct - )); - } - } - - if output_counts.len() >= 3 && output_counts.iter().all(|count| *count == output_counts[0]) { - patterns.push(format!( - "Uniform output count: all {} send TXs have exactly {} outputs", - output_counts.len(), - output_counts[0] - )); - } - - let input_types = input_script_types.iter().copied().collect::>(); - if input_types.len() > 1 { - patterns.push("Mixed input script types used across send transactions".into()); - } else if input_types.len() == 1 && input_types.contains(&crate::model::DescriptorType::P2pkh) { - patterns.push("All inputs use legacy P2PKH".into()); - } - - if !rbf_signals.is_empty() { - let rbf_enabled = rbf_signals.iter().filter(|signal| **signal).count(); - if rbf_enabled == rbf_signals.len() { - patterns.push("RBF always enabled".into()); - } else if rbf_enabled == 0 { - patterns.push("RBF never enabled".into()); - } - } - - if locktime_values.len() >= 3 { - let non_zero = locktime_values.iter().filter(|value| **value > 0).count(); - if non_zero == locktime_values.len() { - patterns.push("Anti-fee-sniping locktime always set".into()); - } else if non_zero == 0 { - patterns.push("Locktime always 0".into()); - } - } - - if fee_rates.len() >= 3 { - let avg = fee_rates.iter().sum::() / fee_rates.len() as f64; - if avg > 0.0 { - let variance = fee_rates - .iter() - .map(|rate| (*rate - avg).powi(2)) - .sum::() - / fee_rates.len() as f64; - let stddev = variance.sqrt(); - let cv = stddev / avg; - if cv < 0.15 { - patterns.push(format!("Very consistent fee rate: avg {:.1} sat/vB", avg)); - } - } - } - - if !change_types.is_empty() && !payment_types.is_empty() && change_types != payment_types { - patterns.push("Change uses different script type than payments".into()); - } - - if n_inputs.len() >= 3 - && n_inputs - .iter() - .all(|count| *count == n_inputs[0] && *count > 1) - { - patterns.push(format!("Always uses exactly {} inputs per TX", n_inputs[0])); - } - - if patterns.is_empty() { - return DetectorResult::default(); - } - - DetectorResult { - findings: vec![finding( - FindingKind::BehavioralFingerprint, - Severity::Medium, - format!( - "Behavioral fingerprint detected across {} send transactions ({} pattern(s))", - send_txids.len(), - patterns.len() - ), - json!({ - "send_tx_count": send_txids.len(), - "patterns": patterns, - }), - Some( - "Vary spend structure and standardize wallet defaults to reduce fingerprinting." - .into(), - ), - )], - warnings: Vec::new(), - } -} - -fn finding( - kind: FindingKind, - severity: Severity, - description: String, - details: serde_json::Value, - correction: Option, -) -> Finding { - Finding { - kind, - severity, - description, - details: FindingDetails::Generic(details), - correction, - } -} - -fn warning( - kind: WarningKind, - severity: Severity, - description: String, - details: serde_json::Value, -) -> Warning { - Warning { - kind, - severity, - description, - details: WarningDetails::Generic(details), - } -} - -fn participant_json(participant: &TransactionParticipant) -> serde_json::Value { - json!({ - "address": participant.address, - "amount_btc": round_btc(participant.value_btc), - "source_txid": participant.funding_txid, - }) -} - -fn round_btc(value: f64) -> f64 { - (value * 100_000_000.0).round() / 100_000_000.0 -} - -fn is_round_amount(sats: u64) -> bool { - sats > 0 && (sats % 100_000 == 0 || sats % 1_000_000 == 0) -} diff --git a/crates/stealth-core/src/engine.rs b/crates/stealth-core/src/engine.rs deleted file mode 100644 index 0b39c9b..0000000 --- a/crates/stealth-core/src/engine.rs +++ /dev/null @@ -1,122 +0,0 @@ -use crate::config::AnalysisConfig; -use crate::descriptor::normalize_descriptors; -use crate::detectors::{DetectorContext, run_all}; -use crate::error::AnalysisError; -use crate::gateway::BlockchainGateway; -use crate::graph::TxGraph; -use crate::model::{ - AnalysisReport, DerivedAddress, DescriptorChainRole, DescriptorType, ResolvedDescriptor, -}; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct EngineSettings { - pub analysis: AnalysisConfig, - pub known_exchange_wallets: Vec, - pub known_risky_wallets: Vec, -} - -impl Default for EngineSettings { - fn default() -> Self { - Self { - analysis: AnalysisConfig::default(), - known_exchange_wallets: Vec::new(), - known_risky_wallets: Vec::new(), - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum ScanTarget { - Descriptors(Vec), - WalletName(String), -} - -pub struct AnalysisEngine<'a, G> { - gateway: &'a G, - settings: EngineSettings, -} - -impl<'a, G> AnalysisEngine<'a, G> -where - G: BlockchainGateway, -{ - pub fn new(gateway: &'a G, settings: EngineSettings) -> Self { - Self { gateway, settings } - } - - pub fn analyze(&self, target: ScanTarget) -> Result { - let (descriptors, history) = match target { - ScanTarget::Descriptors(raw_descriptors) => { - if raw_descriptors.is_empty() { - return Err(AnalysisError::EmptyDescriptor); - } - let descriptors = normalize_descriptors( - &raw_descriptors, - self.settings.analysis.derivation_range_end, - self.gateway, - )?; - let history = self.gateway.scan_descriptors(&descriptors)?; - (descriptors, history) - } - ScanTarget::WalletName(wallet_name) => { - let descriptors = self.gateway.list_wallet_descriptors(&wallet_name)?; - let history = self.gateway.scan_wallet(&wallet_name)?; - (descriptors, history) - } - }; - - if history.wallet_txs.is_empty() { - return Err(AnalysisError::AnalysisEmpty); - } - - let derived_addresses = self.derive_all_addresses(&descriptors)?; - let graph = TxGraph::new(derived_addresses.clone(), history); - let known_exchange_txids = self - .gateway - .known_wallet_txids(&self.settings.known_exchange_wallets)?; - let known_risky_txids = self - .gateway - .known_wallet_txids(&self.settings.known_risky_wallets)?; - - let detector_result = run_all(&DetectorContext { - graph: &graph, - config: &self.settings.analysis, - known_exchange_txids: &known_exchange_txids, - known_risky_txids: &known_risky_txids, - }); - - Ok(AnalysisReport::new( - graph.our_txids().count(), - derived_addresses.len(), - detector_result.findings, - detector_result.warnings, - )) - } - - fn derive_all_addresses( - &self, - descriptors: &[ResolvedDescriptor], - ) -> Result, AnalysisError> { - let mut addresses = Vec::new(); - - for descriptor in descriptors { - let descriptor_type = DescriptorType::from_descriptor(&descriptor.desc); - let chain_role = if descriptor.internal { - DescriptorChainRole::Internal - } else { - DescriptorChainRole::External - }; - let derived = self.gateway.derive_addresses(descriptor)?; - addresses.extend(derived.into_iter().enumerate().map(|(index, address)| { - DerivedAddress { - address, - descriptor_type, - chain_role, - derivation_index: index as u32, - } - })); - } - - Ok(addresses) - } -} diff --git a/crates/stealth-core/src/gateway.rs b/crates/stealth-core/src/gateway.rs deleted file mode 100644 index 6cecbed..0000000 --- a/crates/stealth-core/src/gateway.rs +++ /dev/null @@ -1,33 +0,0 @@ -use std::collections::HashSet; - -use crate::descriptor::DescriptorNormalizer; -use crate::error::AnalysisError; -use crate::model::{ResolvedDescriptor, WalletHistory}; - -pub trait BlockchainGateway { - fn normalize_descriptor(&self, descriptor: &str) -> Result; - fn derive_addresses( - &self, - descriptor: &ResolvedDescriptor, - ) -> Result, AnalysisError>; - fn scan_descriptors( - &self, - descriptors: &[ResolvedDescriptor], - ) -> Result; - fn list_wallet_descriptors( - &self, - wallet_name: &str, - ) -> Result, AnalysisError>; - fn scan_wallet(&self, wallet_name: &str) -> Result; - fn known_wallet_txids(&self, wallet_names: &[String]) - -> Result, AnalysisError>; -} - -impl DescriptorNormalizer for T -where - T: BlockchainGateway + ?Sized, -{ - fn normalize(&self, descriptor: &str) -> Result { - self.normalize_descriptor(descriptor) - } -} diff --git a/crates/stealth-core/src/graph.rs b/crates/stealth-core/src/graph.rs deleted file mode 100644 index 3424484..0000000 --- a/crates/stealth-core/src/graph.rs +++ /dev/null @@ -1,151 +0,0 @@ -use std::collections::{HashMap, HashSet}; - -use crate::model::{ - DecodedTransaction, DerivedAddress, DescriptorChainRole, DescriptorType, - TransactionParticipant, TxOutput, Utxo, WalletHistory, WalletTxEntry, -}; - -#[derive(Debug, Clone)] -pub struct TxGraph { - addresses: HashMap, - our_addrs: HashSet, - history: WalletHistory, - addr_txs: HashMap>, - tx_addrs: HashMap>, - our_txids: HashSet, -} - -impl TxGraph { - pub fn new(addresses: Vec, history: WalletHistory) -> Self { - let mut address_map = HashMap::new(); - let mut our_addrs = HashSet::new(); - let mut addr_txs: HashMap> = HashMap::new(); - let mut tx_addrs: HashMap> = HashMap::new(); - let mut our_txids = HashSet::new(); - - for address in addresses { - our_addrs.insert(address.address.clone()); - address_map.insert(address.address.clone(), address); - } - - for entry in &history.wallet_txs { - our_txids.insert(entry.txid.clone()); - if !entry.address.is_empty() { - addr_txs - .entry(entry.address.clone()) - .or_default() - .push(entry.clone()); - tx_addrs - .entry(entry.txid.clone()) - .or_default() - .insert(entry.address.clone()); - } - } - - Self { - addresses: address_map, - our_addrs, - history, - addr_txs, - tx_addrs, - our_txids, - } - } - - pub fn addresses(&self) -> impl Iterator { - self.addresses.values() - } - - pub fn derived_address(&self, address: &str) -> Option<&DerivedAddress> { - self.addresses.get(address) - } - - pub fn wallet_entries(&self, address: &str) -> &[WalletTxEntry] { - self.addr_txs.get(address).map(Vec::as_slice).unwrap_or(&[]) - } - - pub fn tx_addrs(&self, txid: &str) -> Option<&HashSet> { - self.tx_addrs.get(txid) - } - - pub fn tx(&self, txid: &str) -> Option<&DecodedTransaction> { - self.history.transactions.get(txid) - } - - pub fn our_txids(&self) -> impl Iterator { - self.our_txids.iter() - } - - pub fn utxos(&self) -> &[Utxo] { - &self.history.utxos - } - - pub fn is_ours(&self, address: &str) -> bool { - self.our_addrs.contains(address) - } - - pub fn get_script_type(&self, address: &str) -> DescriptorType { - self.derived_address(address) - .map(|item| item.descriptor_type) - .unwrap_or_else(|| DescriptorType::infer_from_address(address)) - } - - pub fn output_by_outpoint(&self, txid: &str, vout: u32) -> Option<&TxOutput> { - self.tx(txid)?.vout.iter().find(|output| output.n == vout) - } - - pub fn input_participants(&self, txid: &str) -> Vec { - let Some(tx) = self.tx(txid) else { - return Vec::new(); - }; - - tx.vin - .iter() - .filter(|input| !input.coinbase) - .filter_map(|input| { - let previous_output = - self.output_by_outpoint(&input.previous_txid, input.previous_vout)?; - let script_type = self.get_script_type(&previous_output.address); - Some(TransactionParticipant { - address: previous_output.address.clone(), - value_btc: previous_output.value_btc, - value_sats: btc_to_sats(previous_output.value_btc), - script_type, - is_ours: self.is_ours(&previous_output.address), - funding_txid: Some(input.previous_txid.clone()), - funding_vout: Some(input.previous_vout), - }) - }) - .collect() - } - - pub fn output_participants(&self, txid: &str) -> Vec { - let Some(tx) = self.tx(txid) else { - return Vec::new(); - }; - - tx.vout - .iter() - .map(|output| TransactionParticipant { - address: output.address.clone(), - value_btc: output.value_btc, - value_sats: btc_to_sats(output.value_btc), - script_type: self.get_script_type(&output.address), - is_ours: self.is_ours(&output.address), - funding_txid: Some(txid.to_string()), - funding_vout: Some(output.n), - }) - .collect() - } - - pub fn address_role(&self, address: &str) -> &'static str { - match self.derived_address(address).map(|item| item.chain_role) { - Some(DescriptorChainRole::Internal) => "change", - _ => "receive", - } - } -} - -pub fn btc_to_sats(value_btc: f64) -> u64 { - (value_btc * 100_000_000.0).round() as u64 -} diff --git a/crates/stealth-core/src/lib.rs b/crates/stealth-core/src/lib.rs deleted file mode 100644 index cd8253b..0000000 --- a/crates/stealth-core/src/lib.rs +++ /dev/null @@ -1,80 +0,0 @@ -pub mod config; -pub mod descriptor; -pub mod detectors; -pub mod engine; -pub mod error; -pub mod gateway; -pub mod graph; -pub mod model; - -#[cfg(test)] -mod tests { - use crate::descriptor::{DescriptorNormalizer, normalize_descriptors}; - use crate::model::{ - AnalysisReport, Finding, FindingDetails, FindingKind, Severity, Warning, WarningDetails, - WarningKind, - }; - use serde_json::json; - - #[test] - fn normalizes_checksums_and_infers_change_descriptor_pair() { - struct RecordingNormalizer; - - impl DescriptorNormalizer for RecordingNormalizer { - fn normalize(&self, descriptor: &str) -> Result { - Ok(format!("normalized:{descriptor}")) - } - } - - let normalized = normalize_descriptors( - &[String::from("wpkh([abcd/84h/1h/0h]tpub123/0/*)#checksum")], - 777, - &RecordingNormalizer, - ) - .expect("descriptor normalization should succeed"); - - assert_eq!(normalized.len(), 2); - assert_eq!( - normalized[0].desc, - "normalized:wpkh([abcd/84h/1h/0h]tpub123/0/*)" - ); - assert!(!normalized[0].internal); - assert_eq!( - normalized[1].desc, - "normalized:wpkh([abcd/84h/1h/0h]tpub123/1/*)" - ); - assert!(normalized[1].internal); - assert_eq!(normalized[1].range_end, 777); - } - - #[test] - fn report_summary_tracks_clean_state_and_counts() { - let finding = Finding { - kind: FindingKind::AddressReuse, - severity: Severity::High, - description: "address reused".into(), - details: FindingDetails::Generic(json!({"address":"bcrt1qexample"})), - correction: Some("use a fresh address".into()), - }; - let warning = Warning { - kind: WarningKind::DormantUtxos, - severity: Severity::Low, - description: "dormant coins".into(), - details: WarningDetails::Generic(json!({"count":1})), - }; - - let report = AnalysisReport::new(12, 34, vec![finding], vec![warning]); - - assert_eq!(report.summary.findings, 1); - assert_eq!(report.summary.warnings, 1); - assert!(!report.summary.clean); - assert_eq!(report.stats.transactions_analyzed, 12); - assert_eq!(report.stats.addresses_derived, 34); - } - - #[test] - fn empty_report_is_marked_clean() { - let report = AnalysisReport::new(0, 0, Vec::new(), Vec::new()); - assert!(report.summary.clean); - } -} diff --git a/crates/stealth-core/src/model.rs b/crates/stealth-core/src/model.rs deleted file mode 100644 index 51a4759..0000000 --- a/crates/stealth-core/src/model.rs +++ /dev/null @@ -1,267 +0,0 @@ -use serde::{Deserialize, Serialize}; -use serde_json::Value; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "SCREAMING_SNAKE_CASE")] -pub enum Severity { - Low, - Medium, - High, - Critical, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[serde(rename_all = "SCREAMING_SNAKE_CASE")] -pub enum FindingKind { - AddressReuse, - Cioh, - Dust, - DustSpending, - ChangeDetection, - Consolidation, - ScriptTypeMixing, - ClusterMerge, - UtxoAgeSpread, - ExchangeOrigin, - TaintedUtxoMerge, - BehavioralFingerprint, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[serde(rename_all = "SCREAMING_SNAKE_CASE")] -pub enum WarningKind { - DormantUtxos, - DirectTaint, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(untagged)] -pub enum FindingDetails { - Generic(Value), -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(untagged)] -pub enum WarningDetails { - Generic(Value), -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct Finding { - #[serde(rename = "type")] - pub kind: FindingKind, - pub severity: Severity, - pub description: String, - pub details: FindingDetails, - #[serde(skip_serializing_if = "Option::is_none")] - pub correction: Option, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct Warning { - #[serde(rename = "type")] - pub kind: WarningKind, - pub severity: Severity, - pub description: String, - pub details: WarningDetails, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct AnalysisStats { - pub transactions_analyzed: usize, - pub addresses_derived: usize, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct AnalysisSummary { - pub findings: usize, - pub warnings: usize, - pub clean: bool, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct AnalysisReport { - pub stats: AnalysisStats, - pub findings: Vec, - pub warnings: Vec, - pub summary: AnalysisSummary, -} - -impl AnalysisReport { - pub fn new( - transactions_analyzed: usize, - addresses_derived: usize, - findings: Vec, - warnings: Vec, - ) -> Self { - let summary = AnalysisSummary { - findings: findings.len(), - warnings: warnings.len(), - clean: findings.is_empty() && warnings.is_empty(), - }; - - Self { - stats: AnalysisStats { - transactions_analyzed, - addresses_derived, - }, - findings, - warnings, - summary, - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum DescriptorChainRole { - External, - Internal, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum DescriptorType { - P2wpkh, - P2tr, - P2shP2wpkh, - P2pkh, - Unknown, -} - -impl DescriptorType { - pub fn from_descriptor(descriptor: &str) -> Self { - if descriptor.starts_with("wpkh(") { - Self::P2wpkh - } else if descriptor.starts_with("tr(") { - Self::P2tr - } else if descriptor.starts_with("sh(wpkh(") { - Self::P2shP2wpkh - } else if descriptor.starts_with("pkh(") { - Self::P2pkh - } else { - Self::Unknown - } - } - - pub fn infer_from_address(address: &str) -> Self { - if address.starts_with("bc1q") - || address.starts_with("tb1q") - || address.starts_with("bcrt1q") - { - Self::P2wpkh - } else if address.starts_with("bc1p") - || address.starts_with("tb1p") - || address.starts_with("bcrt1p") - { - Self::P2tr - } else if address.starts_with('2') || address.starts_with('3') { - Self::P2shP2wpkh - } else if address.starts_with('1') || address.starts_with('m') || address.starts_with('n') { - Self::P2pkh - } else { - Self::Unknown - } - } - - pub fn as_script_name(self) -> &'static str { - match self { - Self::P2wpkh => "witness_v0_keyhash", - Self::P2tr => "witness_v1_taproot", - Self::P2shP2wpkh => "scripthash", - Self::P2pkh => "pubkeyhash", - Self::Unknown => "unknown", - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct DerivedAddress { - pub address: String, - pub descriptor_type: DescriptorType, - pub chain_role: DescriptorChainRole, - pub derivation_index: u32, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct ResolvedDescriptor { - pub desc: String, - pub internal: bool, - pub active: bool, - pub range_end: u32, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum WalletTxCategory { - Send, - Receive, - Unknown, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct WalletTxEntry { - pub txid: String, - pub address: String, - pub category: WalletTxCategory, - pub amount_btc: f64, - pub confirmations: u32, - pub blockheight: u32, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct TxInputRef { - #[serde(rename = "txid")] - pub previous_txid: String, - #[serde(rename = "vout")] - pub previous_vout: u32, - pub sequence: u32, - pub coinbase: bool, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct TxOutput { - pub n: u32, - pub address: String, - pub value_btc: f64, - pub script_type: DescriptorType, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct DecodedTransaction { - pub txid: String, - pub vin: Vec, - pub vout: Vec, - pub version: i32, - pub locktime: u32, - pub vsize: u32, - pub confirmations: u32, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct Utxo { - pub txid: String, - pub vout: u32, - pub address: String, - pub amount_btc: f64, - pub confirmations: u32, - pub script_type: DescriptorType, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct WalletHistory { - pub wallet_txs: Vec, - pub utxos: Vec, - pub transactions: std::collections::HashMap, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct TransactionParticipant { - pub address: String, - pub value_btc: f64, - pub value_sats: u64, - pub script_type: DescriptorType, - pub is_ours: bool, - pub funding_txid: Option, - pub funding_vout: Option, -} diff --git a/crates/stealth-core/tests/detectors.rs b/crates/stealth-core/tests/detectors.rs deleted file mode 100644 index 6cde82e..0000000 --- a/crates/stealth-core/tests/detectors.rs +++ /dev/null @@ -1,689 +0,0 @@ -use std::collections::{HashMap, HashSet}; - -use stealth_core::config::AnalysisConfig; -use stealth_core::detectors::{ - DetectorContext, detect_address_reuse, detect_behavioral_fingerprint, detect_change_detection, - detect_cioh, detect_cluster_merge, detect_consolidation, detect_dust, detect_dust_spending, - detect_exchange_origin, detect_script_type_mixing, detect_tainted_utxo_merge, - detect_utxo_age_spread, -}; -use stealth_core::graph::TxGraph; -use stealth_core::model::{ - DecodedTransaction, DerivedAddress, DescriptorChainRole, DescriptorType, FindingKind, - TxInputRef, TxOutput, Utxo, WalletHistory, WalletTxCategory, WalletTxEntry, WarningKind, -}; - -fn satoshis(value: u64) -> f64 { - value as f64 / 100_000_000.0 -} - -fn our_address( - address: &str, - descriptor_type: DescriptorType, - chain_role: DescriptorChainRole, -) -> DerivedAddress { - DerivedAddress { - address: address.to_string(), - descriptor_type, - chain_role, - derivation_index: 0, - } -} - -fn wallet_entry( - txid: &str, - address: &str, - category: WalletTxCategory, - sats: u64, - confirmations: u32, -) -> WalletTxEntry { - WalletTxEntry { - txid: txid.to_string(), - address: address.to_string(), - category, - amount_btc: satoshis(sats), - confirmations, - blockheight: 0, - } -} - -fn tx( - txid: &str, - vin: Vec, - vout: Vec, - confirmations: u32, -) -> DecodedTransaction { - DecodedTransaction { - txid: txid.to_string(), - vin, - vout, - version: 2, - locktime: 0, - vsize: 200, - confirmations, - } -} - -fn input(previous_txid: &str, previous_vout: u32) -> TxInputRef { - TxInputRef { - previous_txid: previous_txid.to_string(), - previous_vout, - sequence: 0xffff_fffd, - coinbase: false, - } -} - -fn output(n: u32, address: &str, sats: u64, script_type: DescriptorType) -> TxOutput { - TxOutput { - n, - address: address.to_string(), - value_btc: satoshis(sats), - script_type, - } -} - -fn utxo( - txid: &str, - vout: u32, - address: &str, - sats: u64, - confirmations: u32, - script_type: DescriptorType, -) -> Utxo { - Utxo { - txid: txid.to_string(), - vout, - address: address.to_string(), - amount_btc: satoshis(sats), - confirmations, - script_type, - } -} - -fn graph( - addresses: Vec, - wallet_txs: Vec, - utxos: Vec, - transactions: Vec, -) -> TxGraph { - let history = WalletHistory { - wallet_txs, - utxos, - transactions: transactions - .into_iter() - .map(|item| (item.txid.clone(), item)) - .collect::>(), - }; - TxGraph::new(addresses, history) -} - -fn context<'a>( - graph: &'a TxGraph, - config: &'a AnalysisConfig, - known_exchange_txids: &'a HashSet, - known_risky_txids: &'a HashSet, -) -> DetectorContext<'a> { - DetectorContext { - graph, - config, - known_exchange_txids, - known_risky_txids, - } -} - -#[test] -fn address_reuse_is_detected() { - let receive = our_address( - "bcrt1qreceive", - DescriptorType::P2wpkh, - DescriptorChainRole::External, - ); - let graph = graph( - vec![receive.clone()], - vec![ - wallet_entry( - "reuse-1", - &receive.address, - WalletTxCategory::Receive, - 1_000_000, - 6, - ), - wallet_entry( - "reuse-2", - &receive.address, - WalletTxCategory::Receive, - 2_000_000, - 5, - ), - ], - Vec::new(), - vec![ - tx( - "reuse-1", - Vec::new(), - vec![output( - 0, - &receive.address, - 1_000_000, - DescriptorType::P2wpkh, - )], - 6, - ), - tx( - "reuse-2", - Vec::new(), - vec![output( - 0, - &receive.address, - 2_000_000, - DescriptorType::P2wpkh, - )], - 5, - ), - ], - ); - - let config = AnalysisConfig::default(); - let known_exchange = HashSet::new(); - let known_risky = HashSet::new(); - let findings = - detect_address_reuse(&context(&graph, &config, &known_exchange, &known_risky)).findings; - assert_eq!(findings.len(), 1); - assert_eq!(findings[0].kind, FindingKind::AddressReuse); -} - -#[test] -fn dust_current_and_historical_are_detected() { - let strict = our_address( - "bcrt1qdust", - DescriptorType::P2wpkh, - DescriptorChainRole::External, - ); - let spent = our_address( - "bcrt1qspent", - DescriptorType::P2wpkh, - DescriptorChainRole::External, - ); - let graph = graph( - vec![strict.clone(), spent.clone()], - vec![ - wallet_entry( - "dust-live", - &strict.address, - WalletTxCategory::Receive, - 546, - 3, - ), - wallet_entry( - "dust-spent", - &spent.address, - WalletTxCategory::Receive, - 1_000, - 2, - ), - ], - vec![utxo( - "dust-live", - 0, - &strict.address, - 546, - 3, - DescriptorType::P2wpkh, - )], - vec![ - tx( - "dust-live", - Vec::new(), - vec![output(0, &strict.address, 546, DescriptorType::P2wpkh)], - 3, - ), - tx( - "dust-spent", - Vec::new(), - vec![output(0, &spent.address, 1_000, DescriptorType::P2wpkh)], - 2, - ), - ], - ); - - let config = AnalysisConfig::default(); - let known_exchange = HashSet::new(); - let known_risky = HashSet::new(); - let findings = detect_dust(&context(&graph, &config, &known_exchange, &known_risky)).findings; - assert_eq!(findings.len(), 2); - assert!( - findings - .iter() - .any(|finding| finding.kind == FindingKind::Dust) - ); -} - -#[test] -fn multi_input_heuristics_are_detected() { - let a = our_address( - "bcrt1qin1", - DescriptorType::P2wpkh, - DescriptorChainRole::External, - ); - let b = our_address( - "bcrt1qin2", - DescriptorType::P2tr, - DescriptorChainRole::External, - ); - let change = our_address( - "bcrt1qchange", - DescriptorType::P2wpkh, - DescriptorChainRole::Internal, - ); - let transactions = vec![ - tx( - "fund-a", - vec![input("bob-parent", 0)], - vec![output(0, &a.address, 50_000, DescriptorType::P2wpkh)], - 10, - ), - tx( - "fund-b", - vec![input("carol-parent", 0)], - vec![output(0, &b.address, 60_000, DescriptorType::P2tr)], - 10, - ), - tx( - "spend", - vec![input("fund-a", 0), input("fund-b", 0)], - vec![ - output(0, "mipcPayment", 1_000_000, DescriptorType::P2pkh), - output(1, &change.address, 10_345, DescriptorType::P2wpkh), - ], - 2, - ), - ]; - let graph = graph( - vec![a.clone(), b.clone(), change.clone()], - vec![ - wallet_entry("fund-a", &a.address, WalletTxCategory::Receive, 50_000, 10), - wallet_entry("fund-b", &b.address, WalletTxCategory::Receive, 60_000, 10), - wallet_entry("spend", &change.address, WalletTxCategory::Send, 10_345, 2), - ], - vec![utxo( - "spend", - 1, - &change.address, - 10_345, - 2, - DescriptorType::P2wpkh, - )], - transactions, - ); - - let config = AnalysisConfig::default(); - let known_exchange = HashSet::new(); - let known_risky = HashSet::new(); - let ctx = context(&graph, &config, &known_exchange, &known_risky); - assert_eq!(detect_cioh(&ctx).findings[0].kind, FindingKind::Cioh); - assert_eq!( - detect_change_detection(&ctx).findings[0].kind, - FindingKind::ChangeDetection - ); - assert_eq!( - detect_script_type_mixing(&ctx).findings[0].kind, - FindingKind::ScriptTypeMixing - ); - assert_eq!( - detect_cluster_merge(&ctx).findings[0].kind, - FindingKind::ClusterMerge - ); -} - -#[test] -fn consolidation_and_dust_spending_are_detected() { - let dust_addr = our_address( - "bcrt1qdustin", - DescriptorType::P2wpkh, - DescriptorChainRole::External, - ); - let normal_addr = our_address( - "bcrt1qnormal", - DescriptorType::P2wpkh, - DescriptorChainRole::External, - ); - let consolidated = our_address( - "bcrt1qconsolidated", - DescriptorType::P2wpkh, - DescriptorChainRole::External, - ); - let transactions = vec![ - tx( - "dust-fund", - vec![input("miner-a", 0)], - vec![output(0, &dust_addr.address, 1_000, DescriptorType::P2wpkh)], - 20, - ), - tx( - "normal-fund", - vec![input("miner-b", 0)], - vec![output( - 0, - &normal_addr.address, - 25_000, - DescriptorType::P2wpkh, - )], - 20, - ), - tx( - "consolidation-parent", - vec![input("src-1", 0), input("src-2", 0), input("src-3", 0)], - vec![output( - 0, - &consolidated.address, - 26_000, - DescriptorType::P2wpkh, - )], - 5, - ), - tx( - "spend-dust", - vec![input("dust-fund", 0), input("normal-fund", 0)], - vec![output(0, "mipcRecipient", 20_000, DescriptorType::P2pkh)], - 2, - ), - ]; - let graph = graph( - vec![dust_addr.clone(), normal_addr.clone(), consolidated.clone()], - vec![ - wallet_entry( - "dust-fund", - &dust_addr.address, - WalletTxCategory::Receive, - 1_000, - 20, - ), - wallet_entry( - "normal-fund", - &normal_addr.address, - WalletTxCategory::Receive, - 25_000, - 20, - ), - wallet_entry( - "consolidation-parent", - &consolidated.address, - WalletTxCategory::Receive, - 26_000, - 5, - ), - wallet_entry( - "spend-dust", - "mipcRecipient", - WalletTxCategory::Send, - 20_000, - 2, - ), - ], - vec![utxo( - "consolidation-parent", - 0, - &consolidated.address, - 26_000, - 5, - DescriptorType::P2wpkh, - )], - transactions, - ); - - let config = AnalysisConfig::default(); - let known_exchange = HashSet::new(); - let known_risky = HashSet::new(); - let ctx = context(&graph, &config, &known_exchange, &known_risky); - assert_eq!( - detect_dust_spending(&ctx).findings[0].kind, - FindingKind::DustSpending - ); - assert_eq!( - detect_consolidation(&ctx).findings[0].kind, - FindingKind::Consolidation - ); -} - -#[test] -fn age_spread_emits_finding_and_warning() { - let old = our_address( - "bcrt1qold", - DescriptorType::P2wpkh, - DescriptorChainRole::External, - ); - let fresh = our_address( - "bcrt1qfresh", - DescriptorType::P2wpkh, - DescriptorChainRole::External, - ); - let graph = graph( - vec![old.clone(), fresh.clone()], - Vec::new(), - vec![ - utxo( - "old-utxo", - 0, - &old.address, - 300_000, - 120, - DescriptorType::P2wpkh, - ), - utxo( - "fresh-utxo", - 0, - &fresh.address, - 310_000, - 5, - DescriptorType::P2wpkh, - ), - ], - vec![ - tx( - "old-utxo", - Vec::new(), - vec![output(0, &old.address, 300_000, DescriptorType::P2wpkh)], - 120, - ), - tx( - "fresh-utxo", - Vec::new(), - vec![output(0, &fresh.address, 310_000, DescriptorType::P2wpkh)], - 5, - ), - ], - ); - - let config = AnalysisConfig::default(); - let known_exchange = HashSet::new(); - let known_risky = HashSet::new(); - let result = detect_utxo_age_spread(&context(&graph, &config, &known_exchange, &known_risky)); - assert_eq!(result.findings[0].kind, FindingKind::UtxoAgeSpread); - assert_eq!(result.warnings[0].kind, WarningKind::DormantUtxos); -} - -#[test] -fn exchange_origin_and_tainted_merge_are_detected() { - let receive = our_address( - "bcrt1qexchange", - DescriptorType::P2wpkh, - DescriptorChainRole::External, - ); - let clean = our_address( - "bcrt1qclean", - DescriptorType::P2wpkh, - DescriptorChainRole::External, - ); - let tainted = our_address( - "bcrt1qtainted", - DescriptorType::P2wpkh, - DescriptorChainRole::External, - ); - let transactions = vec![ - tx( - "exchange-batch", - vec![input("exchange-hot", 0)], - vec![ - output(0, &receive.address, 200_000, DescriptorType::P2wpkh), - output(1, "bcrt1qsomeone1", 190_000, DescriptorType::P2wpkh), - output(2, "bcrt1qsomeone2", 180_000, DescriptorType::P2wpkh), - output(3, "bcrt1qsomeone3", 170_000, DescriptorType::P2wpkh), - output(4, "bcrt1qsomeone4", 160_000, DescriptorType::P2wpkh), - ], - 4, - ), - tx( - "risky-source", - vec![input("risky-parent", 0)], - vec![output(0, &tainted.address, 80_000, DescriptorType::P2wpkh)], - 8, - ), - tx( - "clean-source", - vec![input("clean-parent", 0)], - vec![output(0, &clean.address, 90_000, DescriptorType::P2wpkh)], - 8, - ), - tx( - "merge-taint", - vec![input("risky-source", 0), input("clean-source", 0)], - vec![output(0, "mipcOut", 150_000, DescriptorType::P2pkh)], - 1, - ), - ]; - let graph = graph( - vec![receive.clone(), clean.clone(), tainted.clone()], - vec![ - wallet_entry( - "exchange-batch", - &receive.address, - WalletTxCategory::Receive, - 200_000, - 4, - ), - wallet_entry( - "risky-source", - &tainted.address, - WalletTxCategory::Receive, - 80_000, - 8, - ), - wallet_entry( - "clean-source", - &clean.address, - WalletTxCategory::Receive, - 90_000, - 8, - ), - wallet_entry("merge-taint", "mipcOut", WalletTxCategory::Send, 150_000, 1), - ], - Vec::new(), - transactions, - ); - - let known_exchange_txids = HashSet::from([String::from("exchange-batch")]); - let known_risky_txids = HashSet::from([String::from("risky-source")]); - let config = AnalysisConfig::default(); - let ctx = context(&graph, &config, &known_exchange_txids, &known_risky_txids); - - assert_eq!( - detect_exchange_origin(&ctx).findings[0].kind, - FindingKind::ExchangeOrigin - ); - - let taint_result = detect_tainted_utxo_merge(&ctx); - assert_eq!(taint_result.findings[0].kind, FindingKind::TaintedUtxoMerge); - assert_eq!(taint_result.warnings[0].kind, WarningKind::DirectTaint); -} - -#[test] -fn behavioral_fingerprint_requires_consistent_patterns() { - let in1 = our_address( - "bcrt1qbeh1", - DescriptorType::P2pkh, - DescriptorChainRole::External, - ); - let in2 = our_address( - "bcrt1qbeh2", - DescriptorType::P2pkh, - DescriptorChainRole::External, - ); - let change = our_address( - "bcrt1qbehchange", - DescriptorType::P2wpkh, - DescriptorChainRole::Internal, - ); - let transactions = vec![ - tx( - "fund-1", - vec![input("source-1", 0)], - vec![output(0, &in1.address, 400_000, DescriptorType::P2pkh)], - 20, - ), - tx( - "fund-2", - vec![input("source-2", 0)], - vec![output(0, &in2.address, 400_000, DescriptorType::P2pkh)], - 20, - ), - tx( - "send-1", - vec![input("fund-1", 0), input("fund-2", 0)], - vec![ - output(0, "mipcDest1", 100_000, DescriptorType::P2pkh), - output(1, &change.address, 20_000, DescriptorType::P2wpkh), - ], - 3, - ), - tx( - "send-2", - vec![input("fund-1", 0), input("fund-2", 0)], - vec![ - output(0, "mipcDest2", 200_000, DescriptorType::P2pkh), - output(1, &change.address, 30_000, DescriptorType::P2wpkh), - ], - 2, - ), - tx( - "send-3", - vec![input("fund-1", 0), input("fund-2", 0)], - vec![ - output(0, "mipcDest3", 300_000, DescriptorType::P2pkh), - output(1, &change.address, 40_000, DescriptorType::P2wpkh), - ], - 1, - ), - ]; - let graph = graph( - vec![in1.clone(), in2.clone(), change.clone()], - vec![ - wallet_entry( - "fund-1", - &in1.address, - WalletTxCategory::Receive, - 400_000, - 20, - ), - wallet_entry( - "fund-2", - &in2.address, - WalletTxCategory::Receive, - 400_000, - 20, - ), - wallet_entry("send-1", &change.address, WalletTxCategory::Send, 20_000, 3), - wallet_entry("send-2", &change.address, WalletTxCategory::Send, 30_000, 2), - wallet_entry("send-3", &change.address, WalletTxCategory::Send, 40_000, 1), - ], - Vec::new(), - transactions, - ); - - let config = AnalysisConfig::default(); - let known_exchange = HashSet::new(); - let known_risky = HashSet::new(); - let findings = - detect_behavioral_fingerprint(&context(&graph, &config, &known_exchange, &known_risky)) - .findings; - assert_eq!(findings[0].kind, FindingKind::BehavioralFingerprint); -} diff --git a/crates/stealth-core/tests/engine.rs b/crates/stealth-core/tests/engine.rs deleted file mode 100644 index dcf5c19..0000000 --- a/crates/stealth-core/tests/engine.rs +++ /dev/null @@ -1,253 +0,0 @@ -use std::collections::{HashMap, HashSet}; - -use stealth_core::config::AnalysisConfig; -use stealth_core::engine::{AnalysisEngine, EngineSettings, ScanTarget}; -use stealth_core::error::AnalysisError; -use stealth_core::gateway::BlockchainGateway; -use stealth_core::model::{ - DecodedTransaction, DescriptorType, ResolvedDescriptor, TxOutput, WalletHistory, - WalletTxCategory, WalletTxEntry, -}; - -#[derive(Default)] -struct MockGateway { - normalized: HashMap, - derived: HashMap>, - descriptor_history: Option, - wallet_descriptors: HashMap>, - wallet_history: HashMap, - known_wallet_txids: HashMap>, -} - -impl BlockchainGateway for MockGateway { - fn normalize_descriptor(&self, descriptor: &str) -> Result { - self.normalized.get(descriptor).cloned().ok_or_else(|| { - AnalysisError::DescriptorNormalization { - descriptor: descriptor.to_string(), - message: "missing normalization fixture".into(), - } - }) - } - - fn derive_addresses( - &self, - descriptor: &ResolvedDescriptor, - ) -> Result, AnalysisError> { - self.derived.get(&descriptor.desc).cloned().ok_or_else(|| { - AnalysisError::EnvironmentUnavailable("missing derivation fixture".into()) - }) - } - - fn scan_descriptors( - &self, - _descriptors: &[ResolvedDescriptor], - ) -> Result { - self.descriptor_history - .clone() - .ok_or(AnalysisError::AnalysisEmpty) - } - - fn list_wallet_descriptors( - &self, - wallet_name: &str, - ) -> Result, AnalysisError> { - self.wallet_descriptors - .get(wallet_name) - .cloned() - .ok_or_else(|| AnalysisError::EnvironmentUnavailable("wallet not found".into())) - } - - fn scan_wallet(&self, wallet_name: &str) -> Result { - self.wallet_history - .get(wallet_name) - .cloned() - .ok_or(AnalysisError::AnalysisEmpty) - } - - fn known_wallet_txids( - &self, - wallet_names: &[String], - ) -> Result, AnalysisError> { - Ok(wallet_names - .iter() - .filter_map(|wallet_name| self.known_wallet_txids.get(wallet_name)) - .flat_map(|txids| txids.iter().cloned()) - .collect()) - } -} - -fn satoshis(value: u64) -> f64 { - value as f64 / 100_000_000.0 -} - -fn descriptor(desc: &str, internal: bool) -> ResolvedDescriptor { - ResolvedDescriptor { - desc: desc.to_string(), - internal, - active: true, - range_end: 50, - } -} - -fn history_for_address_reuse(address: &str) -> WalletHistory { - WalletHistory { - wallet_txs: vec![ - WalletTxEntry { - txid: "tx-1".into(), - address: address.into(), - category: WalletTxCategory::Receive, - amount_btc: satoshis(100_000), - confirmations: 6, - blockheight: 0, - }, - WalletTxEntry { - txid: "tx-2".into(), - address: address.into(), - category: WalletTxCategory::Receive, - amount_btc: satoshis(200_000), - confirmations: 5, - blockheight: 0, - }, - ], - utxos: Vec::new(), - transactions: HashMap::from([ - ( - "tx-1".into(), - DecodedTransaction { - txid: "tx-1".into(), - vin: Vec::new(), - vout: vec![TxOutput { - n: 0, - address: address.into(), - value_btc: satoshis(100_000), - script_type: DescriptorType::P2wpkh, - }], - version: 2, - locktime: 0, - vsize: 100, - confirmations: 6, - }, - ), - ( - "tx-2".into(), - DecodedTransaction { - txid: "tx-2".into(), - vin: Vec::new(), - vout: vec![TxOutput { - n: 0, - address: address.into(), - value_btc: satoshis(200_000), - script_type: DescriptorType::P2wpkh, - }], - version: 2, - locktime: 0, - vsize: 100, - confirmations: 5, - }, - ), - ]), - } -} - -#[test] -fn descriptor_scan_normalizes_derives_and_reports_findings() { - let normalized_external = "normalized:wpkh(xpub/0/*)"; - let normalized_internal = "normalized:wpkh(xpub/1/*)"; - let address = "bcrt1qengine"; - let gateway = MockGateway { - normalized: HashMap::from([ - ("wpkh(xpub/0/*)".into(), normalized_external.into()), - ("wpkh(xpub/1/*)".into(), normalized_internal.into()), - ]), - derived: HashMap::from([ - (normalized_external.into(), vec![address.into()]), - (normalized_internal.into(), vec!["bcrt1qchange".into()]), - ]), - descriptor_history: Some(history_for_address_reuse(address)), - ..MockGateway::default() - }; - let engine = AnalysisEngine::new(&gateway, EngineSettings::default()); - - let report = engine - .analyze(ScanTarget::Descriptors(vec!["wpkh(xpub/0/*)#abcd".into()])) - .expect("analysis should succeed"); - - assert_eq!(report.summary.findings, 1); - assert_eq!( - report.findings[0].kind, - stealth_core::model::FindingKind::AddressReuse - ); - assert_eq!(report.stats.addresses_derived, 2); -} - -#[test] -fn wallet_scan_uses_existing_wallet_descriptors() { - let address = "bcrt1qwallet"; - let wallet_name = "alice"; - let gateway = MockGateway { - derived: HashMap::from([ - ("normalized:wpkh(wallet/0/*)".into(), vec![address.into()]), - ( - "normalized:wpkh(wallet/1/*)".into(), - vec!["bcrt1qwalletchange".into()], - ), - ]), - wallet_descriptors: HashMap::from([( - wallet_name.into(), - vec![ - descriptor("normalized:wpkh(wallet/0/*)", false), - descriptor("normalized:wpkh(wallet/1/*)", true), - ], - )]), - wallet_history: HashMap::from([(wallet_name.into(), history_for_address_reuse(address))]), - ..MockGateway::default() - }; - let engine = AnalysisEngine::new(&gateway, EngineSettings::default()); - - let report = engine - .analyze(ScanTarget::WalletName(wallet_name.into())) - .expect("wallet analysis should succeed"); - - assert_eq!(report.summary.findings, 1); - assert_eq!(report.stats.transactions_analyzed, 2); -} - -#[test] -fn empty_history_returns_typed_error() { - let gateway = MockGateway { - normalized: HashMap::from([ - ("wpkh(xpub/0/*)".into(), "normalized:wpkh(xpub/0/*)".into()), - ("wpkh(xpub/1/*)".into(), "normalized:wpkh(xpub/1/*)".into()), - ]), - derived: HashMap::from([ - ( - "normalized:wpkh(xpub/0/*)".into(), - vec!["bcrt1qnone".into()], - ), - ( - "normalized:wpkh(xpub/1/*)".into(), - vec!["bcrt1qnonechange".into()], - ), - ]), - descriptor_history: Some(WalletHistory { - wallet_txs: Vec::new(), - utxos: Vec::new(), - transactions: HashMap::new(), - }), - ..MockGateway::default() - }; - let engine = AnalysisEngine::new( - &gateway, - EngineSettings { - analysis: AnalysisConfig::default(), - known_exchange_wallets: Vec::new(), - known_risky_wallets: Vec::new(), - }, - ); - - let error = engine - .analyze(ScanTarget::Descriptors(vec!["wpkh(xpub/0/*)".into()])) - .expect_err("analysis should fail"); - - assert_eq!(error, AnalysisError::AnalysisEmpty); -} diff --git a/engine/Cargo.toml b/engine/Cargo.toml new file mode 100644 index 0000000..d54075f --- /dev/null +++ b/engine/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "stealth-engine" +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" + +[dependencies] +bitcoin = { workspace = true, features = ["std"] } +serde = { workspace = true } +serde_json = { workspace = true } +stealth-model = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +corepc-node = { workspace = true } +stealth-bitcoincore = { path = "../bitcoincore" } + +[lints.rust] +missing_debug_implementations = "deny" diff --git a/engine/README.md b/engine/README.md new file mode 100644 index 0000000..811f8df --- /dev/null +++ b/engine/README.md @@ -0,0 +1,116 @@ +# stealth-engine + +Detects Bitcoin UTXO privacy vulnerabilities by analysing a wallet's transaction +history on a Bitcoin Core node via JSON-RPC. + +The library receives a pre-built `WalletHistory` (via any `BlockchainGateway` +implementation), indexes it into a `TxGraph`, then runs 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-engine = "0.1.0" +``` + +```rust,ignore +use stealth_engine::gateway::BlockchainGateway; +use stealth_engine::TxGraph; +use stealth_bitcoincore::BitcoinCoreRpc; + +// Connect to a wallet-loaded bitcoind +let gateway = BitcoinCoreRpc::from_url( + "http://127.0.0.1:8332", + Some("user".into()), + Some("pass".into()), +).unwrap(); +let history = gateway.scan_wallet("my_wallet").unwrap(); + +let graph = TxGraph::from_wallet_history(history); +let report = graph.detect_all(&Default::default(), 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 + all regtest integration tests) +cargo test -p stealth-engine + +# Run a single test with output +cargo test -p stealth-engine 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 +│ ├── engine.rs # AnalysisEngine — canonical scan entry point +│ ├── graph.rs # TxGraph — indexed wallet transaction view +│ └── detect.rs # all vulnerability detectors + detect_all() +└── tests/ + └── integration.rs # all regtest integration tests +``` + +## License + +[MIT](../LICENSE) diff --git a/engine/src/detect.rs b/engine/src/detect.rs new file mode 100644 index 0000000..40a446e --- /dev/null +++ b/engine/src/detect.rs @@ -0,0 +1,1009 @@ +use std::collections::{HashMap, HashSet}; + +use bitcoin::{Amount, Txid}; +use serde_json::json; + +use crate::config::DetectorThresholds; +use crate::gateway::WalletTxCategory; +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( + &self, + thresholds: &DetectorThresholds, + 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(thresholds, &mut findings); + self.detect_dust_spending(thresholds, &mut findings); + self.detect_change_detection(&mut findings); + self.detect_consolidation_origin(thresholds, &mut findings); + self.detect_script_type_mixing(&mut findings); + self.detect_cluster_merge(&mut findings); + self.detect_lookback_depth(thresholds, &mut findings, &mut warnings); + self.detect_exchange_origin(thresholds, &mut findings, known_exchange_txids); + self.detect_tainted_utxos(&mut findings, &mut warnings, known_risky_txids); + self.detect_behavioral_fingerprint(&mut findings); + + let stats = Stats { + transactions_analyzed: self.our_txids.len(), + addresses_seen: self.addr_map.len(), + utxos_current: self.utxos.len(), + }; + + Report::new(stats, findings, warnings) + } + + // ── 1. Address Reuse ─────────────────────────────────────────────────── + + fn detect_address_reuse(&self, findings: &mut Vec) { + for addr in &self.our_addrs { + let entries = match self.addr_txs.get(addr) { + Some(e) => e, + None => continue, + }; + let receive_txids: HashSet = entries + .iter() + .filter(|e| e.category == WalletTxCategory::Receive) + .map(|e| e.txid) + .collect(); + + if receive_txids.len() >= 2 { + let meta = self.addr_map.get(addr); + let role = if meta.is_some_and(|m: &AddressInfo| m.internal) { + "change" + } else { + "receive" + }; + findings.push(Finding { + vulnerability_type: VulnerabilityType::AddressReuse, + severity: Severity::High, + description: format!( + "Address {} ({}) reused across {} transactions", + addr.assume_checked_ref(), + role, + receive_txids.len() + ), + details: Some(json!({ + "address": addr.assume_checked_ref().to_string(), + "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(&self, findings: &mut Vec) { + let txids: Vec = self.our_txids.iter().copied().collect(); + for txid in &txids { + let tx = match self.fetch_tx(txid) { + Some(t) => t, + None => continue, + }; + if tx.vin.len() < 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(&self, thresholds: &DetectorThresholds, findings: &mut Vec) { + let dust = thresholds.dust; + let strict_dust = thresholds.strict_dust; + + // Current UTXOs + for utxo in &self.utxos { + if !self.is_ours(&utxo.address) { + continue; + } + let amt = utxo.amount; + if amt <= dust { + let label = if amt <= strict_dust { + "STRICT_DUST" + } else { + "dust-class" + }; + let severity = if amt <= strict_dust { + Severity::High + } else { + Severity::Medium + }; + findings.push(Finding { + vulnerability_type: VulnerabilityType::Dust, + severity, + description: format!( + "Dust UTXO at {} ({} sats, {}, unspent)", + utxo.address.assume_checked_ref(), + amt.to_sat(), + label + ), + details: Some(json!({ + "status": "unspent", + "address": utxo.address.assume_checked_ref().to_string(), + "sats": amt.to_sat(), + "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().copied().collect(); + let current_keys: HashSet<(Txid, String)> = self + .utxos + .iter() + .map(|u| (u.txid, u.address.assume_checked_ref().to_string())) + .collect(); + let mut seen = HashSet::new(); + for txid in &txids { + let outputs = self.get_output_addresses(txid); + for out in &outputs { + let amt = out.value; + if amt <= dust && self.is_ours(&out.address) { + let key = (*txid, out.address.assume_checked_ref().to_string()); + 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.assume_checked_ref(), + amt.to_sat() + ), + details: Some(json!({ + "status": "spent", + "address": out.address.assume_checked_ref().to_string(), + "sats": amt.to_sat(), + "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(&self, thresholds: &DetectorThresholds, findings: &mut Vec) { + let dust = thresholds.dust; + let normal_min = thresholds.normal_input_min; + + let txids: Vec = self.our_txids.iter().copied().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 amt = ia.value; + if amt <= dust { + dust_inputs.push(ia); + } else if amt > normal_min { + 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.assume_checked_ref().to_string(), "sats": d.value.to_sat()}) + }).collect::>(), + "normal_inputs": normal_inputs.iter().map(|n| { + json!({"address": n.address.assume_checked_ref().to_string(), "sats": n.value.to_sat()}) + }).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(&self, findings: &mut Vec) { + let txids: Vec = self.our_txids.iter().copied().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.to_sat(); + let ch_round = ch_sats % 100_000 == 0 || ch_sats % 1_000_000 == 0; + + for payment in &ext_outs { + let pay_sats = payment.value.to_sat(); + let pay_round = pay_sats % 100_000 == 0 || pay_sats % 1_000_000 == 0; + + 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( + &self, + thresholds: &DetectorThresholds, + findings: &mut Vec, + ) { + let min_inputs = thresholds.consolidation_min_inputs; + let max_outputs = thresholds.consolidation_max_outputs; + + for utxo in &self.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.vin.len(); + let n_out = parent.vout.len(); + + if n_in >= min_inputs && n_out <= max_outputs { + 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.to_btc(), + n_in + ), + details: Some(json!({ + "txid": utxo.txid, + "vout": utxo.vout, + "amount_sats": utxo.amount.to_sat(), + "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(&self, findings: &mut Vec) { + let txids: Vec = self.our_txids.iter().copied().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(&self, findings: &mut Vec) { + let txids: Vec = self.our_txids.iter().copied().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(); + for p_vin in &parent_tx.vin { + if p_vin.coinbase { + gp_sources.insert("coinbase".into()); + } else { + let ptxid = p_vin.previous_txid.to_string(); + gp_sources.insert(ptxid[..16].to_string()); + } + } + let ftxid = ia.funding_txid.to_string(); + let key = format!("{}:{}", &ftxid[..16], 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( + &self, + thresholds: &DetectorThresholds, + 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 < thresholds.utxo_age_spread_blocks { + 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_sats": oldest.0.amount.to_sat(), + }, + "newest": { + "txid": newest.0.txid, + "confirmations": newest.1, + "amount_sats": newest.0.amount.to_sat(), + }, + })), + correction: Some( + "Prefer spending older UTXOs first (FIFO coin selection) to normalize \ + the age distribution of your UTXO set." + .into(), + ), + }); + + let dormant_threshold = thresholds.dormant_utxo_blocks; + let old_count = aged.iter().filter(|(_, c)| *c >= dormant_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, dormant_threshold + ), + details: Some(json!({ + "count": old_count, + "threshold_blocks": dormant_threshold, + })), + correction: None, + }); + } + } + + // ── 10. Exchange Origin ──────────────────────────────────────────────── + + fn detect_exchange_origin( + &self, + thresholds: &DetectorThresholds, + findings: &mut Vec, + known_exchange_txids: Option<&HashSet>, + ) { + let batch_threshold = thresholds.exchange_batch_min_outputs; + + let txids: Vec = self.our_txids.iter().copied().collect(); + for txid in &txids { + let tx = match self.fetch_tx(txid) { + Some(t) => t, + None => continue, + }; + let n_out = tx.vout.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)]; + + let unique_addr_count = tx + .vout + .iter() + .filter_map(|o| o.address.as_ref()) + .collect::>() + .len(); + if unique_addr_count >= batch_threshold { + signals.push(format!("{} unique recipient addresses", unique_addr_count)); + } + + // Input-value to median-output-value ratio heuristic. + let total_input: Amount = input_addrs.iter().map(|ia| ia.value).sum(); + let mut output_values: Vec = tx.vout.iter().map(|o| o.value.to_sat()).collect(); + output_values.sort_unstable(); + if !output_values.is_empty() { + let median = output_values[output_values.len() / 2]; + if median > 0 && total_input.to_sat() > 10 * median { + signals.push(format!( + "Input/median-output ratio: {}x (exchange-like fan-out)", + total_input.to_sat() / median + )); + } + } + + if let Some(exchange_txids) = known_exchange_txids { + if exchange_txids.contains(txid) { + 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.assume_checked_ref().to_string(), "sats": o.value.to_sat()}) + }).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( + &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().copied().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.assume_checked_ref().to_string(), "sats": t.value.to_sat(), "source_txid": t.funding_txid}) + }).collect::>(), + "clean_inputs": clean.iter().map(|c| { + json!({"address": c.address.assume_checked_ref().to_string(), "sats": c.value.to_sat()}) + }).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) { + 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.assume_checked_ref().to_string(), "sats": o.value.to_sat()}) + }).collect::>(), + })), + correction: None, + }); + } + } + } + } + + // ── 12. Behavioral Fingerprint ───────────────────────────────────────── + + fn detect_behavioral_fingerprint(&self, findings: &mut Vec) { + // Collect send transactions. Prefer explicit wallet-side `send` + // labels and fall back to ownership inferred from inputs. + let txids: Vec = self.our_txids.iter().copied().collect(); + let send_labeled_txids: HashSet = self + .addr_txs + .values() + .flatten() + .filter(|entry| entry.category == WalletTxCategory::Send) + .map(|entry| entry.txid) + .collect(); + let mut send_txids = Vec::new(); + for txid in &txids { + let input_addrs = self.get_input_addresses(txid); + let has_our_input = input_addrs.iter().any(|ia| self.is_ours(&ia.address)); + if has_our_input || send_labeled_txids.contains(txid) { + send_txids.push(*txid); + } + } + + 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, + }; + + output_counts.push(tx.vout.len()); + + locktime_values.push(tx.locktime as u64); + + for vin in &tx.vin { + rbf_signals.push(vin.sequence < 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.to_sat(); + total_payments += 1; + if sats > 0 && (sats % 100_000 == 0 || sats % 1_000_000 == 0) { + uses_round_amounts += 1; + } + } + } + + // Fee rate + let vsize = tx.vsize as u64; + if vsize > 0 { + let in_total: Amount = input_addrs.iter().map(|ia| ia.value).sum(); + let out_total: Amount = tx.vout.iter().map(|o| o.value).sum(); + let fee_sats = in_total.to_sat().saturating_sub(out_total.to_sat()); + if fee_sats > 0 { + fee_rates.push(fee_sats as f64 / 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(), + ), + }); + } +} diff --git a/engine/src/engine.rs b/engine/src/engine.rs new file mode 100644 index 0000000..af70cf2 --- /dev/null +++ b/engine/src/engine.rs @@ -0,0 +1,177 @@ +//! Canonical analysis pipeline. +//! +//! [`AnalysisEngine`] is the primary entry point for running a privacy +//! scan. It accepts a [`BlockchainGateway`] for data access and routes +//! every scan request through the shared gateway abstraction, ensuring a +//! single execution path for HTTP, CLI, and library consumers. + +use std::collections::{HashMap, HashSet}; + +use bitcoin::{Amount, Txid}; + +use crate::descriptor::normalize_descriptors; +use crate::error::AnalysisError; +use crate::gateway::{ + BlockchainGateway, DecodedTransaction, DescriptorType, Utxo, WalletHistory, WalletTxCategory, + WalletTxEntry, +}; +use crate::graph::TxGraph; +use crate::types::Report; + +pub use stealth_model::scan::{EngineSettings, ScanTarget, UtxoInput}; + +// ── Engine ────────────────────────────────────────────────────────────────── + +/// Runs a privacy analysis through a [`BlockchainGateway`]. +/// +/// Construct one per request (or per CLI invocation) and call +/// [`analyze`](Self::analyze). +pub struct AnalysisEngine<'a, G: BlockchainGateway> { + gateway: &'a G, + settings: EngineSettings, +} + +impl std::fmt::Debug for AnalysisEngine<'_, G> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("AnalysisEngine") + .field("settings", &self.settings) + .finish_non_exhaustive() + } +} + +impl<'a, G: BlockchainGateway> AnalysisEngine<'a, G> { + pub fn new(gateway: &'a G, settings: EngineSettings) -> Self { + Self { gateway, settings } + } + + /// Run a full privacy scan for the given target. + pub fn analyze(&self, target: ScanTarget) -> Result { + match target { + ScanTarget::Descriptor(d) => self.analyze_descriptors(vec![d]), + ScanTarget::Descriptors(ds) => self.analyze_descriptors(ds), + ScanTarget::Utxos(utxos) => self.analyze_utxos(utxos), + } + } + + // ── descriptor path ───────────────────────────────────────────────── + + fn analyze_descriptors(&self, raw_descriptors: Vec) -> Result { + let resolved = normalize_descriptors( + &raw_descriptors, + self.settings.config.derivation_range_end, + self.gateway, + )?; + let history = self.gateway.scan_descriptors(&resolved)?; + let graph = TxGraph::from_wallet_history(history); + Ok(graph.detect_all( + &self.settings.config.thresholds, + self.settings.known_risky_txids.as_ref(), + self.settings.known_exchange_txids.as_ref(), + )) + } + + // ── UTXO path ─────────────────────────────────────────────────────── + + fn analyze_utxos(&self, utxos: Vec) -> Result { + let history = self.resolve_utxo_history(&utxos)?; + let graph = TxGraph::from_wallet_history(history); + Ok(graph.detect_all( + &self.settings.config.thresholds, + self.settings.known_risky_txids.as_ref(), + self.settings.known_exchange_txids.as_ref(), + )) + } + + /// Build a [`WalletHistory`] from raw UTXO inputs by fetching the + /// referenced transactions (and their parents) through the gateway. + fn resolve_utxo_history(&self, utxos: &[UtxoInput]) -> Result { + let mut wallet_txs = Vec::new(); + let mut utxo_entries = Vec::new(); + let mut transactions: HashMap = HashMap::new(); + let mut fetch_queue: Vec = Vec::new(); + + for utxo in utxos { + // Fetch the UTXO's parent transaction. + if let std::collections::hash_map::Entry::Vacant(e) = transactions.entry(utxo.txid) { + let tx = self.gateway.get_transaction(utxo.txid)?; + fetch_queue.extend( + tx.vin + .iter() + .filter(|i| !i.coinbase) + .map(|i| i.previous_txid), + ); + e.insert(tx); + } + + let tx = &transactions[&utxo.txid]; + + let address = utxo.address.clone().or_else(|| { + tx.vout + .iter() + .find(|o| o.n == utxo.vout) + .and_then(|o| o.address.clone()) + }); + + let value = utxo.value.unwrap_or_else(|| { + tx.vout + .iter() + .find(|o| o.n == utxo.vout) + .map(|o| o.value) + .unwrap_or(Amount::ZERO) + }); + + if address.is_some() { + wallet_txs.push(WalletTxEntry { + txid: utxo.txid, + address: address.clone(), + category: WalletTxCategory::Receive, + amount: value, + confirmations: 0, + blockheight: 0, + }); + } + + utxo_entries.push(Utxo { + txid: utxo.txid, + vout: utxo.vout, + address, + amount: value, + confirmations: 0, + script_type: DescriptorType::Unknown, + }); + } + + // Fetch ancestor transactions for input resolution, bounded by + // max_ancestor_depth to prevent unbounded graph traversal. + // A depth of 0 means we only keep the UTXO's own transaction. + let max_depth = self.settings.config.max_ancestor_depth; + if max_depth > 0 { + let mut depth_queue: Vec<(Txid, u32)> = + fetch_queue.into_iter().map(|txid| (txid, 1)).collect(); + while let Some((txid, depth)) = depth_queue.pop() { + if transactions.contains_key(&txid) { + continue; + } + if let Ok(tx) = self.gateway.get_transaction(txid) { + if depth < max_depth { + depth_queue.extend( + tx.vin + .iter() + .filter(|i| !i.coinbase) + .map(|i| (i.previous_txid, depth + 1)), + ); + } + transactions.insert(txid, tx); + } + } + } + + Ok(WalletHistory { + wallet_txs, + utxos: utxo_entries, + transactions, + internal_addresses: HashSet::new(), + derived_addresses: HashSet::new(), + }) + } +} diff --git a/engine/src/graph.rs b/engine/src/graph.rs new file mode 100644 index 0000000..7b63957 --- /dev/null +++ b/engine/src/graph.rs @@ -0,0 +1,236 @@ +use std::collections::{HashMap, HashSet}; + +use bitcoin::address::NetworkUnchecked; +use bitcoin::{Address, Amount, Txid}; + +use crate::gateway::{DecodedTransaction, WalletHistory, WalletTxCategory}; +use crate::types::{AddressInfo, InputInfo, OutputInfo, WalletTx}; + +/// Indexed view of all transactions touching a wallet's address set. +/// +/// All caches are populated up-front from a [`WalletHistory`] so no live +/// RPC connection is needed at detection time. +#[derive(Debug)] +pub struct TxGraph { + /// Map of our addresses → metadata. + pub addr_map: HashMap, AddressInfo>, + /// 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, Vec>, + /// Per-txid set of our addresses involved. + pub tx_addrs: HashMap>>, + + /// Decoded transactions keyed by txid. + 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: Txid, + pub vout: u32, + pub address: Address, + pub amount: Amount, + pub confirmations: u32, +} + +impl TxGraph { + /// Check whether an address belongs to our wallet. + pub fn is_ours(&self, address: &Address) -> bool { + self.our_addrs.contains(address) + } + + /// Get the script type for an address. + pub fn script_type(&self, address: &Address) -> String { + self.addr_map + .get(address) + .map(|info| info.script_type.clone()) + .unwrap_or_else(|| script_type_from_address(address)) + } + + /// Look up a decoded transaction by txid. + pub fn fetch_tx(&self, txid: &Txid) -> Option<&DecodedTransaction> { + self.tx_cache.get(txid) + } + + /// Get all input addresses for a transaction. + pub fn get_input_addresses(&self, txid: &Txid) -> Vec { + self.input_cache.get(txid).cloned().unwrap_or_default() + } + + /// Get all output addresses for a transaction. + pub fn get_output_addresses(&self, txid: &Txid) -> Vec { + self.output_cache.get(txid).cloned().unwrap_or_default() + } + + /// Build a [`TxGraph`] from a pre-fetched [`WalletHistory`] produced + /// by a [`BlockchainGateway`](crate::gateway::BlockchainGateway). + /// + /// All transaction caches are populated up-front so no live RPC + /// connection is needed. + pub fn from_wallet_history(history: WalletHistory) -> Self { + let mut our_addrs = HashSet::new(); + let mut addr_map = HashMap::new(); + let mut our_txids = HashSet::new(); + let mut addr_txs: HashMap, Vec> = HashMap::new(); + let mut tx_addrs: HashMap>> = HashMap::new(); + + for entry in &history.wallet_txs { + our_txids.insert(entry.txid); + let address = match &entry.address { + Some(addr) => addr, + None => continue, + }; + + let wtx = WalletTx { + txid: entry.txid, + address: address.clone(), + category: entry.category, + amount: entry.amount, + confirmations: entry.confirmations, + }; + + if entry.category != WalletTxCategory::Send { + our_addrs.insert(address.clone()); + addr_map + .entry(address.clone()) + .or_insert_with(|| AddressInfo { + script_type: script_type_from_address(address), + internal: history.internal_addresses.contains(address), + index: 0, + }); + } + + addr_txs.entry(address.clone()).or_default().push(wtx); + tx_addrs + .entry(entry.txid) + .or_default() + .insert(address.clone()); + } + + let utxos: Vec = history + .utxos + .iter() + .filter_map(|u| { + let address = u.address.clone()?; + our_addrs.insert(address.clone()); + addr_map + .entry(address.clone()) + .or_insert_with(|| AddressInfo { + script_type: script_type_from_address(&address), + internal: history.internal_addresses.contains(&address), + index: 0, + }); + Some(UtxoEntry { + txid: u.txid, + vout: u.vout, + address, + amount: u.amount, + confirmations: u.confirmations, + }) + }) + .collect(); + + // Add ALL derived addresses to `our_addrs` and `addr_map` + for addr in &history.derived_addresses { + our_addrs.insert(addr.clone()); + addr_map.entry(addr.clone()).or_insert_with(|| AddressInfo { + script_type: script_type_from_address(addr), + internal: history.internal_addresses.contains(addr), + index: 0, + }); + } + + // Pre-populate caches from decoded transactions. + let mut tx_cache = HashMap::new(); + let mut input_cache: HashMap> = HashMap::new(); + let mut output_cache: HashMap> = HashMap::new(); + + for (txid, tx) in &history.transactions { + tx_cache.insert(*txid, tx.clone()); + + let inputs: Vec = tx + .vin + .iter() + .filter_map(|input| { + if input.coinbase { + return None; + } + let parent = history.transactions.get(&input.previous_txid)?; + let out = parent.vout.iter().find(|o| o.n == input.previous_vout)?; + let address = out.address.clone()?; + Some(InputInfo { + address, + value: out.value, + funding_txid: input.previous_txid, + funding_vout: input.previous_vout, + }) + }) + .collect(); + input_cache.insert(*txid, inputs); + + let outputs: Vec = tx + .vout + .iter() + .filter_map(|out| { + let address = out.address.clone()?; + Some(OutputInfo { + address: address.clone(), + value: out.value, + index: out.n, + script_type: script_type_from_address(&address), + }) + }) + .collect(); + output_cache.insert(*txid, outputs); + } + + TxGraph { + addr_map, + our_addrs, + utxos, + our_txids, + addr_txs, + tx_addrs, + tx_cache, + input_cache, + output_cache, + } + } +} + +/// Determine script type by decoding the address and inspecting the +/// resulting script. +/// +/// * `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: &Address) -> String { + let addr = address.clone().assume_checked(); + let script = addr.script_pubkey(); + if script.is_p2pkh() { + "p2pkh".into() + } else if script.is_p2sh() { + "p2sh".into() + } else if script.is_p2wpkh() { + "p2wpkh".into() + } else if script.is_p2wsh() { + "p2wsh".into() + } else if script.is_p2tr() { + "p2tr".into() + } else { + "unknown".into() + } +} diff --git a/engine/src/lib.rs b/engine/src/lib.rs new file mode 100644 index 0000000..a597514 --- /dev/null +++ b/engine/src/lib.rs @@ -0,0 +1,46 @@ +//! # stealth-engine +//! +//! Detects Bitcoin UTXO privacy vulnerabilities by analysing a wallet's +//! transaction history through a [`BlockchainGateway`](gateway::BlockchainGateway). +//! +//! The canonical execution path is: +//! +//! ```text +//! AnalysisEngine + BlockchainGateway → Report +//! ``` +//! +//! Construct an [`AnalysisEngine`] with a concrete gateway implementation, +//! then call [`AnalysisEngine::analyze`] with a [`ScanTarget`]. +//! +//! 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 | + +pub use stealth_model::config; +pub use stealth_model::descriptor; +mod detect; +pub mod engine; +pub use stealth_model::error; +pub use stealth_model::gateway; +mod graph; +pub use stealth_model::types; + +pub use engine::{AnalysisEngine, EngineSettings, ScanTarget, UtxoInput}; +pub use graph::TxGraph; +pub use stealth_model::types::*; diff --git a/engine/tests/integration.rs b/engine/tests/integration.rs new file mode 100644 index 0000000..c521b19 --- /dev/null +++ b/engine/tests/integration.rs @@ -0,0 +1,715 @@ +//! Integration tests for stealth-engine. +//! +//! Each test spins up a fresh regtest Bitcoin Core via `corepc-node`, +//! reproduces one or more privacy vulnerabilities, then runs the +//! detector through the canonical `AnalysisEngine` + `BitcoinCoreRpc` +//! gateway path to verify it fires the expected finding(s). + +use std::collections::{BTreeMap, HashSet}; + +use bitcoin::Txid; +use corepc_node::client::bitcoin::{Address, Amount}; +use corepc_node::{AddressType, Input, Node, Output}; +use stealth_bitcoincore::BitcoinCoreRpc; +use stealth_engine::gateway::BlockchainGateway; +use stealth_engine::{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 gateway_for(node: &Node) -> BitcoinCoreRpc { + let cookie = + std::fs::read_to_string(&node.params.cookie_file).expect("failed to read cookie file"); + let mut parts = cookie.trim().splitn(2, ':'); + let user = parts.next().unwrap().to_string(); + let pass = parts.next().unwrap().to_string(); + BitcoinCoreRpc::from_url(&node.rpc_url(), Some(user), Some(pass)) + .expect("failed to build gateway") +} + +fn scan_wallet(gateway: &BitcoinCoreRpc, wallet: &str) -> stealth_engine::Report { + let history = gateway.scan_wallet(wallet).expect("scan_wallet failed"); + let graph = TxGraph::from_wallet_history(history); + graph.detect_all(&Default::default(), None, None) +} + +fn scan_wallet_with( + gateway: &BitcoinCoreRpc, + wallet: &str, + known_risky: Option<&HashSet>, + known_exchange: Option<&HashSet>, +) -> stealth_engine::Report { + let history = gateway.scan_wallet(wallet).expect("scan_wallet failed"); + let graph = TxGraph::from_wallet_history(history); + graph.detect_all(&Default::default(), known_risky, known_exchange) +} + +fn has_finding(report: &stealth_engine::Report, vtype: VulnerabilityType) -> bool { + 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 gateway = gateway_for(&node); + let report = scan_wallet(&gateway, "alice"); + assert!(has_finding(&report, 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 gateway = gateway_for(&node); + let report = scan_wallet(&gateway, "alice"); + assert!(has_finding(&report, 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 gateway = gateway_for(&node); + let report = scan_wallet(&gateway, "alice"); + assert!(has_finding(&report, 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 gateway = gateway_for(&node); + let report = scan_wallet(&gateway, "alice"); + assert!(has_finding(&report, 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 gateway = gateway_for(&node); + let report = scan_wallet(&gateway, "alice"); + assert!(has_finding(&report, 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 gateway = gateway_for(&node); + let report = scan_wallet(&gateway, "alice"); + assert!(has_finding(&report, 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 gateway = gateway_for(&node); + let report = scan_wallet(&gateway, "alice"); + assert!(has_finding(&report, 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 gateway = gateway_for(&node); + let report = scan_wallet(&gateway, "alice"); + assert!(has_finding(&report, 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 gateway = gateway_for(&node); + let report = scan_wallet(&gateway, "alice"); + assert!(has_finding(&report, 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.parse::().unwrap()] + .into_iter() + .collect(); + let gateway = gateway_for(&node); + let report = scan_wallet_with(&gateway, "alice", None, Some(&exchange_txids)); + assert!(has_finding(&report, VulnerabilityType::ExchangeOrigin)); +} + +// ─── 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: Txid = taint_result.0.parse().unwrap(); + + // 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 gateway = gateway_for(&node); + let report = scan_wallet_with(&gateway, "alice", Some(&risky_txids), None); + assert!(has_finding(&report, VulnerabilityType::TaintedUtxoMerge)); +} + +// ─── 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 gateway = gateway_for(&node); + let report = scan_wallet(&gateway, "alice"); + 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 gateway = gateway_for(&node); + let report = scan_wallet(&gateway, "alice"); + + assert_eq!( + report.summary.findings + report.summary.warnings, + report.findings.len() + report.warnings.len() + ); + assert_eq!(report.stats.utxos_current, 1); +} diff --git a/model/Cargo.toml b/model/Cargo.toml new file mode 100644 index 0000000..efb8b53 --- /dev/null +++ b/model/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "stealth-model" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true +description = "Domain model types for Stealth wallet privacy analysis" +categories = ["cryptography::cryptocurrencies"] +keywords = ["bitcoin", "privacy", "utxo"] + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +bitcoin = { workspace = true } diff --git a/crates/stealth-core/src/config.rs b/model/src/config.rs similarity index 65% rename from crates/stealth-core/src/config.rs rename to model/src/config.rs index 171aa2c..d8a9a5f 100644 --- a/crates/stealth-core/src/config.rs +++ b/model/src/config.rs @@ -1,5 +1,8 @@ use std::collections::HashSet; +use bitcoin::Amount; + +/// Identifies a specific detector for enable/disable configuration. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum DetectorId { AddressReuse, @@ -16,38 +19,49 @@ pub enum DetectorId { BehavioralFingerprint, } +/// Numeric thresholds used by the detectors. #[derive(Debug, Clone, PartialEq, Eq)] pub struct DetectorThresholds { - pub dust_sats: u64, - pub strict_dust_sats: u64, - pub normal_input_min_sats: u64, + pub dust: Amount, + pub strict_dust: Amount, + pub normal_input_min: Amount, pub consolidation_min_inputs: usize, pub consolidation_max_outputs: usize, pub utxo_age_spread_blocks: u32, pub dormant_utxo_blocks: u32, - pub exchange_batch_outputs: usize, + pub exchange_batch_min_outputs: usize, + pub dust_attack_min_outputs: usize, + pub dust_attack_min_dust_outputs: usize, + pub toxic_change_upper: Amount, } impl Default for DetectorThresholds { fn default() -> Self { Self { - dust_sats: 1_000, - strict_dust_sats: 546, - normal_input_min_sats: 10_000, + dust: Amount::from_sat(1_000), + strict_dust: Amount::from_sat(546), + normal_input_min: Amount::from_sat(10_000), consolidation_min_inputs: 3, consolidation_max_outputs: 2, utxo_age_spread_blocks: 10, dormant_utxo_blocks: 100, - exchange_batch_outputs: 5, + exchange_batch_min_outputs: 5, + dust_attack_min_outputs: 10, + dust_attack_min_dust_outputs: 5, + toxic_change_upper: Amount::from_sat(10_000), } } } +/// Top-level analysis configuration. #[derive(Debug, Clone, PartialEq, Eq)] pub struct AnalysisConfig { pub derivation_range_end: u32, pub thresholds: DetectorThresholds, pub enabled_detectors: HashSet, + /// Maximum ancestor-fetch depth when resolving UTXO history. + /// `0` means only UTXO's own tx; `2` (the default) + pub max_ancestor_depth: u32, } impl Default for AnalysisConfig { @@ -69,6 +83,7 @@ impl Default for AnalysisConfig { DetectorId::TaintedUtxoMerge, DetectorId::BehavioralFingerprint, ]), + max_ancestor_depth: 2, } } } diff --git a/crates/stealth-core/src/descriptor.rs b/model/src/descriptor.rs similarity index 55% rename from crates/stealth-core/src/descriptor.rs rename to model/src/descriptor.rs index c03b98f..66a2b26 100644 --- a/crates/stealth-core/src/descriptor.rs +++ b/model/src/descriptor.rs @@ -1,10 +1,17 @@ use crate::error::AnalysisError; -use crate::model::ResolvedDescriptor; +use crate::gateway::ResolvedDescriptor; +/// Trait for normalizing a raw descriptor string (e.g. via `getdescriptorinfo`). pub trait DescriptorNormalizer { fn normalize(&self, descriptor: &str) -> Result; } +/// Normalize raw descriptor strings: strip checksums, infer receive/change +/// pairs (`/0/*` ↔ `/1/*`), deduplicate. +/// +/// When a `normalizer` is provided (typically a [`BlockchainGateway`]), +/// each candidate is passed through `getdescriptorinfo` for canonical +/// checksumming. pub fn normalize_descriptors( raw_descriptors: &[String], derivation_range_end: u32, @@ -64,3 +71,46 @@ pub fn normalize_descriptors( Ok(resolved) } + +/// Lightweight descriptor normalization that strips checksums and infers +/// receive/change pairs without calling an RPC normalizer. +/// +/// Returns `(descriptor_string, is_internal)` pairs. +pub fn normalize_descriptors_raw(raw_descriptors: &[String]) -> Vec<(String, bool)> { + let mut result = Vec::new(); + + for raw in raw_descriptors { + let without_checksum = raw + .split('#') + .next() + .map(str::trim) + .unwrap_or_default() + .to_string(); + + if without_checksum.is_empty() { + continue; + } + + let candidates = if without_checksum.contains("/0/*") { + vec![ + (without_checksum.clone(), false), + (without_checksum.replace("/0/*", "/1/*"), true), + ] + } else if without_checksum.contains("/1/*") { + vec![ + (without_checksum.replace("/1/*", "/0/*"), false), + (without_checksum.clone(), true), + ] + } else { + vec![(without_checksum, false)] + }; + + for pair in candidates { + if !result.contains(&pair) { + result.push(pair); + } + } + } + + result +} diff --git a/crates/stealth-core/src/error.rs b/model/src/error.rs similarity index 78% rename from crates/stealth-core/src/error.rs rename to model/src/error.rs index 2129d8a..542aedd 100644 --- a/crates/stealth-core/src/error.rs +++ b/model/src/error.rs @@ -1,5 +1,6 @@ use thiserror::Error; +/// Errors from the analysis pipeline. #[derive(Debug, Error, Clone, PartialEq, Eq)] pub enum AnalysisError { #[error("descriptor input cannot be empty")] @@ -8,6 +9,6 @@ pub enum AnalysisError { DescriptorNormalization { descriptor: String, message: String }, #[error("environment unavailable: {0}")] EnvironmentUnavailable(String), - #[error("analysis found no history for the supplied descriptors")] - AnalysisEmpty, + #[error("analysis execution failed: {0}")] + Execution(String), } diff --git a/model/src/gateway.rs b/model/src/gateway.rs new file mode 100644 index 0000000..89bc12c --- /dev/null +++ b/model/src/gateway.rs @@ -0,0 +1,222 @@ +use std::collections::{HashMap, HashSet}; + +use bitcoin::address::NetworkUnchecked; +use bitcoin::{Address, Amount, Txid}; +use serde::{Deserialize, Serialize}; + +use crate::descriptor::DescriptorNormalizer; +use crate::error::AnalysisError; +use crate::types::{serde_addr, serde_addr_opt, serde_addr_set}; + +/// Abstraction over a blockchain data source (e.g. Bitcoin Core RPC). +/// +/// Implementations provide descriptor normalization, address derivation, +/// wallet scanning, and transaction history retrieval. This trait decouples +/// domain logic from the concrete RPC transport, making it possible to +/// test with mocks. +pub trait BlockchainGateway { + fn normalize_descriptor(&self, descriptor: &str) -> Result; + fn derive_addresses( + &self, + descriptor: &ResolvedDescriptor, + ) -> Result>, AnalysisError>; + fn scan_descriptors( + &self, + descriptors: &[ResolvedDescriptor], + ) -> Result; + fn list_wallet_descriptors( + &self, + wallet_name: &str, + ) -> Result, AnalysisError>; + fn scan_wallet(&self, wallet_name: &str) -> Result; + fn known_wallet_txids(&self, wallet_names: &[String]) -> Result, AnalysisError>; + fn get_transaction(&self, txid: Txid) -> Result; +} + +/// Blanket implementation: any `BlockchainGateway` is also a +/// `DescriptorNormalizer`. +impl DescriptorNormalizer for T +where + T: BlockchainGateway + ?Sized, +{ + fn normalize(&self, descriptor: &str) -> Result { + self.normalize_descriptor(descriptor) + } +} + +// ── Gateway model types ───────────────────────────────────────────────────── + +/// A descriptor that has been normalized and resolved for import. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ResolvedDescriptor { + pub desc: String, + pub internal: bool, + pub active: bool, + pub range_end: u32, +} + +/// Role of a descriptor chain (external receive vs internal change). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum DescriptorChainRole { + External, + Internal, +} + +/// Script/address type derived from a descriptor. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum DescriptorType { + P2wpkh, + P2tr, + P2shP2wpkh, + P2sh, + P2pkh, + Unknown, +} + +impl DescriptorType { + pub fn from_descriptor(descriptor: &str) -> Self { + if descriptor.starts_with("wpkh(") { + Self::P2wpkh + } else if descriptor.starts_with("tr(") { + Self::P2tr + } else if descriptor.starts_with("sh(wpkh(") { + Self::P2shP2wpkh + } else if descriptor.starts_with("pkh(") { + Self::P2pkh + } else { + Self::Unknown + } + } + + pub fn infer_from_address(address: &Address) -> Self { + let script = address.clone().assume_checked().script_pubkey(); + if script.is_p2wpkh() { + Self::P2wpkh + } else if script.is_p2tr() { + Self::P2tr + } else if script.is_p2sh() { + Self::P2sh + } else if script.is_p2pkh() { + Self::P2pkh + } else { + Self::Unknown + } + } + + pub fn as_script_name(self) -> &'static str { + match self { + Self::P2wpkh => "witness_v0_keyhash", + Self::P2tr => "witness_v1_taproot", + Self::P2shP2wpkh | Self::P2sh => "scripthash", + Self::P2pkh => "pubkeyhash", + Self::Unknown => "unknown", + } + } +} + +/// A derived address with metadata about its origin descriptor. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DerivedAddress { + #[serde(with = "serde_addr")] + pub address: Address, + pub descriptor_type: DescriptorType, + pub chain_role: DescriptorChainRole, + pub derivation_index: u32, +} + +/// Wallet transaction category. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum WalletTxCategory { + Send, + Receive, + Unknown, +} + +/// A wallet transaction entry (from `listtransactions`). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct WalletTxEntry { + pub txid: Txid, + #[serde(with = "serde_addr_opt")] + pub address: Option>, + pub category: WalletTxCategory, + pub amount: Amount, + pub confirmations: u32, + pub blockheight: u32, +} + +/// An input reference within a decoded transaction. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TxInputRef { + #[serde(rename = "txid")] + pub previous_txid: Txid, + #[serde(rename = "vout")] + pub previous_vout: u32, + pub sequence: u32, + pub coinbase: bool, +} + +/// A transaction output. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct TxOutput { + pub n: u32, + #[serde(with = "serde_addr_opt")] + pub address: Option>, + pub value: Amount, + pub script_type: DescriptorType, +} + +/// A fully decoded transaction with inputs and outputs. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct DecodedTransaction { + pub txid: Txid, + pub vin: Vec, + pub vout: Vec, + pub version: i32, + pub locktime: u32, + pub vsize: u32, + pub confirmations: u32, +} + +/// A current unspent transaction output. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Utxo { + pub txid: Txid, + pub vout: u32, + #[serde(with = "serde_addr_opt")] + pub address: Option>, + pub amount: Amount, + pub confirmations: u32, + pub script_type: DescriptorType, +} + +/// Complete wallet history with transactions and UTXOs. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct WalletHistory { + pub wallet_txs: Vec, + pub utxos: Vec, + pub transactions: HashMap, + /// Addresses known to belong to internal (change) descriptor chains. + /// Populated by the descriptor scan path; may be empty for wallet scans. + #[serde(default, with = "serde_addr_set")] + pub internal_addresses: HashSet>, + /// Every address derived from ALL wallet descriptors (external + internal). + /// Used by `TxGraph` to seed `our_addrs` + #[serde(default, with = "serde_addr_set")] + pub derived_addresses: HashSet>, +} + +/// A participant (input or output) in a transaction, enriched with +/// ownership information. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct TransactionParticipant { + #[serde(with = "serde_addr")] + pub address: Address, + pub value: Amount, + pub script_type: DescriptorType, + pub is_ours: bool, + pub funding_txid: Option, + pub funding_vout: Option, +} diff --git a/model/src/lib.rs b/model/src/lib.rs new file mode 100644 index 0000000..f67c14f --- /dev/null +++ b/model/src/lib.rs @@ -0,0 +1,8 @@ +pub mod config; +pub mod descriptor; +pub mod error; +pub mod gateway; +pub mod scan; +pub mod types; + +pub use types::*; diff --git a/model/src/scan.rs b/model/src/scan.rs new file mode 100644 index 0000000..a35084c --- /dev/null +++ b/model/src/scan.rs @@ -0,0 +1,39 @@ +use std::collections::HashSet; + +use bitcoin::address::NetworkUnchecked; +use bitcoin::{Address, Amount, Txid}; +use serde::{Deserialize, Serialize}; + +use crate::config::AnalysisConfig; + +/// 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: Txid, + pub vout: u32, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub value: Option, + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "crate::types::serde_addr_opt" + )] + pub address: Option>, +} + +/// Top-level settings for the analysis engine, combining detector config +/// with optional known-wallet hooks used by taint and exchange detectors. +#[derive(Debug, Clone, Default)] +pub struct EngineSettings { + pub config: AnalysisConfig, + pub known_risky_txids: Option>, + pub known_exchange_txids: Option>, +} diff --git a/model/src/types.rs b/model/src/types.rs new file mode 100644 index 0000000..a7a22b7 --- /dev/null +++ b/model/src/types.rs @@ -0,0 +1,248 @@ +use bitcoin::address::NetworkUnchecked; +use bitcoin::{Address, Amount, Txid}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::gateway::WalletTxCategory; + +/// Serde helper: serialize an [`Address`] via its checked +/// display representation. Deserialization delegates to the standard +/// `Address` deserializer. +pub mod serde_addr { + use bitcoin::address::NetworkUnchecked; + use bitcoin::Address; + use serde::{self, Deserialize, Deserializer, Serializer}; + + pub fn serialize(addr: &Address, s: S) -> Result + where + S: Serializer, + { + s.collect_str(addr.assume_checked_ref()) + } + + pub fn deserialize<'de, D>(d: D) -> Result, D::Error> + where + D: Deserializer<'de>, + { + Address::::deserialize(d) + } +} + +/// Serde helper for `Option>`. +pub mod serde_addr_opt { + use bitcoin::address::NetworkUnchecked; + use bitcoin::Address; + use serde::{self, Deserialize, Deserializer, Serializer}; + + pub fn serialize(addr: &Option>, s: S) -> Result + where + S: Serializer, + { + match addr { + Some(a) => s.collect_str(a.assume_checked_ref()), + None => s.serialize_none(), + } + } + + pub fn deserialize<'de, D>(d: D) -> Result>, D::Error> + where + D: Deserializer<'de>, + { + Option::>::deserialize(d) + } +} + +/// Serde helper for `HashSet>`. +pub mod serde_addr_set { + use bitcoin::address::NetworkUnchecked; + use bitcoin::Address; + use serde::{self, Deserialize, Deserializer, Serializer}; + use std::collections::HashSet; + + pub fn serialize(addrs: &HashSet>, s: S) -> Result + where + S: Serializer, + { + use serde::ser::SerializeSeq; + let mut seq = s.serialize_seq(Some(addrs.len()))?; + for addr in addrs { + seq.serialize_element(&addr.assume_checked_ref().to_string())?; + } + seq.end() + } + + pub fn deserialize<'de, D>(d: D) -> Result>, D::Error> + where + D: Deserializer<'de>, + { + let strings: Vec = Vec::deserialize(d)?; + strings + .into_iter() + .map(|s| { + s.parse::>() + .map_err(serde::de::Error::custom) + }) + .collect() + } +} + +/// 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, +} + +impl core::fmt::Display for VulnerabilityType { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::AddressReuse => write!(f, "ADDRESS_REUSE"), + Self::Cioh => write!(f, "CIOH"), + Self::Dust => write!(f, "DUST"), + Self::DustSpending => write!(f, "DUST_SPENDING"), + Self::ChangeDetection => write!(f, "CHANGE_DETECTION"), + Self::Consolidation => write!(f, "CONSOLIDATION"), + Self::ScriptTypeMixing => write!(f, "SCRIPT_TYPE_MIXING"), + Self::ClusterMerge => write!(f, "CLUSTER_MERGE"), + Self::UtxoAgeSpread => write!(f, "UTXO_AGE_SPREAD"), + Self::DormantUtxos => write!(f, "DORMANT_UTXOS"), + Self::ExchangeOrigin => write!(f, "EXCHANGE_ORIGIN"), + Self::TaintedUtxoMerge => write!(f, "TAINTED_UTXO_MERGE"), + Self::DirectTaint => write!(f, "DIRECT_TAINT"), + Self::BehavioralFingerprint => write!(f, "BEHAVIORAL_FINGERPRINT"), + } + } +} + +/// 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_seen: 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, + } + } +} + +/// Convert a BTC f64 value to an [`Amount`]. +pub fn btc_to_amount(btc: f64) -> Amount { + Amount::from_sat((btc * 1e8).round() as u64) +} + +/// 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: Address, + pub value: Amount, + pub funding_txid: Txid, + pub funding_vout: u32, +} + +/// Information about a transaction output. +#[derive(Debug, Clone)] +pub struct OutputInfo { + pub address: Address, + pub value: Amount, + pub index: u32, + pub script_type: String, +} + +/// A wallet transaction entry (from `listtransactions`). +#[derive(Debug, Clone)] +pub struct WalletTx { + pub txid: Txid, + pub address: Address, + pub category: WalletTxCategory, + pub amount: Amount, + pub confirmations: u32, +}