diff --git a/.gitignore b/.gitignore index 7bfc0b5..5910afc 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ dist/ .pnpm-store .qwen **/__pycache__/ +target/ diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..31afad8 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1735 @@ +# 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 new file mode 100644 index 0000000..7f6669b --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,20 @@ +[workspace] +members = [ + "crates/stealth-app", + "crates/stealth-bitcoincore", + "crates/stealth-core", +] +resolver = "2" + +[workspace.package] +edition = "2024" +license = "MIT" +version = "0.1.0" + +[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"] } diff --git a/crates/stealth-app/Cargo.toml b/crates/stealth-app/Cargo.toml new file mode 100644 index 0000000..9db2343 --- /dev/null +++ b/crates/stealth-app/Cargo.toml @@ -0,0 +1,18 @@ +[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 new file mode 100644 index 0000000..d54947f --- /dev/null +++ b/crates/stealth-app/src/bin/stealth-api.rs @@ -0,0 +1,39 @@ +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 new file mode 100644 index 0000000..4458728 --- /dev/null +++ b/crates/stealth-app/src/lib.rs @@ -0,0 +1,189 @@ +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 new file mode 100644 index 0000000..99860e4 --- /dev/null +++ b/crates/stealth-app/src/main.rs @@ -0,0 +1,48 @@ +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 new file mode 100644 index 0000000..222222a --- /dev/null +++ b/crates/stealth-bitcoincore/Cargo.toml @@ -0,0 +1,14 @@ +[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-bitcoincore/src/lib.rs b/crates/stealth-bitcoincore/src/lib.rs new file mode 100644 index 0000000..95769f7 --- /dev/null +++ b/crates/stealth-bitcoincore/src/lib.rs @@ -0,0 +1,558 @@ +use std::collections::{HashMap, HashSet}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +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, +}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BitcoinCoreConfig { + pub network: String, + pub datadir: Option, + pub rpchost: String, + pub rpcport: u16, + pub rpcuser: Option, + pub rpcpassword: Option, +} + +impl BitcoinCoreConfig { + pub fn from_ini_file(path: impl AsRef) -> Result { + let path = path.as_ref(); + let ini = Ini::load_from_file(path) + .map_err(|error| AnalysisError::EnvironmentUnavailable(error.to_string()))?; + let section = ini.section(Some("bitcoin")).ok_or_else(|| { + AnalysisError::EnvironmentUnavailable("missing [bitcoin] section".into()) + })?; + + let network = section + .get("network") + .map(|value| value.trim().to_lowercase()) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| "regtest".into()); + let datadir = section.get("datadir").and_then(|value| { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else if Path::new(trimmed).is_absolute() { + Some(PathBuf::from(trimmed)) + } else { + Some( + path.parent() + .unwrap_or_else(|| Path::new(".")) + .join(trimmed), + ) + } + }); + + Ok(Self { + rpcport: section + .get("rpcport") + .and_then(|value| value.parse::().ok()) + .unwrap_or_else(|| default_rpc_port(&network)), + rpchost: section + .get("rpchost") + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| "127.0.0.1".into()), + rpcuser: section + .get("rpcuser") + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()), + rpcpassword: section + .get("rpcpassword") + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()), + network, + datadir, + }) + } + + fn cookie_credentials(&self) -> Result<(String, String), AnalysisError> { + let datadir = self.datadir.as_ref().ok_or_else(|| { + AnalysisError::EnvironmentUnavailable("missing datadir for cookie auth".into()) + })?; + let mut candidates = Vec::new(); + if self.network == "mainnet" { + candidates.push(datadir.join(".cookie")); + } else { + candidates.push(datadir.join(&self.network).join(".cookie")); + candidates.push(datadir.join(".cookie")); + } + + for candidate in candidates { + 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)); + } + } + + Err(AnalysisError::EnvironmentUnavailable( + "could not locate a readable Bitcoin Core cookie file".into(), + )) + } +} + +pub struct BitcoinCoreRpc { + config: BitcoinCoreConfig, + client: Client, +} + +impl BitcoinCoreRpc { + pub fn new(config: BitcoinCoreConfig) -> Result { + let client = Client::builder() + .build() + .map_err(|error| AnalysisError::EnvironmentUnavailable(error.to_string()))?; + Ok(Self { config, client }) + } + + fn rpc_url(&self, wallet: Option<&str>) -> String { + let base = format!("http://{}:{}", self.config.rpchost, self.config.rpcport); + wallet + .map(|wallet_name| format!("{base}/wallet/{}", urlencoding::encode(wallet_name))) + .unwrap_or(base) + } + + fn credentials(&self) -> Result<(String, String), AnalysisError> { + if let (Some(user), Some(password)) = + (self.config.rpcuser.clone(), self.config.rpcpassword.clone()) + { + Ok((user, password)) + } else { + self.config.cookie_credentials() + } + } + + fn call( + &self, + wallet: Option<&str>, + method: &str, + params: Vec, + ) -> Result { + let (user, password) = self.credentials()?; + let response = self + .client + .post(self.rpc_url(wallet)) + .basic_auth(user, Some(password)) + .json(&json!({ + "jsonrpc": "1.0", + "id": "stealth-rust", + "method": method, + "params": params, + })) + .send() + .map_err(|error| AnalysisError::EnvironmentUnavailable(error.to_string()))?; + + if !response.status().is_success() { + return Err(AnalysisError::EnvironmentUnavailable(format!( + "rpc transport error: {}", + response.status() + ))); + } + + let envelope = response + .json::>() + .map_err(|error| AnalysisError::EnvironmentUnavailable(error.to_string()))?; + match (envelope.result, envelope.error) { + (Some(result), None) => Ok(result), + (_, Some(error)) => Err(AnalysisError::EnvironmentUnavailable(error.message)), + _ => Err(AnalysisError::EnvironmentUnavailable( + "rpc returned neither result nor error".into(), + )), + } + } + + fn load_history_for_wallet(&self, wallet_name: &str) -> Result { + let wallet_txs = self.list_transactions(wallet_name)?; + let utxos = self.list_unspent(wallet_name)?; + let mut txids = wallet_txs + .iter() + .map(|entry| entry.txid.clone()) + .collect::>(); + txids.extend(utxos.iter().map(|utxo| utxo.txid.clone())); + + let mut transactions = HashMap::new(); + let mut queue = txids.into_iter().collect::>(); + while let Some(txid) = queue.pop() { + if transactions.contains_key(&txid) { + continue; + } + let tx = self.get_transaction(&txid)?; + for input in &tx.vin { + if !input.coinbase && !transactions.contains_key(&input.previous_txid) { + queue.push(input.previous_txid.clone()); + } + } + transactions.insert(txid.clone(), tx); + } + + Ok(WalletHistory { + wallet_txs, + utxos, + transactions, + }) + } + + fn list_transactions(&self, wallet_name: &str) -> Result, AnalysisError> { + let entries = self.call::>( + Some(wallet_name), + "listtransactions", + vec![json!("*"), json!(10000), json!(0), json!(true)], + )?; + Ok(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(), + }) + .collect()) + } + + fn list_unspent(&self, wallet_name: &str) -> Result, AnalysisError> { + let utxos = self.call::>( + Some(wallet_name), + "listunspent", + vec![json!(0), json!(9_999_999)], + )?; + Ok(utxos + .into_iter() + .map(|utxo| { + let address = utxo.address.unwrap_or_default(); + Utxo { + txid: utxo.txid, + vout: utxo.vout, + address: address.clone(), + amount_btc: utxo.amount, + confirmations: utxo.confirmations.unwrap_or_default(), + script_type: DescriptorType::infer_from_address(&address), + } + }) + .collect()) + } + + fn get_transaction(&self, txid: &str) -> Result { + let tx = + self.call::(None, "getrawtransaction", vec![json!(txid), json!(true)])?; + + Ok(DecodedTransaction { + 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(), + }) + .collect(), + vout: tx + .vout + .into_iter() + .map(|output| { + let address = output + .script_pub_key + .address + .or_else(|| { + output + .script_pub_key + .addresses + .and_then(|mut items| items.pop()) + }) + .unwrap_or_default(); + 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)), + } + }) + .collect(), + version: tx.version.unwrap_or(2), + locktime: tx.locktime.unwrap_or_default(), + vsize: tx.vsize.unwrap_or_default(), + confirmations: tx.confirmations.unwrap_or_default(), + }) + } + + fn create_watch_only_wallet(&self, wallet_name: &str) -> Result<(), AnalysisError> { + let _ = self.call::( + None, + "createwallet", + vec![ + json!(wallet_name), + json!(true), + json!(true), + json!(""), + json!(false), + json!(true), + ], + )?; + Ok(()) + } + + fn unload_wallet(&self, wallet_name: &str) { + let _ = self.call::(None, "unloadwallet", vec![json!(wallet_name)]); + } +} + +impl BlockchainGateway for BitcoinCoreRpc { + fn normalize_descriptor(&self, descriptor: &str) -> Result { + let response = + self.call::(None, "getdescriptorinfo", vec![json!(descriptor)])?; + Ok(response.descriptor) + } + + fn derive_addresses( + &self, + descriptor: &ResolvedDescriptor, + ) -> Result, AnalysisError> { + self.call( + None, + "deriveaddresses", + vec![json!(descriptor.desc), json!([0, descriptor.range_end])], + ) + } + + fn scan_descriptors( + &self, + descriptors: &[ResolvedDescriptor], + ) -> Result { + let wallet_name = format!( + "_stealth_scan_{}", + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|error| AnalysisError::EnvironmentUnavailable(error.to_string()))? + .as_millis() + ); + self.create_watch_only_wallet(&wallet_name)?; + + let imports = descriptors + .iter() + .map(|descriptor| { + json!({ + "desc": descriptor.desc, + "timestamp": 0, + "internal": descriptor.internal, + "active": descriptor.active, + "range": [0, descriptor.range_end], + }) + }) + .collect::>(); + + let import_results = self.call::>( + Some(&wallet_name), + "importdescriptors", + 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 history = self.load_history_for_wallet(&wallet_name); + self.unload_wallet(&wallet_name); + history + } + + fn list_wallet_descriptors( + &self, + wallet_name: &str, + ) -> Result, AnalysisError> { + let response = + self.call::(Some(wallet_name), "listdescriptors", Vec::new())?; + Ok(response + .descriptors + .into_iter() + .map(|descriptor| ResolvedDescriptor { + desc: descriptor.desc, + internal: descriptor.internal.unwrap_or(false), + active: descriptor.active.unwrap_or(true), + range_end: descriptor + .range + .map(|range| match range { + DescriptorRange::Single(value) => value, + DescriptorRange::Pair([_, end]) => end, + }) + .unwrap_or(999), + }) + .collect()) + } + + fn scan_wallet(&self, wallet_name: &str) -> Result { + self.load_history_for_wallet(wallet_name) + } + + fn known_wallet_txids( + &self, + wallet_names: &[String], + ) -> Result, AnalysisError> { + let mut txids = HashSet::new(); + for wallet_name in wallet_names { + txids.extend( + self.list_transactions(wallet_name)? + .into_iter() + .map(|entry| entry.txid), + ); + } + Ok(txids) + } +} + +fn default_rpc_port(network: &str) -> u16 { + match network { + "mainnet" => 8332, + "testnet" => 18332, + "signet" => 38332, + _ => 18443, + } +} + +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, + "pubkeyhash" => DescriptorType::P2pkh, + _ => DescriptorType::Unknown, + } +} + +#[derive(Debug, Deserialize)] +struct JsonRpcEnvelope { + result: Option, + error: Option, +} + +#[derive(Debug, Deserialize)] +struct JsonRpcError { + message: String, +} + +#[derive(Debug, Deserialize)] +struct DescriptorInfo { + descriptor: String, +} + +#[derive(Debug, Deserialize)] +struct ImportResult { + success: bool, +} + +#[derive(Debug, Deserialize)] +struct ListDescriptorsResponse { + descriptors: Vec, +} + +#[derive(Debug, Deserialize)] +struct DescriptorRecord { + desc: String, + internal: Option, + active: Option, + range: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(untagged)] +enum DescriptorRange { + Single(u32), + Pair([u32; 2]), +} + +#[derive(Debug, Deserialize)] +struct ListTransactionEntry { + txid: String, + address: Option, + category: Option, + amount: f64, + confirmations: Option, + blockheight: Option, +} + +#[derive(Debug, Deserialize)] +struct ListUnspentEntry { + txid: String, + vout: u32, + address: Option, + amount: f64, + confirmations: Option, +} + +#[derive(Debug, Deserialize)] +struct RawTransaction { + txid: String, + vin: Vec, + vout: Vec, + version: Option, + locktime: Option, + vsize: Option, + confirmations: Option, +} + +#[derive(Debug, Deserialize)] +struct RawVin { + txid: Option, + vout: Option, + coinbase: Option, + sequence: Option, +} + +#[derive(Debug, Deserialize)] +struct RawVout { + value: f64, + n: u32, + #[serde(rename = "scriptPubKey")] + script_pub_key: RawScriptPubKey, +} + +#[derive(Debug, Deserialize)] +struct RawScriptPubKey { + address: Option, + addresses: Option>, + #[serde(rename = "type")] + script_type: Option, +} + +#[cfg(test)] +mod tests { + use super::default_rpc_port; + + #[test] + fn network_defaults_match_bitcoin_core_ports() { + assert_eq!(default_rpc_port("regtest"), 18443); + assert_eq!(default_rpc_port("testnet"), 18332); + assert_eq!(default_rpc_port("signet"), 38332); + assert_eq!(default_rpc_port("mainnet"), 8332); + } +} diff --git a/crates/stealth-core/Cargo.toml b/crates/stealth-core/Cargo.toml new file mode 100644 index 0000000..466f94b --- /dev/null +++ b/crates/stealth-core/Cargo.toml @@ -0,0 +1,10 @@ +[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/config.rs b/crates/stealth-core/src/config.rs new file mode 100644 index 0000000..171aa2c --- /dev/null +++ b/crates/stealth-core/src/config.rs @@ -0,0 +1,74 @@ +use std::collections::HashSet; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum DetectorId { + AddressReuse, + Cioh, + Dust, + DustSpending, + ChangeDetection, + Consolidation, + ScriptTypeMixing, + ClusterMerge, + UtxoAgeSpread, + ExchangeOrigin, + TaintedUtxoMerge, + BehavioralFingerprint, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DetectorThresholds { + pub dust_sats: u64, + pub strict_dust_sats: u64, + pub normal_input_min_sats: u64, + 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, +} + +impl Default for DetectorThresholds { + fn default() -> Self { + Self { + dust_sats: 1_000, + strict_dust_sats: 546, + normal_input_min_sats: 10_000, + consolidation_min_inputs: 3, + consolidation_max_outputs: 2, + utxo_age_spread_blocks: 10, + dormant_utxo_blocks: 100, + exchange_batch_outputs: 5, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AnalysisConfig { + pub derivation_range_end: u32, + pub thresholds: DetectorThresholds, + pub enabled_detectors: HashSet, +} + +impl Default for AnalysisConfig { + fn default() -> Self { + Self { + derivation_range_end: 999, + thresholds: DetectorThresholds::default(), + enabled_detectors: HashSet::from([ + DetectorId::AddressReuse, + DetectorId::Cioh, + DetectorId::Dust, + DetectorId::DustSpending, + DetectorId::ChangeDetection, + DetectorId::Consolidation, + DetectorId::ScriptTypeMixing, + DetectorId::ClusterMerge, + DetectorId::UtxoAgeSpread, + DetectorId::ExchangeOrigin, + DetectorId::TaintedUtxoMerge, + DetectorId::BehavioralFingerprint, + ]), + } + } +} diff --git a/crates/stealth-core/src/descriptor.rs b/crates/stealth-core/src/descriptor.rs new file mode 100644 index 0000000..c03b98f --- /dev/null +++ b/crates/stealth-core/src/descriptor.rs @@ -0,0 +1,66 @@ +use crate::error::AnalysisError; +use crate::model::ResolvedDescriptor; + +pub trait DescriptorNormalizer { + fn normalize(&self, descriptor: &str) -> Result; +} + +pub fn normalize_descriptors( + raw_descriptors: &[String], + derivation_range_end: u32, + normalizer: &dyn DescriptorNormalizer, +) -> Result, AnalysisError> { + let mut resolved = 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() { + return Err(AnalysisError::EmptyDescriptor); + } + + 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.clone(), false)] + }; + + for (candidate, internal) in candidates { + let normalized = normalizer + .normalize(&candidate) + .map_err(|error| match error { + AnalysisError::DescriptorNormalization { .. } => error, + other => AnalysisError::DescriptorNormalization { + descriptor: candidate.clone(), + message: other.to_string(), + }, + })?; + + let descriptor = ResolvedDescriptor { + desc: normalized, + internal, + active: true, + range_end: derivation_range_end, + }; + + if !resolved.iter().any(|item| item == &descriptor) { + resolved.push(descriptor); + } + } + } + + Ok(resolved) +} diff --git a/crates/stealth-core/src/detectors.rs b/crates/stealth-core/src/detectors.rs new file mode 100644 index 0000000..0696cd5 --- /dev/null +++ b/crates/stealth-core/src/detectors.rs @@ -0,0 +1,1042 @@ +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 new file mode 100644 index 0000000..0b39c9b --- /dev/null +++ b/crates/stealth-core/src/engine.rs @@ -0,0 +1,122 @@ +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/error.rs b/crates/stealth-core/src/error.rs new file mode 100644 index 0000000..2129d8a --- /dev/null +++ b/crates/stealth-core/src/error.rs @@ -0,0 +1,13 @@ +use thiserror::Error; + +#[derive(Debug, Error, Clone, PartialEq, Eq)] +pub enum AnalysisError { + #[error("descriptor input cannot be empty")] + EmptyDescriptor, + #[error("descriptor `{descriptor}` failed normalization: {message}")] + DescriptorNormalization { descriptor: String, message: String }, + #[error("environment unavailable: {0}")] + EnvironmentUnavailable(String), + #[error("analysis found no history for the supplied descriptors")] + AnalysisEmpty, +} diff --git a/crates/stealth-core/src/gateway.rs b/crates/stealth-core/src/gateway.rs new file mode 100644 index 0000000..6cecbed --- /dev/null +++ b/crates/stealth-core/src/gateway.rs @@ -0,0 +1,33 @@ +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 new file mode 100644 index 0000000..3424484 --- /dev/null +++ b/crates/stealth-core/src/graph.rs @@ -0,0 +1,151 @@ +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 new file mode 100644 index 0000000..cd8253b --- /dev/null +++ b/crates/stealth-core/src/lib.rs @@ -0,0 +1,80 @@ +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 new file mode 100644 index 0000000..51a4759 --- /dev/null +++ b/crates/stealth-core/src/model.rs @@ -0,0 +1,267 @@ +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 new file mode 100644 index 0000000..6cde82e --- /dev/null +++ b/crates/stealth-core/tests/detectors.rs @@ -0,0 +1,689 @@ +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 new file mode 100644 index 0000000..dcf5c19 --- /dev/null +++ b/crates/stealth-core/tests/engine.rs @@ -0,0 +1,253 @@ +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); +}