diff --git a/Cargo.lock b/Cargo.lock index 617a35fe..a5826c4b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -213,14 +213,14 @@ dependencies = [ "env_logger", "log", "payjoin", - "reqwest 0.13.2", + "reqwest 0.13.4", "serde", "serde_json", "shlex 1.3.0", "tap", "thiserror 2.0.18", "tokio", - "toml 1.1.1+spec-1.1.0", + "toml 1.1.2+spec-1.1.0", "tracing", "tracing-subscriber", "url", @@ -375,7 +375,7 @@ dependencies = [ "bitcoin", "bitcoin_hashes 0.15.0", "chacha20-poly1305", - "rand 0.8.5", + "rand 0.8.6", "tokio", ] @@ -493,7 +493,7 @@ dependencies = [ "hkdf 0.11.0", "lazy_static", "log", - "rand 0.8.5", + "rand 0.8.6", "serde", "serde_derive", "sha2 0.9.9", @@ -611,9 +611,9 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.20.2" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "byteorder" @@ -639,12 +639,6 @@ dependencies = [ "shlex 2.0.1", ] -[[package]] -name = "cesu8" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" - [[package]] name = "cfg-if" version = "1.0.4" @@ -778,9 +772,9 @@ checksum = "bba18ee93d577a8428902687bcc2b6b45a56b1981a1f6d779731c86cc4c5db18" [[package]] name = "clap" -version = "4.6.0" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" dependencies = [ "clap_builder", "clap_derive", @@ -809,9 +803,9 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.6.0" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" dependencies = [ "heck", "proc-macro2", @@ -1158,9 +1152,9 @@ checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" [[package]] name = "fastrand" -version = "2.3.0" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] name = "find-msvc-tools" @@ -1374,9 +1368,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.16.1" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" [[package]] name = "hashlink" @@ -1522,15 +1516,14 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.27.7" +version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ "http", "hyper", "hyper-util", "rustls 0.23.41", - "rustls-pki-types", "tokio", "tokio-rustls", "tower-service", @@ -1578,12 +1571,13 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" dependencies = [ "displaydoc", "potential_utf", + "utf8_iter", "yoke", "zerofrom", "zerovec", @@ -1591,9 +1585,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" dependencies = [ "displaydoc", "litemap", @@ -1604,9 +1598,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -1618,15 +1612,15 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" [[package]] name = "icu_properties" -version = "2.1.2" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" dependencies = [ "icu_collections", "icu_locale_core", @@ -1638,15 +1632,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.1.2" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" [[package]] name = "icu_provider" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" dependencies = [ "displaydoc", "icu_locale_core", @@ -1670,9 +1664,9 @@ dependencies = [ [[package]] name = "idna_adapter" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" dependencies = [ "icu_normalizer", "icu_properties", @@ -1680,12 +1674,12 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.13.0" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.16.1", + "hashbrown 0.17.1", ] [[package]] @@ -1703,16 +1697,6 @@ version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" -[[package]] -name = "iri-string" -version = "0.7.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" -dependencies = [ - "memchr", - "serde", -] - [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -1752,27 +1736,32 @@ dependencies = [ [[package]] name = "jni" -version = "0.21.1" +version = "0.22.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" dependencies = [ - "cesu8", "cfg-if", "combine", - "jni-sys 0.3.1", + "jni-macros", + "jni-sys", "log", - "thiserror 1.0.69", + "simd_cesu8", + "thiserror 2.0.18", "walkdir", - "windows-sys 0.45.0", + "windows-link", ] [[package]] -name = "jni-sys" -version = "0.3.1" +name = "jni-macros" +version = "0.22.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" dependencies = [ - "jni-sys 0.4.1", + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn", ] [[package]] @@ -1835,9 +1824,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.183" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libm" @@ -1921,9 +1910,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" [[package]] name = "lock_api" @@ -2144,9 +2133,9 @@ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pkg-config" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" [[package]] name = "poly1305" @@ -2190,9 +2179,9 @@ checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" [[package]] name = "portable-atomic-util" -version = "0.2.6" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "091397be61a01d4be58e7841595bd4bfedb15f1cd54977d79b8271e94ed799a3" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" dependencies = [ "portable-atomic", ] @@ -2208,9 +2197,9 @@ dependencies = [ [[package]] name = "potential_utf" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" dependencies = [ "zerovec", ] @@ -2313,9 +2302,9 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rand" -version = "0.8.5" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ "libc", "rand_chacha", @@ -2468,9 +2457,9 @@ dependencies = [ [[package]] name = "reqwest" -version = "0.13.2" +version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" dependencies = [ "base64 0.22.1", "bytes", @@ -2535,6 +2524,15 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "1.1.4" @@ -2571,7 +2569,7 @@ dependencies = [ "once_cell", "ring", "rustls-pki-types", - "rustls-webpki 0.103.10", + "rustls-webpki 0.103.13", "subtle", "zeroize", ] @@ -2600,9 +2598,9 @@ dependencies = [ [[package]] name = "rustls-platform-verifier" -version = "0.6.2" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" dependencies = [ "core-foundation", "core-foundation-sys", @@ -2612,11 +2610,11 @@ dependencies = [ "rustls 0.23.41", "rustls-native-certs", "rustls-platform-verifier-android", - "rustls-webpki 0.103.10", + "rustls-webpki 0.103.13", "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2637,9 +2635,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.10" +version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ "aws-lc-rs", "ring", @@ -2700,7 +2698,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113" dependencies = [ "bitcoin_hashes 0.14.101", - "rand 0.8.5", + "rand 0.8.6", "secp256k1-sys", "serde", ] @@ -2737,6 +2735,12 @@ dependencies = [ "libc", ] +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" version = "1.0.228" @@ -2769,9 +2773,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "itoa", "memchr", @@ -2856,6 +2860,22 @@ dependencies = [ "libc", ] +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "slab" version = "0.4.12" @@ -3006,9 +3026,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" dependencies = [ "displaydoc", "zerovec", @@ -3031,9 +3051,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.50.0" +version = "1.52.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" dependencies = [ "bytes", "libc", @@ -3048,9 +3068,9 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.6.1" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", @@ -3088,9 +3108,9 @@ dependencies = [ [[package]] name = "toml" -version = "1.1.1+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "994b95d9e7bae62b34bab0e2a4510b801fa466066a6a8b2b57361fa1eba068ee" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" dependencies = [ "indexmap", "serde_core", @@ -3112,9 +3132,9 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.1.1+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39ca317ebc49f06bd748bfba29533eac9485569dc9bf80b849024b025e814fb9" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ "winnow", ] @@ -3142,20 +3162,20 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.8" +version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ "bitflags 2.13.0", "bytes", "futures-util", "http", "http-body", - "iri-string", "pin-project-lite", "tower", "tower-layer", "tower-service", + "url", ] [[package]] @@ -3499,22 +3519,13 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" -[[package]] -name = "windows-sys" -version = "0.45.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" -dependencies = [ - "windows-targets 0.42.2", -] - [[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", + "windows-targets", ] [[package]] @@ -3526,67 +3537,34 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-targets" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" -dependencies = [ - "windows_aarch64_gnullvm 0.42.2", - "windows_aarch64_msvc 0.42.2", - "windows_i686_gnu 0.42.2", - "windows_i686_msvc 0.42.2", - "windows_x86_64_gnu 0.42.2", - "windows_x86_64_gnullvm 0.42.2", - "windows_x86_64_msvc 0.42.2", -] - [[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_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", "windows_i686_gnullvm", - "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", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" - [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_i686_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" - [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -3599,48 +3577,24 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" - [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_x86_64_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -3649,9 +3603,9 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "winnow" -version = "1.0.1" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" [[package]] name = "wit-bindgen" @@ -3661,9 +3615,9 @@ checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" [[package]] name = "writeable" -version = "0.6.2" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" [[package]] name = "yoke" @@ -3710,18 +3664,18 @@ dependencies = [ [[package]] name = "zerofrom" -version = "0.1.6" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" dependencies = [ "zerofrom-derive", ] [[package]] name = "zerofrom-derive" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", @@ -3751,9 +3705,9 @@ dependencies = [ [[package]] name = "zerotrie" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" dependencies = [ "displaydoc", "yoke", @@ -3762,9 +3716,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.5" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" dependencies = [ "yoke", "zerofrom", @@ -3773,9 +3727,9 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.2" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 8097662a..07254880 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,7 +26,6 @@ tracing = "0.1.44" tracing-subscriber = "0.3.20" toml = "1.1.0" serde= {version = "1.0", features = ["derive"]} -shlex = "1.3.0" tap = "1.0.1" # Optional dependencies @@ -36,6 +35,7 @@ bdk_esplora = { version = "0.22.2", features = ["async-https", "tokio"], optiona bdk_kyoto = { version = "0.15.4", optional = true } bdk_redb = { version = "0.1.1", optional = true } bdk_sp = { version = "0.1.0", optional = true, git = "https://github.com/bitcoindevkit/bdk-sp", tag = "v0.1.0" } +shlex = { version = "1.3.0", optional = true } payjoin = { version = "0.25.0", features = ["v1", "v2", "io", "_test-utils"], optional = true} reqwest = { version = "0.13.2", default-features = false, features = ["rustls"], optional = true } url = { version = "2.5.8", optional = true } @@ -46,7 +46,7 @@ bitcoin-payment-instructions = { version = "0.7.0", optional = true} default = ["repl", "sqlite"] # To use the app in a REPL mode -repl = [] +repl = ["shlex"] # Available database options sqlite = ["bdk_wallet/rusqlite"] diff --git a/README.md b/README.md index 20d86898..006a5663 100644 --- a/README.md +++ b/README.md @@ -282,15 +282,6 @@ Note: You can modify the `Justfile` to reflect your nodes' configuration values. cargo run --features rpc -- wallet -w regtest1 balance ``` -## Formatting Responses using `--pretty` flag - -You can optionally return outputs of commands in human-readable, tabular format instead of `JSON`. To enable this option, simply add the `--pretty` flag as a top level flag. For instance, you wallet's balance in a pretty format, you can run: - -```shell -cargo run -- --pretty -n signet wallet -w {wallet_name} balance -``` -This is available for wallet, key, repl and compile features. When ommitted, outputs default to `JSON`. - ## Shell Completions `bdk-cli` supports generating shell completions for Bash, Zsh, Fish, Elvish, and PowerShell. For setup instructions, run: diff --git a/src/client.rs b/src/client.rs new file mode 100644 index 00000000..483183c0 --- /dev/null +++ b/src/client.rs @@ -0,0 +1,330 @@ +#[cfg(feature = "esplora")] +use bdk_esplora::EsploraAsyncExt; +#[cfg(any( + feature = "electrum", + feature = "esplora", + feature = "rpc", + feature = "cbf" +))] +use { + crate::commands::WalletOpts, + crate::error::BDKCliError as Error, + bdk_wallet::{ + Wallet, + bitcoin::{Transaction, Txid}, + }, + clap::ValueEnum, + std::path::PathBuf, +}; +#[cfg(feature = "rpc")] +use { + bdk_bitcoind_rpc::{Emitter, bitcoincore_rpc::RpcApi}, + bdk_wallet::chain::CanonicalizationParams, +}; + +#[cfg(feature = "cbf")] +use { + crate::utils::trace_logger, + bdk_kyoto::{BuilderExt, LightClient}, +}; + +#[cfg(any( + feature = "electrum", + feature = "esplora", + feature = "rpc", + feature = "cbf" +))] +#[derive(Clone, ValueEnum, Debug, Eq, PartialEq)] +pub enum ClientType { + #[cfg(feature = "electrum")] + Electrum, + #[cfg(feature = "esplora")] + Esplora, + #[cfg(feature = "rpc")] + Rpc, + #[cfg(feature = "cbf")] + Cbf, +} + +#[cfg(any( + feature = "electrum", + feature = "esplora", + feature = "rpc", + feature = "cbf" +))] +pub(crate) enum BlockchainClient { + #[cfg(feature = "electrum")] + Electrum { + client: Box>, + batch_size: usize, + }, + #[cfg(feature = "esplora")] + Esplora { + client: Box, + parallel_requests: usize, + }, + #[cfg(feature = "rpc")] + RpcClient { + client: Box, + }, + + #[cfg(feature = "cbf")] + KyotoClient { client: Box }, +} + +#[cfg(any( + feature = "electrum", + feature = "esplora", + feature = "rpc", + feature = "cbf" +))] +impl BlockchainClient { + pub async fn broadcast(&self, tx: Transaction) -> Result { + match self { + #[cfg(feature = "electrum")] + Self::Electrum { client, .. } => client + .transaction_broadcast(&tx) + .map_err(|e| Error::Generic(e.to_string())), + + #[cfg(feature = "esplora")] + Self::Esplora { client, .. } => client + .broadcast(&tx) + .await + .map(|()| tx.compute_txid()) + .map_err(|e| Error::Generic(e.to_string())), + + #[cfg(feature = "rpc")] + Self::RpcClient { client } => client + .send_raw_transaction(&tx) + .map_err(|e| Error::Generic(e.to_string())), + + #[cfg(feature = "cbf")] + Self::KyotoClient { client } => { + let txid = tx.compute_txid(); + let wtxid = client + .requester + .broadcast_random(tx.clone()) + .await + .map_err(|_| { + tracing::warn!("Broadcast was unsuccessful"); + Error::Generic("Transaction broadcast timed out after 30 seconds".into()) + })?; + tracing::info!("Successfully broadcast WTXID: {wtxid}"); + Ok(txid) + } + } + } + + pub async fn sync_wallet(&self, wallet: &mut Wallet) -> Result<(), Error> { + #[cfg(any(feature = "electrum", feature = "esplora"))] + let request = wallet + .start_sync_with_revealed_spks() + .inspect(|item, progress| { + let pc = (100 * progress.consumed()) as f32 / progress.total() as f32; + eprintln!("[ SCANNING {pc:03.0}% ] {item}"); + }); + match self { + #[cfg(feature = "electrum")] + Self::Electrum { client, batch_size } => { + // Populate the electrum client's transaction cache so it doesn't re-download transaction we + // already have. + client.populate_tx_cache(wallet.tx_graph().full_txs().map(|tx_node| tx_node.tx)); + + let update = client.sync(request, *batch_size, false)?; + wallet + .apply_update(update) + .map_err(|e| Error::Generic(e.to_string())) + } + #[cfg(feature = "esplora")] + Self::Esplora { + client, + parallel_requests, + } => { + let update = client + .sync(request, *parallel_requests) + .await + .map_err(|e| *e)?; + wallet + .apply_update(update) + .map_err(|e| Error::Generic(e.to_string())) + } + #[cfg(feature = "rpc")] + Self::RpcClient { client } => { + let blockchain_info = client.get_blockchain_info()?; + let wallet_cp = wallet.latest_checkpoint(); + + // reload the last 200 blocks in case of a reorg + let emitter_height = wallet_cp.height().saturating_sub(200); + let mut emitter = Emitter::new( + client.as_ref(), + wallet_cp, + emitter_height, + wallet + .tx_graph() + .list_canonical_txs( + wallet.local_chain(), + wallet.local_chain().tip().block_id(), + CanonicalizationParams::default(), + ) + .filter(|tx| tx.chain_position.is_unconfirmed()), + ); + + while let Some(block_event) = emitter.next_block()? { + if block_event.block_height() % 10_000 == 0 { + let percent_done = f64::from(block_event.block_height()) + / f64::from(blockchain_info.headers as u32) + * 100f64; + println!( + "Applying block at height: {}, {:.2}% done.", + block_event.block_height(), + percent_done + ); + } + + wallet.apply_block_connected_to( + &block_event.block, + block_event.block_height(), + block_event.connected_to(), + )?; + } + + let mempool_txs = emitter.mempool()?; + wallet.apply_unconfirmed_txs(mempool_txs.update); + Ok(()) + } + #[cfg(feature = "cbf")] + Self::KyotoClient { client } => sync_kyoto_client(wallet, client) + .await + .map_err(|e| Error::Generic(e.to_string())), + } + } +} + +/// Handle for the Kyoto client after the node has been started. +/// Contains only the components needed for sync and broadcast operations. +#[cfg(feature = "cbf")] +pub struct KyotoClientHandle { + pub requester: bdk_kyoto::Requester, + pub update_subscriber: tokio::sync::Mutex, +} + +#[cfg(any( + feature = "electrum", + feature = "esplora", + feature = "rpc", + feature = "cbf", +))] +/// Create a new blockchain from the wallet configuration options. +pub(crate) fn new_blockchain_client( + wallet_opts: &WalletOpts, + _wallet: &Wallet, + _datadir: PathBuf, +) -> Result { + #[cfg(any(feature = "electrum", feature = "esplora", feature = "rpc"))] + let url = &wallet_opts.url; + let client = match wallet_opts.client_type { + #[cfg(feature = "electrum")] + ClientType::Electrum => { + let client = bdk_electrum::electrum_client::Client::new(url) + .map(bdk_electrum::BdkElectrumClient::new)?; + BlockchainClient::Electrum { + client: Box::new(client), + batch_size: wallet_opts.batch_size, + } + } + #[cfg(feature = "esplora")] + ClientType::Esplora => { + let client = bdk_esplora::esplora_client::Builder::new(url).build_async()?; + BlockchainClient::Esplora { + client: Box::new(client), + parallel_requests: wallet_opts.parallel_requests, + } + } + + #[cfg(feature = "rpc")] + ClientType::Rpc => { + let auth = match &wallet_opts.cookie { + Some(cookie) => bdk_bitcoind_rpc::bitcoincore_rpc::Auth::CookieFile(cookie.into()), + None => bdk_bitcoind_rpc::bitcoincore_rpc::Auth::UserPass( + wallet_opts.basic_auth.0.clone(), + wallet_opts.basic_auth.1.clone(), + ), + }; + let client = bdk_bitcoind_rpc::bitcoincore_rpc::Client::new(url, auth) + .map_err(|e| Error::Generic(e.to_string()))?; + BlockchainClient::RpcClient { + client: Box::new(client), + } + } + + #[cfg(feature = "cbf")] + ClientType::Cbf => { + let scan_type = bdk_kyoto::ScanType::Sync; + let builder = bdk_kyoto::builder::Builder::new(_wallet.network()); + + let light_client = builder + .required_peers(wallet_opts.compactfilter_opts.conn_count) + .data_dir(&_datadir) + .build_with_wallet(_wallet, scan_type)?; + + let LightClient { + requester, + info_subscriber, + warning_subscriber, + update_subscriber, + node, + } = light_client; + + let subscriber = tracing_subscriber::FmtSubscriber::new(); + let _ = tracing::subscriber::set_global_default(subscriber); + + tokio::task::spawn(async move { node.run().await }); + tokio::task::spawn( + async move { trace_logger(info_subscriber, warning_subscriber).await }, + ); + + BlockchainClient::KyotoClient { + client: Box::new(KyotoClientHandle { + requester, + update_subscriber: tokio::sync::Mutex::new(update_subscriber), + }), + } + } + }; + Ok(client) +} + +// Handle Kyoto Client sync +#[cfg(feature = "cbf")] +pub async fn sync_kyoto_client( + wallet: &mut Wallet, + handle: &KyotoClientHandle, +) -> Result<(), Error> { + if !handle.requester.is_running() { + tracing::error!("Kyoto node is not running"); + return Err(Error::Generic("Kyoto node failed to start".to_string())); + } + tracing::info!("Kyoto node is running"); + + let update = handle.update_subscriber.lock().await.update().await?; + tracing::info!("Received update: applying to wallet"); + let events = wallet + .apply_update_events(update) + .map_err(|e| Error::Generic(format!("Failed to apply update: {e}")))?; + crate::utils::print_wallet_events(&events); + + tracing::info!( + "Chain tip: {}, Transactions: {}, Balance: {}", + wallet.local_chain().tip().height(), + wallet.transactions().count(), + wallet.balance().total().to_sat() + ); + + tracing::info!( + "Sync completed: tx_count={}, balance={}", + wallet.transactions().count(), + wallet.balance().total().to_sat() + ); + + Ok(()) +} diff --git a/src/commands.rs b/src/commands.rs index 849bb38a..529ef26b 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -13,21 +13,50 @@ //! All subcommands are defined in the below enums. #![allow(clippy::large_enum_variant)] +#[cfg(feature = "bip322")] +use crate::handlers::offline::{SignMessageCommand, VerifyMessageCommand}; +use crate::handlers::{ + config::{ListWalletsCommand, SaveConfigCommand}, + descriptor::DescriptorCommand, + key::{DeriveKeyCommand, GenerateKeyCommand, RestoreKeyCommand}, + offline::{ + BalanceCommand, BumpFeeCommand, CombinePsbtCommand, CreateTxCommand, ExtractPsbtCommand, + FinalizePsbtCommand, NewAddressCommand, PoliciesCommand, PublicDescriptorCommand, + SignCommand, TransactionsCommand, UnspentCommand, UnusedAddressCommand, + }, +}; + #[cfg(feature = "silent-payments")] -use {crate::utils::parse_sp_code_value_pairs, bdk_sp::encoding::SilentPaymentCode}; +use crate::handlers::{descriptor::SilentPaymentCodeCommand, offline::CreateSpTxCommand}; -use bdk_wallet::bitcoin::{ - Address, Network, OutPoint, ScriptBuf, - bip32::{DerivationPath, Xpriv}, +#[cfg(any( + feature = "electrum", + feature = "esplora", + feature = "rpc", + feature = "cbf" +))] +use crate::{ + client::ClientType, + handlers::online::{ + BroadcastCommand, FullScanCommand, PayjoinHistoryCommand, ReceivePayjoinCommand, + ResumePayjoinCommand, SendPayjoinCommand, SyncCommand, + }, }; -use clap::{Args, Parser, Subcommand, ValueEnum, value_parser}; + +#[cfg(feature = "compiler")] +use crate::handlers::descriptor::CompileCommand; +#[cfg(any(feature = "sqlite", feature = "redb"))] +use crate::persister::DatabaseType; + +use bdk_wallet::bitcoin::Network; +use clap::{Args, Parser, Subcommand, value_parser}; use clap_complete::Shell; #[cfg(feature = "dns_payment")] -use crate::utils::parse_dns_recipient; +use crate::handlers::dns::{CreateDnsTxCommand, ResolveDnsRecipientCommand}; + #[cfg(any(feature = "electrum", feature = "esplora", feature = "rpc"))] use crate::utils::parse_proxy_auth; -use crate::utils::{parse_address, parse_outpoint, parse_recipient}; /// The BDK Command Line Wallet App /// @@ -56,9 +85,6 @@ pub struct CliOpts { /// Default value : ~/.bdk-bitcoin #[arg(env = "DATADIR", short = 'd', long = "datadir")] pub datadir: Option, - /// Output results in pretty format (instead of JSON). - #[arg(long = "pretty", global = true)] - pub pretty: bool, /// Top level cli sub-commands. #[command(subcommand)] pub subcommand: CliSubCommand, @@ -96,15 +122,7 @@ pub enum CliSubCommand { /// Compile a miniscript policy to an output descriptor. #[cfg(feature = "compiler")] #[clap(long_about = "Miniscript policy compiler")] - Compile { - /// Sets the spending policy to compile. - #[arg(env = "POLICY", required = true, index = 1)] - policy: String, - /// Sets the script type used to embed the compiled policy. - #[arg(env = "TYPE", short = 't', long = "type", default_value = "wsh", value_parser = ["sh","wsh", "sh-wsh", "tr"] - )] - script_type: String, - }, + Compile(CompileCommand), #[cfg(feature = "repl")] /// REPL command loop mode. /// @@ -115,24 +133,15 @@ pub enum CliSubCommand { #[arg(env = "WALLET_NAME", short = 'w', long = "wallet", required = true)] wallet: String, }, + /// Output Descriptors operations. /// /// Generate output descriptors from either extended key (Xprv/Xpub) or mnemonic phrase. /// This feature is intended for development and testing purposes only. - Descriptor { - /// Descriptor type (script type) - #[arg( - long = "type", - short = 't', - value_parser = ["pkh", "wpkh", "sh", "wsh", "tr"], - default_value = "wsh" - )] - desc_type: String, - /// Optional key: xprv, xpub, or mnemonic phrase - key: Option, - }, + Descriptor(DescriptorCommand), + /// List all saved wallet configurations. - Wallets, + Wallets(ListWalletsCommand), /// Generate tab-completion scripts for your shell. /// /// The completion script is output on stdout, allowing you to redirect @@ -192,38 +201,17 @@ pub enum CliSubCommand { /// Allows the encoding of two public keys into a silent payment code. /// Useful to create silent payment transactions using fake silent payment codes. #[cfg(feature = "silent-payments")] - SilentPaymentCode { - /// The scan public key to use on the silent payment code. - #[arg(long = "scan_key")] - scan: bdk_sp::bitcoin::secp256k1::PublicKey, - /// The spend public key to use on the silent payment code. - #[arg(long = "spend_key")] - spend: bdk_sp::bitcoin::secp256k1::PublicKey, - }, - - #[cfg(feature = "dns_payment")] + SilentPaymentCode(SilentPaymentCodeCommand), /// Resolves BIP-353 DNS payment instructions for a human-readable name. - ResolveDnsRecipient { - /// Human-readable name (e.g. user@domain.com) - hrn: String, - /// DNS resolver address - #[arg(long, default_value = "8.8.8.8")] - resolver: String, - }, + #[cfg(feature = "dns_payment")] + ResolveDnsRecipient(ResolveDnsRecipientCommand), } /// Wallet operation subcommands. #[derive(Debug, Subcommand, Clone, PartialEq)] pub enum WalletSubCommand { /// Save wallet configuration to `config.toml`. - Config { - /// Overwrite existing wallet configuration if it exists. - #[arg(short = 'f', long = "force", default_value_t = false)] - force: bool, - - #[command(flatten)] - wallet_opts: WalletOpts, - }, + Config(SaveConfigCommand), #[cfg(any( feature = "electrum", feature = "esplora", @@ -236,43 +224,12 @@ pub enum WalletSubCommand { OfflineWalletSubCommand(OfflineWalletSubCommand), } -#[derive(Clone, ValueEnum, Debug, Eq, PartialEq)] -pub enum DatabaseType { - /// Sqlite database - #[cfg(feature = "sqlite")] - Sqlite, - /// Redb database - #[cfg(feature = "redb")] - Redb, -} - -#[cfg(any( - feature = "electrum", - feature = "esplora", - feature = "rpc", - feature = "cbf" -))] -#[derive(Clone, ValueEnum, Debug, Eq, PartialEq)] -pub enum ClientType { - #[cfg(feature = "electrum")] - Electrum, - #[cfg(feature = "esplora")] - Esplora, - #[cfg(feature = "rpc")] - Rpc, - #[cfg(feature = "cbf")] - Cbf, -} - /// Config options wallet operations can take. #[derive(Debug, Args, Clone, PartialEq, Eq)] pub struct WalletOpts { /// Selects the wallet to use. #[arg(skip)] pub wallet: Option, - /// Adds verbosity, returns PSBT in JSON format alongside serialized, displays expanded objects. - #[arg(env = "VERBOSE", short = 'v', long = "verbose")] - pub verbose: bool, /// Sets the descriptor to use for the external addresses. #[arg(env = "EXT_DESCRIPTOR", short = 'e', long, required = true)] pub ext_descriptor: String, @@ -366,265 +323,49 @@ pub struct CompactFilterOpts { #[command(rename_all = "snake")] pub enum OfflineWalletSubCommand { /// Get a new external address. - NewAddress, + NewAddress(NewAddressCommand), /// Get the first unused external address. - UnusedAddress, + UnusedAddress(UnusedAddressCommand), /// Lists the available spendable UTXOs. - Unspent, + Unspent(UnspentCommand), /// Lists all the incoming and outgoing transactions of the wallet. - Transactions, + Transactions(TransactionsCommand), /// Returns the current wallet balance. - Balance, + Balance(BalanceCommand), /// Creates a new unsigned transaction. - CreateTx { - /// Adds a recipient to the transaction. - // Clap Doesn't support complex vector parsing https://github.com/clap-rs/clap/issues/1704. - // Address and amount parsing is done at run time in handler function. - #[arg(env = "ADDRESS:SAT", long = "to", required = true, value_parser = parse_recipient)] - recipients: Vec<(ScriptBuf, u64)>, - /// Sends all the funds (or all the selected utxos). Requires only one recipient with value 0. - #[arg(long = "send_all", short = 'a')] - send_all: bool, - /// Enables Replace-By-Fee (BIP125). - #[arg(long = "enable_rbf", short = 'r', default_value_t = true)] - enable_rbf: bool, - /// Make a PSBT that can be signed by offline signers and hardware wallets. Forces the addition of `non_witness_utxo` and more details to let the signer identify the change output. - #[arg(long = "offline_signer")] - offline_signer: bool, - /// Selects which utxos *must* be spent. - #[arg(env = "MUST_SPEND_TXID:VOUT", long = "utxos", value_parser = parse_outpoint)] - utxos: Option>, - /// Marks a utxo as unspendable. - #[arg(env = "CANT_SPEND_TXID:VOUT", long = "unspendable", value_parser = parse_outpoint)] - unspendable: Option>, - /// Fee rate to use in sat/vbyte. - #[arg(env = "SATS_VBYTE", short = 'f', long = "fee_rate")] - fee_rate: Option, - /// Selects which policy should be used to satisfy the external descriptor. - #[arg(env = "EXT_POLICY", long = "external_policy")] - external_policy: Option, - /// Selects which policy should be used to satisfy the internal descriptor. - #[arg(env = "INT_POLICY", long = "internal_policy")] - internal_policy: Option, - /// Optionally create an OP_RETURN output containing given String in utf8 encoding (max 80 bytes) - #[arg( - env = "ADD_STRING", - long = "add_string", - short = 's', - conflicts_with = "add_data" - )] - add_string: Option, - /// Optionally create an OP_RETURN output containing given base64 encoded String. (max 80 bytes) - #[arg( - env = "ADD_DATA", - long = "add_data", - short = 'o', - conflicts_with = "add_string" - )] - add_data: Option, //base 64 econding - }, - #[cfg(feature = "dns_payment")] - /// Creates a new unsigned transaction from DNS payment instructions. - CreateDnsTx { - /// Adds a recipient to the transaction. - #[arg(env = "ADDRESS:SAT", long = "to", value_parser = parse_recipient)] - recipients: Vec<(ScriptBuf, u64)>, - #[arg(long = "to_dns", value_parser = parse_dns_recipient)] - dns_recipients: Vec<(String, u64)>, - #[arg(long = "dns_resolver", default_value = "8.8.8.8")] - dns_resolver: String, - /// Sends all the funds (or all the selected utxos). Requires only one recipient with value 0. - #[arg(long = "send_all", short = 'a')] - send_all: bool, - /// Enables Replace-By-Fee (BIP125). - #[arg(long = "enable_rbf", short = 'r', default_value_t = true)] - enable_rbf: bool, - /// Make a PSBT that can be signed by offline signers and hardware wallets. Forces the addition of `non_witness_utxo` and more details to let the signer identify the change output. - #[arg(long = "offline_signer")] - offline_signer: bool, - /// Selects which utxos *must* be spent. - #[arg(env = "MUST_SPEND_TXID:VOUT", long = "utxos", value_parser = parse_outpoint)] - utxos: Option>, - /// Marks a utxo as unspendable. - #[arg(env = "CANT_SPEND_TXID:VOUT", long = "unspendable", value_parser = parse_outpoint)] - unspendable: Option>, - /// Fee rate to use in sat/vbyte. - #[arg(env = "SATS_VBYTE", short = 'f', long = "fee_rate")] - fee_rate: Option, - /// Selects which policy should be used to satisfy the external descriptor. - #[arg(env = "EXT_POLICY", long = "external_policy")] - external_policy: Option, - /// Selects which policy should be used to satisfy the internal descriptor. - #[arg(env = "INT_POLICY", long = "internal_policy")] - internal_policy: Option, - /// Optionally create an OP_RETURN output containing given String in utf8 encoding (max 80 bytes) - #[arg( - env = "ADD_STRING", - long = "add_string", - short = 's', - conflicts_with = "add_data" - )] - add_string: Option, - /// Optionally create an OP_RETURN output containing given base64 encoded String. (max 80 bytes) - #[arg( - env = "ADD_DATA", - long = "add_data", - short = 'o', - conflicts_with = "add_string" - )] - add_data: Option, //base 64 econding - }, + CreateTx(CreateTxCommand), /// Creates a silent payment transaction /// /// This sub-command is **EXPERIMENTAL** and should only be used for testing. Do not use this /// feature to create transactions that spend actual funds on the Bitcoin mainnet. - // This command DOES NOT return a PSBT. Instead, it directly returns a signed transaction // ready for broadcast, as it is not yet possible to perform a shared derivation of a silent // payment script pubkey in a secure and trustless manner. #[cfg(feature = "silent-payments")] - CreateSpTx { - /// Adds a recipient to the transaction. - // Clap Doesn't support complex vector parsing https://github.com/clap-rs/clap/issues/1704. - // Address and amount parsing is done at run time in handler function. - #[arg(env = "ADDRESS:SAT", long = "to", required = false, value_parser = parse_recipient)] - recipients: Option>, - /// Parse silent payment recipients - #[arg(long = "to-sp", required = true, value_parser = parse_sp_code_value_pairs)] - silent_payment_recipients: Vec<(SilentPaymentCode, u64)>, - /// Sends all the funds (or all the selected utxos). Requires only one recipient with value 0. - #[arg(long = "send_all", short = 'a')] - send_all: bool, - /// Make a PSBT that can be signed by offline signers and hardware wallets. Forces the addition of `non_witness_utxo` and more details to let the signer identify the change output. - #[arg(long = "offline_signer")] - offline_signer: bool, - /// Selects which utxos *must* be spent. - #[arg(env = "MUST_SPEND_TXID:VOUT", long = "utxos", value_parser = parse_outpoint)] - utxos: Option>, - /// Marks a utxo as unspendable. - #[arg(env = "CANT_SPEND_TXID:VOUT", long = "unspendable", value_parser = parse_outpoint)] - unspendable: Option>, - /// Fee rate to use in sat/vbyte. - #[arg(env = "SATS_VBYTE", short = 'f', long = "fee_rate")] - fee_rate: Option, - /// Selects which policy should be used to satisfy the external descriptor. - #[arg(env = "EXT_POLICY", long = "external_policy")] - external_policy: Option, - /// Selects which policy should be used to satisfy the internal descriptor. - #[arg(env = "INT_POLICY", long = "internal_policy")] - internal_policy: Option, - /// Optionally create an OP_RETURN output containing given String in utf8 encoding (max 80 bytes) - #[arg( - env = "ADD_STRING", - long = "add_string", - short = 's', - conflicts_with = "add_data" - )] - add_string: Option, - /// Optionally create an OP_RETURN output containing given base64 encoded String. (max 80 bytes) - #[arg( - env = "ADD_DATA", - long = "add_data", - short = 'o', - conflicts_with = "add_string" - )] - add_data: Option, //base 64 econding - }, + CreateSpTx(CreateSpTxCommand), /// Bumps the fees of an RBF transaction. - BumpFee { - /// TXID of the transaction to update. - #[arg(env = "TXID", long = "txid")] - txid: String, - /// Allows the wallet to reduce the amount to the specified address in order to increase fees. - #[arg(env = "SHRINK_ADDRESS", long = "shrink", value_parser = parse_address)] - shrink_address: Option
, - /// Make a PSBT that can be signed by offline signers and hardware wallets. Forces the addition of `non_witness_utxo` and more details to let the signer identify the change output. - #[arg(long = "offline_signer")] - offline_signer: bool, - /// Selects which utxos *must* be added to the tx. Unconfirmed utxos cannot be used. - #[arg(env = "MUST_SPEND_TXID:VOUT", long = "utxos", value_parser = parse_outpoint)] - utxos: Option>, - /// Marks an utxo as unspendable, in case more inputs are needed to cover the extra fees. - #[arg(env = "CANT_SPEND_TXID:VOUT", long = "unspendable", value_parser = parse_outpoint)] - unspendable: Option>, - /// The new targeted fee rate in sat/vbyte. - #[arg( - env = "SATS_VBYTE", - short = 'f', - long = "fee_rate", - default_value = "1.0" - )] - fee_rate: f32, - }, + BumpFee(BumpFeeCommand), /// Returns the available spending policies for the descriptor. - Policies, + Policies(PoliciesCommand), /// Returns the public version of the wallet's descriptor(s). - PublicDescriptor, + PublicDescriptor(PublicDescriptorCommand), /// Signs and tries to finalize a PSBT. - Sign { - /// Sets the PSBT to sign. - #[arg(env = "BASE64_PSBT")] - psbt: String, - /// Assume the blockchain has reached a specific height. This affects the transaction finalization, if there are timelocks in the descriptor. - #[arg(env = "HEIGHT", long = "assume_height")] - assume_height: Option, - /// Whether the signer should trust the witness_utxo, if the non_witness_utxo hasn’t been provided. - #[arg(env = "WITNESS", long = "trust_witness_utxo")] - trust_witness_utxo: Option, - }, + Sign(SignCommand), /// Extracts a raw transaction from a PSBT. - ExtractPsbt { - /// Sets the PSBT to extract - #[arg(env = "BASE64_PSBT")] - psbt: String, - }, + ExtractPsbt(ExtractPsbtCommand), /// Finalizes a PSBT. - FinalizePsbt { - /// Sets the PSBT to finalize. - #[arg(env = "BASE64_PSBT")] - psbt: String, - /// Assume the blockchain has reached a specific height. - #[arg(env = "HEIGHT", long = "assume_height")] - assume_height: Option, - /// Whether the signer should trust the witness_utxo, if the non_witness_utxo hasn’t been provided. - #[arg(env = "WITNESS", long = "trust_witness_utxo")] - trust_witness_utxo: Option, - }, + FinalizePsbt(FinalizePsbtCommand), /// Combines multiple PSBTs into one. - CombinePsbt { - /// Add one PSBT to combine. This option can be repeated multiple times, one for each PSBT. - #[arg(env = "BASE64_PSBT", required = true)] - psbt: Vec, - }, + CombinePsbt(CombinePsbtCommand), /// Sign a message using BIP322 #[cfg(feature = "bip322")] - SignMessage { - /// The message to sign - #[arg(long)] - message: String, - /// The signature format (e.g., Legacy, Simple, Full) - #[arg(long, default_value = "simple")] - signature_type: String, - /// Address to sign - #[arg(long)] - address: String, - /// Optional list of specific UTXOs for proof-of-funds (only for `FullWithProofOfFunds`) - #[arg(long)] - utxos: Option>, - }, + SignMessage(SignMessageCommand), /// Verify a BIP322 signature #[cfg(feature = "bip322")] - VerifyMessage { - /// The signature proof to verify - #[arg(long)] - proof: String, - /// The message that was signed - #[arg(long)] - message: String, - /// The address associated with the signature - #[arg(long)] - address: String, - }, + VerifyMessage(VerifyMessageCommand), + /// Creates a new unsigned transaction from DNS payment instructions. + #[cfg(feature = "dns_payment")] + CreateDnsTx(CreateDnsTxCommand), } /// Wallet subcommands that needs a blockchain backend. @@ -638,118 +379,30 @@ pub enum OfflineWalletSubCommand { ))] pub enum OnlineWalletSubCommand { /// Full Scan with the chosen blockchain server. - FullScan { - /// Stop searching addresses for transactions after finding an unused gap of this length. - #[arg(env = "STOP_GAP", long = "scan-stop-gap", default_value = "20")] - stop_gap: usize, - }, + FullScan(FullScanCommand), /// Syncs with the chosen blockchain server. - Sync, + Sync(SyncCommand), /// Broadcasts a transaction to the network. Takes either a raw transaction or a PSBT to extract. - Broadcast { - /// Sets the PSBT to sign. - #[arg( - env = "BASE64_PSBT", - long = "psbt", - required_unless_present = "tx", - conflicts_with = "tx" - )] - psbt: Option, - /// Sets the raw transaction to broadcast. - #[arg( - env = "RAWTX", - long = "tx", - required_unless_present = "psbt", - conflicts_with = "psbt" - )] - tx: Option, - }, + Broadcast(BroadcastCommand), /// Generates a Payjoin receive URI and processes the sender's Payjoin proposal. - ReceivePayjoin { - /// Amount to be received in sats. - #[arg(env = "PAYJOIN_AMOUNT", long = "amount", required = true)] - amount: u64, - /// Payjoin directory which will be used to store the PSBTs which are pending action - /// from one of the parties. - #[arg(env = "PAYJOIN_DIRECTORY", long = "directory", required = true)] - directory: String, - /// URL of the Payjoin OHTTP relay. Can be repeated multiple times to attempt the - /// operation with multiple relays for redundancy. - #[arg(env = "PAYJOIN_OHTTP_RELAY", long = "ohttp_relay", required = true)] - ohttp_relay: Vec, - /// Maximum effective fee rate the receiver is willing to pay for their own input/output contributions. - #[arg(env = "PAYJOIN_RECEIVER_MAX_FEE_RATE", long = "max_fee_rate")] - max_fee_rate: Option, - }, + ReceivePayjoin(ReceivePayjoinCommand), /// Sends an original PSBT to a BIP 21 URI and broadcasts the returned Payjoin PSBT. - SendPayjoin { - /// BIP 21 URI for the Payjoin. - #[arg(env = "PAYJOIN_URI", long = "uri", required = true)] - uri: String, - /// URL of the Payjoin OHTTP relay. Can be repeated multiple times to attempt the - /// operation with multiple relays for redundancy. - #[arg(env = "PAYJOIN_OHTTP_RELAY", long = "ohttp_relay", required = true)] - ohttp_relay: Vec, - /// Fee rate to use in sat/vbyte. - #[arg( - env = "PAYJOIN_SENDER_FEE_RATE", - short = 'f', - long = "fee_rate", - required = true - )] - fee_rate: u64, - }, + SendPayjoin(SendPayjoinCommand), /// Resume pending payjoin sessions. - ResumePayjoin { - /// Payjoin directory for the session - #[arg(env = "PAYJOIN_DIRECTORY", long = "directory", required = true)] - directory: String, - /// URL of the Payjoin OHTTP relay. Can be repeated multiple times. - #[arg(env = "PAYJOIN_OHTTP_RELAY", long = "ohttp_relay", required = true)] - ohttp_relay: Vec, - /// Resume only a specific active session ID (sender and/or receiver). - #[arg(env = "PAYJOIN_SESSION_ID", long = "session_id")] - session_id: Option, - }, + ResumePayjoin(ResumePayjoinCommand), /// Show payjoin session history. - PayjoinHistory, + PayjoinHistory(PayjoinHistoryCommand), } /// Subcommands for Key operations. #[derive(Debug, Subcommand, Clone, PartialEq, Eq)] pub enum KeySubCommand { /// Generates new random seed mnemonic phrase and corresponding master extended key. - Generate { - /// Entropy level based on number of random seed mnemonic words. - #[arg( - env = "WORD_COUNT", - short = 'e', - long = "entropy", - default_value = "12" - )] - word_count: usize, - /// Seed password. - #[arg(env = "PASSWORD", short = 'p', long = "password")] - password: Option, - }, + Generate(GenerateKeyCommand), /// Restore a master extended key from seed backup mnemonic words. - Restore { - /// Seed mnemonic words, must be quoted (eg. "word1 word2 ..."). - #[arg(env = "MNEMONIC", short = 'm', long = "mnemonic")] - mnemonic: String, - /// Seed password. - #[arg(env = "PASSWORD", short = 'p', long = "password")] - password: Option, - }, + Restore(RestoreKeyCommand), /// Derive a child key pair from a master extended key and a derivation path string (eg. "m/84'/1'/0'/0" or "m/84h/1h/0h/0"). - Derive { - /// Extended private key to derive from. - #[arg(env = "XPRV", short = 'x', long = "xprv")] - xprv: Xpriv, - /// Path to use to derive extended public key from extended private key. - #[arg(env = "PATH", short = 'p', long = "path")] - path: DerivationPath, - }, + Derive(DeriveKeyCommand), } /// Subcommands available in REPL mode. @@ -768,18 +421,7 @@ pub enum ReplSubCommand { subcommand: KeySubCommand, }, /// Generate descriptors - Descriptor { - /// Descriptor type (script type). - #[arg( - long = "type", - short = 't', - value_parser = ["pkh", "wpkh", "sh", "wsh", "tr"], - default_value = "wsh" - )] - desc_type: String, - /// Optional key: xprv, xpub, or mnemonic phrase - key: Option, - }, + Descriptor(DescriptorCommand), /// Exit REPL loop. Exit, } diff --git a/src/config.rs b/src/config.rs index 197dc07c..6865ee09 100644 --- a/src/config.rs +++ b/src/config.rs @@ -4,11 +4,11 @@ feature = "rpc", feature = "cbf" ))] -use crate::commands::ClientType; -#[cfg(feature = "sqlite")] -use crate::commands::DatabaseType; +use crate::client::ClientType; use crate::commands::WalletOpts; use crate::error::BDKCliError as Error; +#[cfg(feature = "sqlite")] +use crate::persister::DatabaseType; use bdk_wallet::bitcoin::Network; #[cfg(any(feature = "sqlite", feature = "redb"))] use clap::ValueEnum; @@ -117,7 +117,6 @@ impl TryFrom<&WalletConfigInner> for WalletOpts { Ok(WalletOpts { wallet: Some(config.wallet.clone()), - verbose: false, ext_descriptor: config.ext_descriptor.clone(), int_descriptor: config.int_descriptor.clone(), diff --git a/src/error.rs b/src/error.rs index dbdc7b81..7f7eab04 100644 --- a/src/error.rs +++ b/src/error.rs @@ -154,7 +154,7 @@ pub enum BDKCliError { #[cfg(feature = "payjoin")] #[error("Payjoin database error: {0}")] - PayjoinDb(#[from] crate::payjoin::db::Error), + PayjoinDb(#[from] crate::handlers::payjoin::db::Error), #[cfg(feature = "bip322")] #[error("BIP-322 error: {0}")] diff --git a/src/handlers.rs b/src/handlers.rs deleted file mode 100644 index dc36d547..00000000 --- a/src/handlers.rs +++ /dev/null @@ -1,2331 +0,0 @@ -// Copyright (c) 2020-2025 Bitcoin Dev Kit Developers -// -// This file is licensed under the Apache License, Version 2.0 or the MIT license -// , at your option. -// You may not use this file except in accordance with one or both of these -// licenses. - -//! Command Handlers -//! -//! This module describes all the command handling logic used by bdk-cli. -use crate::commands::OfflineWalletSubCommand::*; -use crate::commands::*; -use crate::config::{WalletConfig, WalletConfigInner}; -#[cfg(feature = "dns_payment")] -use crate::dns_payment_instructions::{ - parse_dns_instructions, process_instructions, resolve_dns_recipient, -}; -use crate::error::BDKCliError as Error; -#[cfg(any(feature = "sqlite", feature = "redb"))] -use crate::persister::Persister; -#[cfg(feature = "cbf")] -use crate::utils::BlockchainClient::KyotoClient; -use crate::utils::*; -#[cfg(feature = "redb")] -use bdk_redb::Store as RedbStore; -use bdk_wallet::bip39::{Language, Mnemonic}; -use bdk_wallet::bitcoin::base64::Engine; -use bdk_wallet::bitcoin::base64::prelude::BASE64_STANDARD; -use bdk_wallet::bitcoin::{ - Address, Amount, FeeRate, Network, Psbt, Sequence, Txid, - bip32::{DerivationPath, KeySource}, - consensus::encode::serialize_hex, - script::PushBytesBuf, - secp256k1::Secp256k1, -}; -use bdk_wallet::chain::ChainPosition; -use bdk_wallet::descriptor::Segwitv0; -use bdk_wallet::keys::{ - DerivableKey, DescriptorKey, DescriptorKey::Secret, ExtendedKey, GeneratableKey, GeneratedKey, - bip39::WordCount, -}; -use bdk_wallet::miniscript::miniscript; -#[cfg(feature = "sqlite")] -use bdk_wallet::rusqlite::Connection; -use bdk_wallet::{KeychainKind, SignOptions, Wallet}; - -#[cfg(feature = "compiler")] -use bdk_wallet::{ - bitcoin::{ - XOnlyPublicKey, - key::{Parity, rand}, - secp256k1::{Scalar, SecretKey}, - }, - descriptor::{Descriptor, Legacy}, - miniscript::{Tap, descriptor::TapTree, policy::Concrete}, -}; - -use clap::CommandFactory; -use cli_table::{Cell, CellStruct, Style, Table, format::Justify}; -use serde_json::json; -#[cfg(feature = "compiler")] -use tap::Pipe; -#[cfg(feature = "silent-payments")] -use { - bdk_sp::{ - bitcoin::{PrivateKey, PublicKey, ScriptBuf}, - encoding::SilentPaymentCode, - send::psbt::derive_sp, - }, - bdk_wallet::keys::{DescriptorPublicKey, DescriptorSecretKey, SinglePubKey}, -}; - -#[cfg(feature = "electrum")] -use crate::utils::BlockchainClient::Electrum; -#[cfg(any(feature = "electrum", feature = "esplora"))] -use std::collections::HashSet; -use std::collections::{BTreeMap, HashMap}; -use std::convert::TryFrom; -#[cfg(any(feature = "repl", feature = "electrum", feature = "esplora"))] -use std::io::Write; -use std::path::Path; -use std::str::FromStr; -#[cfg(any(feature = "redb", feature = "compiler"))] -use std::sync::Arc; - -#[cfg(feature = "bip322")] -use crate::error::BDKCliError; -#[cfg(feature = "bip322")] -use bdk_bip322::{BIP322, MessageProof, MessageVerificationResult}; - -#[cfg(any( - feature = "electrum", - feature = "esplora", - feature = "cbf", - feature = "rpc" -))] -use { - crate::commands::OnlineWalletSubCommand::*, - crate::payjoin::PayjoinManager, - bdk_wallet::bitcoin::{Transaction, consensus::Decodable, hex::FromHex}, -}; -#[cfg(feature = "esplora")] -use {crate::utils::BlockchainClient::Esplora, bdk_esplora::EsploraAsyncExt}; -#[cfg(feature = "rpc")] -use { - crate::utils::BlockchainClient::RpcClient, - bdk_bitcoind_rpc::{Emitter, NO_EXPECTED_MEMPOOL_TXS, bitcoincore_rpc::RpcApi}, - bdk_wallet::chain::{BlockId, CanonicalizationParams, CheckPoint}, -}; - -#[cfg(feature = "compiler")] -const NUMS_UNSPENDABLE_KEY_HEX: &str = - "50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0"; - -/// Execute an offline wallet sub-command -/// -/// Offline wallet sub-commands are described in [`OfflineWalletSubCommand`]. -pub async fn handle_offline_wallet_subcommand( - wallet: &mut Wallet, - wallet_opts: &WalletOpts, - cli_opts: &CliOpts, - offline_subcommand: OfflineWalletSubCommand, -) -> Result { - match offline_subcommand { - NewAddress => { - let addr = wallet.reveal_next_address(KeychainKind::External); - if cli_opts.pretty { - let table = vec![ - vec!["Address".cell().bold(true), addr.address.to_string().cell()], - vec![ - "Index".cell().bold(true), - addr.index.to_string().cell().justify(Justify::Right), - ], - ] - .table() - .display() - .map_err(|e| Error::Generic(e.to_string()))?; - Ok(format!("{table}")) - } else if wallet_opts.verbose { - Ok(serde_json::to_string_pretty(&json!({ - "address": addr.address, - "index": addr.index - }))?) - } else { - Ok(serde_json::to_string_pretty(&json!({ - "address": addr.address, - }))?) - } - } - UnusedAddress => { - let addr = wallet.next_unused_address(KeychainKind::External); - - if cli_opts.pretty { - let table = vec![ - vec!["Address".cell().bold(true), addr.address.to_string().cell()], - vec![ - "Index".cell().bold(true), - addr.index.to_string().cell().justify(Justify::Right), - ], - ] - .table() - .display() - .map_err(|e| Error::Generic(e.to_string()))?; - Ok(format!("{table}")) - } else if wallet_opts.verbose { - Ok(serde_json::to_string_pretty(&json!({ - "address": addr.address, - "index": addr.index - }))?) - } else { - Ok(serde_json::to_string_pretty(&json!({ - "address": addr.address, - }))?) - } - } - Unspent => { - let utxos = wallet.list_unspent().collect::>(); - if cli_opts.pretty { - let mut rows: Vec> = vec![]; - for utxo in &utxos { - let height = utxo - .chain_position - .confirmation_height_upper_bound() - .map(|h| h.to_string()) - .unwrap_or("Pending".to_string()); - - let block_hash = match &utxo.chain_position { - ChainPosition::Confirmed { anchor, .. } => anchor.block_id.hash.to_string(), - ChainPosition::Unconfirmed { .. } => "Unconfirmed".to_string(), - }; - - rows.push(vec![ - shorten(utxo.outpoint, 8, 10).cell(), - utxo.txout - .value - .to_sat() - .to_string() - .cell() - .justify(Justify::Right), - Address::from_script(&utxo.txout.script_pubkey, cli_opts.network) - .unwrap() - .cell(), - utxo.keychain.cell(), - utxo.is_spent.cell(), - utxo.derivation_index.cell(), - height.to_string().cell().justify(Justify::Right), - shorten(block_hash, 8, 8).cell().justify(Justify::Right), - ]); - } - let table = rows - .table() - .title(vec![ - "Outpoint".cell().bold(true), - "Output (sat)".cell().bold(true), - "Output Address".cell().bold(true), - "Keychain".cell().bold(true), - "Is Spent".cell().bold(true), - "Index".cell().bold(true), - "Block Height".cell().bold(true), - "Block Hash".cell().bold(true), - ]) - .display() - .map_err(|e| Error::Generic(e.to_string()))?; - Ok(format!("{table}")) - } else { - Ok(serde_json::to_string_pretty(&utxos)?) - } - } - Transactions => { - let transactions = wallet.transactions(); - - if cli_opts.pretty { - let txns = transactions - .map(|tx| { - let total_value = tx - .tx_node - .output - .iter() - .map(|output| output.value.to_sat()) - .sum::(); - ( - tx.tx_node.txid.to_string(), - tx.tx_node.version, - tx.tx_node.is_explicitly_rbf(), - tx.tx_node.input.len(), - tx.tx_node.output.len(), - total_value, - ) - }) - .collect::>(); - let mut rows: Vec> = vec![]; - for (txid, version, is_rbf, input_count, output_count, total_value) in txns { - rows.push(vec![ - txid.cell(), - version.to_string().cell().justify(Justify::Right), - is_rbf.to_string().cell().justify(Justify::Center), - input_count.to_string().cell().justify(Justify::Right), - output_count.to_string().cell().justify(Justify::Right), - total_value.to_string().cell().justify(Justify::Right), - ]); - } - let table = rows - .table() - .title(vec![ - "Txid".cell().bold(true), - "Version".cell().bold(true), - "Is RBF".cell().bold(true), - "Input Count".cell().bold(true), - "Output Count".cell().bold(true), - "Total Value (sat)".cell().bold(true), - ]) - .display() - .map_err(|e| Error::Generic(e.to_string()))?; - Ok(format!("{table}")) - } else { - let txns: Vec<_> = transactions - .map(|tx| { - json!({ - "txid": tx.tx_node.txid, - "is_coinbase": tx.tx_node.is_coinbase(), - "wtxid": tx.tx_node.compute_wtxid(), - "version": tx.tx_node.version, - "is_rbf": tx.tx_node.is_explicitly_rbf(), - "inputs": tx.tx_node.input, - "outputs": tx.tx_node.output, - }) - }) - .collect(); - Ok(serde_json::to_string_pretty(&txns)?) - } - } - Balance => { - let balance = wallet.balance(); - if cli_opts.pretty { - let table = vec![ - vec!["Type".cell().bold(true), "Amount (sat)".cell().bold(true)], - vec![ - "Total".cell(), - balance - .total() - .to_sat() - .to_string() - .cell() - .justify(Justify::Right), - ], - vec![ - "Confirmed".cell(), - balance - .confirmed - .to_sat() - .to_string() - .cell() - .justify(Justify::Right), - ], - vec![ - "Unconfirmed".cell(), - balance - .immature - .to_sat() - .to_string() - .cell() - .justify(Justify::Right), - ], - vec![ - "Trusted Pending".cell(), - balance - .trusted_pending - .to_sat() - .cell() - .justify(Justify::Right), - ], - vec![ - "Untrusted Pending".cell(), - balance - .untrusted_pending - .to_sat() - .cell() - .justify(Justify::Right), - ], - ] - .table() - .display() - .map_err(|e| Error::Generic(e.to_string()))?; - Ok(format!("{table}")) - } else { - Ok(serde_json::to_string_pretty( - &json!({"satoshi": wallet.balance()}), - )?) - } - } - #[cfg(feature = "silent-payments")] - CreateSpTx { - recipients: maybe_recipients, - silent_payment_recipients, - send_all, - offline_signer, - utxos, - unspendable, - fee_rate, - external_policy, - internal_policy, - add_data, - add_string, - } => { - let mut tx_builder = wallet.build_tx(); - - let sp_recipients: Vec = silent_payment_recipients - .iter() - .map(|(sp_code, _)| sp_code.clone()) - .collect(); - - if send_all { - if sp_recipients.len() == 1 && maybe_recipients.is_none() { - tx_builder - .drain_wallet() - .drain_to(sp_recipients[0].get_placeholder_p2tr_spk()); - } else if let Some(ref recipients) = maybe_recipients - && sp_recipients.is_empty() - { - if recipients.len() == 1 { - tx_builder.drain_wallet().drain_to(recipients[0].0.clone()); - } else { - return Err(Error::Generic( - "Wallet can only be drain to a single output".to_string(), - )); - } - } else { - return Err(Error::Generic( - "Wallet can only be drain to a single output".to_string(), - )); - } - } else { - let mut outputs: Vec<(ScriptBuf, Amount)> = silent_payment_recipients - .iter() - .map(|(sp_code, amount)| { - let script = sp_code.get_placeholder_p2tr_spk(); - (script, Amount::from_sat(*amount)) - }) - .collect(); - - if let Some(recipients) = maybe_recipients { - let recipients = recipients - .into_iter() - .map(|(script, amount)| (script, Amount::from_sat(amount))); - - outputs.extend(recipients); - } - - tx_builder.set_recipients(outputs); - } - - // Do not enable RBF for this transaction - tx_builder.set_exact_sequence(Sequence::MAX); - - if offline_signer { - tx_builder.include_output_redeem_witness_script(); - } - - if let Some(fee_rate) = fee_rate - && let Some(fee_rate) = FeeRate::from_sat_per_vb(fee_rate as u64) - { - tx_builder.fee_rate(fee_rate); - } - - if let Some(utxos) = utxos { - tx_builder - .add_utxos(&utxos[..]) - .map_err(|_| bdk_wallet::error::CreateTxError::UnknownUtxo)?; - } - - if let Some(unspendable) = unspendable { - tx_builder.unspendable(unspendable); - } - - if let Some(base64_data) = add_data { - let op_return_data = BASE64_STANDARD - .decode(base64_data) - .map_err(|e| Error::Generic(e.to_string()))?; - tx_builder.add_data( - &PushBytesBuf::try_from(op_return_data) - .map_err(|e| Error::Generic(e.to_string()))?, - ); - } else if let Some(string_data) = add_string { - let data = PushBytesBuf::try_from(string_data.as_bytes().to_vec()) - .map_err(|e| Error::Generic(e.to_string()))?; - tx_builder.add_data(&data); - } - - let policies = vec![ - external_policy.map(|p| (p, KeychainKind::External)), - internal_policy.map(|p| (p, KeychainKind::Internal)), - ]; - - for (policy, keychain) in policies.into_iter().flatten() { - let policy = serde_json::from_str::>>(&policy)?; - tx_builder.policy_path(policy, keychain); - } - - let mut psbt = tx_builder.finish()?; - - let unsigned_psbt = psbt.clone(); - - let finalized = wallet.sign(&mut psbt, SignOptions::default())?; - - if !finalized { - return Err(Error::Generic( - "Cannot produce silent payment outputs without intermediate signing phase." - .to_string(), - )); - } - - for (full_input, psbt_input) in unsigned_psbt.inputs.iter().zip(psbt.inputs.iter_mut()) - { - // repopulate key derivation data - psbt_input.bip32_derivation = full_input.bip32_derivation.clone(); - psbt_input.tap_key_origins = full_input.tap_key_origins.clone(); - } - - let secp = Secp256k1::new(); - let mut external_signers = wallet.get_signers(KeychainKind::External).as_key_map(&secp); - let internal_signers = wallet.get_signers(KeychainKind::Internal).as_key_map(&secp); - external_signers.extend(internal_signers); - - match external_signers.iter().next().expect("not empty") { - (DescriptorPublicKey::Single(single_pub), DescriptorSecretKey::Single(prv)) => { - match single_pub.key { - SinglePubKey::FullKey(pk) => { - let keys: HashMap = [(pk, prv.key)].into(); - derive_sp(&mut psbt, &keys, &sp_recipients, &secp) - .expect("will fix later"); - } - SinglePubKey::XOnly(xonly) => { - let keys: HashMap = - [(xonly, prv.key)].into(); - derive_sp(&mut psbt, &keys, &sp_recipients, &secp) - .expect("will fix later"); - } - }; - } - (_, DescriptorSecretKey::XPrv(k)) => { - derive_sp(&mut psbt, &k.xkey, &sp_recipients, &secp).expect("will fix later"); - } - _ => unimplemented!("multi xkey signer"), - }; - - // Unfinalize PSBT to resign - for psbt_input in psbt.inputs.iter_mut() { - psbt_input.final_script_sig = None; - psbt_input.final_script_witness = None; - } - - let _resigned = wallet.sign(&mut psbt, SignOptions::default())?; - - let raw_tx = psbt.extract_tx()?; - if cli_opts.pretty { - let table = vec![vec![ - "Raw Transaction".cell().bold(true), - serialize_hex(&raw_tx).cell(), - ]] - .table() - .display() - .map_err(|e| Error::Generic(e.to_string()))?; - Ok(format!("{table}")) - } else { - Ok(serde_json::to_string_pretty( - &json!({"raw_tx": serialize_hex(&raw_tx)}), - )?) - } - } - CreateTx { - recipients, - send_all, - enable_rbf, - offline_signer, - utxos, - unspendable, - fee_rate, - external_policy, - internal_policy, - add_data, - add_string, - } => { - let mut tx_builder = wallet.build_tx(); - - if send_all { - if recipients.len() == 1 { - tx_builder.drain_wallet().drain_to(recipients[0].0.clone()); - } else { - return Err(Error::Generic( - "Wallet can only be drained to a single output".to_string(), - )); - } - } else { - let recipients: Vec<_> = recipients - .into_iter() - .map(|(script, amount)| (script, Amount::from_sat(amount))) - .collect(); - tx_builder.set_recipients(recipients); - } - - if !enable_rbf { - tx_builder.set_exact_sequence(Sequence::MAX); - } - - if offline_signer { - tx_builder.include_output_redeem_witness_script(); - } - - if let Some(fee_rate) = fee_rate - && let Some(fee_rate) = FeeRate::from_sat_per_vb(fee_rate as u64) - { - tx_builder.fee_rate(fee_rate); - } - - if let Some(utxos) = utxos { - tx_builder - .add_utxos(&utxos[..]) - .map_err(|_| bdk_wallet::error::CreateTxError::UnknownUtxo)?; - } - - if let Some(unspendable) = unspendable { - tx_builder.unspendable(unspendable); - } - - if let Some(base64_data) = add_data { - let op_return_data = BASE64_STANDARD - .decode(base64_data) - .map_err(|e| Error::Generic(e.to_string()))?; - tx_builder.add_data( - &PushBytesBuf::try_from(op_return_data) - .map_err(|e| Error::Generic(e.to_string()))?, - ); - } else if let Some(string_data) = add_string { - let data = PushBytesBuf::try_from(string_data.as_bytes().to_vec()) - .map_err(|e| Error::Generic(e.to_string()))?; - tx_builder.add_data(&data); - } - - let policies = vec![ - external_policy.map(|p| (p, KeychainKind::External)), - internal_policy.map(|p| (p, KeychainKind::Internal)), - ]; - - for (policy, keychain) in policies.into_iter().flatten() { - let policy = serde_json::from_str::>>(&policy)?; - tx_builder.policy_path(policy, keychain); - } - - let psbt = tx_builder.finish()?; - - let psbt_base64 = BASE64_STANDARD.encode(psbt.serialize()); - - if wallet_opts.verbose { - Ok(serde_json::to_string_pretty( - &json!({"psbt": psbt_base64, "details": psbt}), - )?) - } else { - Ok(serde_json::to_string_pretty( - &json!({"psbt": psbt_base64 }), - )?) - } - } - #[cfg(feature = "dns_payment")] - CreateDnsTx { - recipients, - dns_recipients, - dns_resolver, - send_all, - enable_rbf, - offline_signer, - utxos, - unspendable, - fee_rate, - external_policy, - internal_policy, - add_data, - add_string, - } => { - let mut tx_builder = wallet.build_tx(); - let mut recipients = recipients; - - for recipient in dns_recipients { - log::info!("Resolving DNS instructions for recipient {}", recipient.0); - let amount = Amount::from_sat(recipient.1); - let (resolver, instructions) = - parse_dns_instructions(&recipient.0, cli_opts.network, &dns_resolver) - .await - .map_err(|e| Error::Generic(format!("Parsing error occured {e:#?}")))?; - let payment = process_instructions(amount, &instructions, resolver).await?; - - recipients.push((payment.0.into(), payment.1.to_sat())); - } - - if recipients.is_empty() { - return Err(Error::Generic( - "Either --to or --to_dns parameters must be specified".to_string(), - )); - } - - if send_all { - if recipients.len() == 1 { - tx_builder.drain_wallet().drain_to(recipients[0].0.clone()); - } else { - return Err(Error::Generic( - "Wallet can only be drained to a single output".to_string(), - )); - } - } else { - let recipients: Vec<_> = recipients - .into_iter() - .map(|(script, amount)| (script, Amount::from_sat(amount))) - .collect(); - tx_builder.set_recipients(recipients); - } - - if !enable_rbf { - tx_builder.set_exact_sequence(Sequence::MAX); - } - - if offline_signer { - tx_builder.include_output_redeem_witness_script(); - } - - if let Some(fee_rate) = fee_rate - && let Some(fee_rate) = FeeRate::from_sat_per_vb(fee_rate as u64) - { - tx_builder.fee_rate(fee_rate); - } - - if let Some(utxos) = utxos { - tx_builder - .add_utxos(&utxos[..]) - .map_err(|_| bdk_wallet::error::CreateTxError::UnknownUtxo)?; - } - - if let Some(unspendable) = unspendable { - tx_builder.unspendable(unspendable); - } - - if let Some(base64_data) = add_data { - let op_return_data = BASE64_STANDARD - .decode(base64_data) - .map_err(|e| Error::Generic(e.to_string()))?; - tx_builder.add_data( - &PushBytesBuf::try_from(op_return_data) - .map_err(|e| Error::Generic(e.to_string()))?, - ); - } else if let Some(string_data) = add_string { - let data = PushBytesBuf::try_from(string_data.as_bytes().to_vec()) - .map_err(|e| Error::Generic(e.to_string()))?; - tx_builder.add_data(&data); - } - - let policies = vec![ - external_policy.map(|p| (p, KeychainKind::External)), - internal_policy.map(|p| (p, KeychainKind::Internal)), - ]; - - for (policy, keychain) in policies.into_iter().flatten() { - let policy = serde_json::from_str::>>(&policy)?; - tx_builder.policy_path(policy, keychain); - } - - let psbt = tx_builder.finish()?; - - let psbt_base64 = BASE64_STANDARD.encode(psbt.serialize()); - - if wallet_opts.verbose { - Ok(serde_json::to_string_pretty( - &json!({"psbt": psbt_base64, "details": psbt}), - )?) - } else { - Ok(serde_json::to_string_pretty( - &json!({"psbt": psbt_base64 }), - )?) - } - } - BumpFee { - txid, - shrink_address, - offline_signer, - utxos, - unspendable, - fee_rate, - } => { - let txid = Txid::from_str(txid.as_str())?; - - let mut tx_builder = wallet.build_fee_bump(txid)?; - let fee_rate = - FeeRate::from_sat_per_vb(fee_rate as u64).unwrap_or(FeeRate::BROADCAST_MIN); - tx_builder.fee_rate(fee_rate); - - if let Some(address) = shrink_address { - let script_pubkey = address.script_pubkey(); - tx_builder.drain_to(script_pubkey); - } - - if offline_signer { - tx_builder.include_output_redeem_witness_script(); - } - - if let Some(utxos) = utxos { - tx_builder.add_utxos(&utxos[..]).unwrap(); - } - - if let Some(unspendable) = unspendable { - tx_builder.unspendable(unspendable); - } - - let psbt = tx_builder.finish()?; - - let psbt_base64 = BASE64_STANDARD.encode(psbt.serialize()); - - Ok(serde_json::to_string_pretty( - &json!({"psbt": psbt_base64 }), - )?) - } - Policies => { - let external_policy = wallet.policies(KeychainKind::External)?; - let internal_policy = wallet.policies(KeychainKind::Internal)?; - if cli_opts.pretty { - let table = vec![ - vec![ - "External".cell().bold(true), - serde_json::to_string_pretty(&external_policy)?.cell(), - ], - vec![ - "Internal".cell().bold(true), - serde_json::to_string_pretty(&internal_policy)?.cell(), - ], - ] - .table() - .display() - .map_err(|e| Error::Generic(e.to_string()))?; - Ok(format!("{table}")) - } else { - Ok(serde_json::to_string_pretty(&json!({ - "external": external_policy, - "internal": internal_policy, - }))?) - } - } - PublicDescriptor => { - let external = wallet.public_descriptor(KeychainKind::External).to_string(); - let internal = wallet.public_descriptor(KeychainKind::Internal).to_string(); - - if cli_opts.pretty { - let table = vec![ - vec![ - "External Descriptor".cell().bold(true), - external.to_string().cell(), - ], - vec![ - "Internal Descriptor".cell().bold(true), - internal.to_string().cell(), - ], - ] - .table() - .display() - .map_err(|e| Error::Generic(e.to_string()))?; - Ok(format!("{table}")) - } else { - Ok(serde_json::to_string_pretty(&json!({ - "external": external.to_string(), - "internal": internal.to_string(), - }))?) - } - } - Sign { - psbt, - assume_height, - trust_witness_utxo, - } => { - let psbt_bytes = BASE64_STANDARD.decode(psbt)?; - let mut psbt = Psbt::deserialize(&psbt_bytes)?; - let signopt = SignOptions { - assume_height, - trust_witness_utxo: trust_witness_utxo.unwrap_or(false), - ..Default::default() - }; - let finalized = wallet.sign(&mut psbt, signopt)?; - let psbt_base64 = BASE64_STANDARD.encode(psbt.serialize()); - if wallet_opts.verbose { - Ok(serde_json::to_string_pretty( - &json!({"psbt": &psbt_base64, "is_finalized": finalized, "serialized_psbt": &psbt}), - )?) - } else { - Ok(serde_json::to_string_pretty( - &json!({"psbt": &psbt_base64, "is_finalized": finalized}), - )?) - } - } - ExtractPsbt { psbt } => { - let psbt_serialized = BASE64_STANDARD.decode(psbt)?; - let psbt = Psbt::deserialize(&psbt_serialized)?; - let raw_tx = psbt.extract_tx()?; - if cli_opts.pretty { - let table = vec![vec![ - "Raw Transaction".cell().bold(true), - serialize_hex(&raw_tx).cell(), - ]] - .table() - .display() - .map_err(|e| Error::Generic(e.to_string()))?; - Ok(format!("{table}")) - } else { - Ok(serde_json::to_string_pretty( - &json!({"raw_tx": serialize_hex(&raw_tx)}), - )?) - } - } - FinalizePsbt { - psbt, - assume_height, - trust_witness_utxo, - } => { - let psbt_bytes = BASE64_STANDARD.decode(psbt)?; - let mut psbt: Psbt = Psbt::deserialize(&psbt_bytes)?; - - let signopt = SignOptions { - assume_height, - trust_witness_utxo: trust_witness_utxo.unwrap_or(false), - ..Default::default() - }; - let finalized = wallet.finalize_psbt(&mut psbt, signopt)?; - if wallet_opts.verbose { - Ok(serde_json::to_string_pretty( - &json!({ "psbt": BASE64_STANDARD.encode(psbt.serialize()), "is_finalized": finalized, "details": psbt}), - )?) - } else { - Ok(serde_json::to_string_pretty( - &json!({ "psbt": BASE64_STANDARD.encode(psbt.serialize()), "is_finalized": finalized}), - )?) - } - } - CombinePsbt { psbt } => { - let mut psbts = psbt - .iter() - .map(|s| { - let psbt = BASE64_STANDARD.decode(s)?; - Ok(Psbt::deserialize(&psbt)?) - }) - .collect::, Error>>()?; - - let init_psbt = psbts - .pop() - .ok_or_else(|| Error::Generic("Invalid PSBT input".to_string()))?; - let final_psbt = psbts.into_iter().try_fold::<_, _, Result>( - init_psbt, - |mut acc, x| { - let _ = acc.combine(x); - Ok(acc) - }, - )?; - Ok(serde_json::to_string_pretty( - &json!({ "psbt": BASE64_STANDARD.encode(final_psbt.serialize()) }), - )?) - } - #[cfg(feature = "bip322")] - SignMessage { - message, - signature_type, - address, - utxos, - } => { - let address: Address = parse_address(&address)?; - let signature_format = parse_signature_format(&signature_type)?; - - if !wallet.is_mine(address.script_pubkey()) { - return Err(Error::Generic(format!( - "Address {} does not belong to this wallet.", - address - ))); - } - - let proof: MessageProof = - wallet.sign_message(message.as_str(), signature_format, &address, utxos)?; - - Ok(json!({"proof": proof.to_base64()}).to_string()) - } - #[cfg(feature = "bip322")] - VerifyMessage { - proof, - message, - address, - } => { - let address: Address = parse_address(&address)?; - let parsed_proof: MessageProof = MessageProof::from_base64(&proof) - .map_err(|e| BDKCliError::Generic(format!("Invalid proof: {e}")))?; - - let is_valid: MessageVerificationResult = - wallet.verify_message(&parsed_proof, &message, &address)?; - - Ok(json!({ - "valid": is_valid.valid, - "proven_amount": is_valid.proven_amount.map(|a| a.to_sat()) // optional field - }) - .to_string()) - } - } -} - -/// Execute an online wallet sub-command -/// -/// Online wallet sub-commands are described in [`OnlineWalletSubCommand`]. -#[cfg(any( - feature = "electrum", - feature = "esplora", - feature = "cbf", - feature = "rpc" -))] -pub(crate) async fn handle_online_wallet_subcommand( - wallet: &mut Wallet, - client: &BlockchainClient, - online_subcommand: OnlineWalletSubCommand, - datadir: Option, - wallet_name: &str, -) -> Result { - match online_subcommand { - FullScan { - stop_gap: _stop_gap, - } => { - #[cfg(any(feature = "electrum", feature = "esplora"))] - let request = wallet.start_full_scan().inspect({ - let mut stdout = std::io::stdout(); - let mut once = HashSet::::new(); - move |k, spk_i, _| { - if once.insert(k) { - print!("\nScanning keychain [{k:?}]"); - } - print!(" {spk_i:<3}"); - stdout.flush().expect("must flush"); - } - }); - match client { - #[cfg(feature = "electrum")] - Electrum { client, batch_size } => { - // Populate the electrum client's transaction cache so it doesn't re-download transaction we - // already have. - client - .populate_tx_cache(wallet.tx_graph().full_txs().map(|tx_node| tx_node.tx)); - - let update = client.full_scan(request, _stop_gap, *batch_size, false)?; - let wallet_events = wallet.apply_update_events(update)?; - print_wallet_events(&wallet_events); - } - #[cfg(feature = "esplora")] - Esplora { - client, - parallel_requests, - } => { - let update = client - .full_scan(request, _stop_gap, *parallel_requests) - .await - .map_err(|e| *e)?; - let wallet_events = wallet.apply_update_events(update)?; - print_wallet_events(&wallet_events); - } - - #[cfg(feature = "rpc")] - RpcClient { client } => { - let blockchain_info = client.get_blockchain_info()?; - - let genesis_block = - bdk_wallet::bitcoin::constants::genesis_block(wallet.network()); - let genesis_cp = CheckPoint::new(BlockId { - height: 0, - hash: genesis_block.block_hash(), - }); - let mut emitter = Emitter::new( - client.as_ref(), - genesis_cp.clone(), - genesis_cp.height(), - NO_EXPECTED_MEMPOOL_TXS, - ); - - let block_events = wallet.events_helper(|w| { - while let Some(block_event) = emitter.next_block()? { - if block_event.block_height() % 10_000 == 0 { - let percent_done = f64::from(block_event.block_height()) - / f64::from(blockchain_info.headers as u32) - * 100f64; - println!( - "Applying block at height: {}, {:.2}% done.", - block_event.block_height(), - percent_done - ); - } - - w.apply_block_connected_to( - &block_event.block, - block_event.block_height(), - block_event.connected_to(), - )?; - } - Ok::<_, Error>(()) - })?; - print_wallet_events(&block_events); - - let mempool_txs = emitter.mempool()?; - let mempool_events = wallet.apply_unconfirmed_txs_events(mempool_txs.update); - print_wallet_events(&mempool_events); - - let evicted_events = wallet.apply_evicted_txs_events(mempool_txs.evicted); - print_wallet_events(&evicted_events); - } - #[cfg(feature = "cbf")] - KyotoClient { client } => { - sync_kyoto_client(wallet, client).await?; - } - } - Ok(serde_json::to_string_pretty(&json!({}))?) - } - Sync => { - sync_wallet(client, wallet).await?; - Ok(serde_json::to_string_pretty(&json!({}))?) - } - Broadcast { psbt, tx } => { - let tx = match (psbt, tx) { - (Some(psbt), None) => { - let psbt = BASE64_STANDARD - .decode(psbt) - .map_err(|e| Error::Generic(e.to_string()))?; - let psbt: Psbt = Psbt::deserialize(&psbt)?; - is_final(&psbt)?; - psbt.extract_tx()? - } - (None, Some(tx)) => { - let tx_bytes = Vec::::from_hex(&tx)?; - Transaction::consensus_decode(&mut tx_bytes.as_slice())? - } - (Some(_), Some(_)) => panic!("Both `psbt` and `tx` options not allowed"), - (None, None) => panic!("Missing `psbt` and `tx` option"), - }; - let txid = broadcast_transaction(client, tx).await?; - Ok(serde_json::to_string_pretty(&json!({ "txid": txid }))?) - } - ReceivePayjoin { - amount, - directory, - ohttp_relay, - max_fee_rate, - } => { - let mut payjoin_manager = PayjoinManager::new(wallet, datadir.clone(), wallet_name)?; - return payjoin_manager - .receive_payjoin(amount, directory, max_fee_rate, ohttp_relay, client) - .await; - } - SendPayjoin { - uri, - ohttp_relay, - fee_rate, - } => { - let mut payjoin_manager = PayjoinManager::new(wallet, datadir.clone(), wallet_name)?; - return payjoin_manager - .send_payjoin(uri, fee_rate, ohttp_relay, client) - .await; - } - ResumePayjoin { - directory, - ohttp_relay, - session_id, - } => { - let mut payjoin_manager = PayjoinManager::new(wallet, datadir, wallet_name)?; - return payjoin_manager - .resume_payjoins(directory, ohttp_relay, session_id, client) - .await; - } - PayjoinHistory => crate::payjoin::PayjoinManager::history(datadir, wallet_name), - } -} - -/// Handle wallet config subcommand to create or update config.toml -pub fn handle_config_subcommand( - datadir: &Path, - network: Network, - wallet: String, - wallet_opts: &WalletOpts, - force: bool, -) -> Result { - if network == Network::Bitcoin { - eprintln!( - "WARNING: You are configuring a wallet for Bitcoin MAINNET. - This software is experimental and not recommended for use with real funds. - Consider using a testnet for testing purposes. \n" - ); - } - - let ext_descriptor = wallet_opts.ext_descriptor.clone(); - let int_descriptor = wallet_opts.int_descriptor.clone(); - - if ext_descriptor.contains("xprv") || ext_descriptor.contains("tprv") { - eprintln!( - "WARNING: Your external descriptor contains PRIVATE KEYS. - Private keys will be saved in PLAINTEXT in the config file. - This is a security risk. Consider using public descriptors instead.\n" - ); - } - - if let Some(ref internal_desc) = int_descriptor - && (internal_desc.contains("xprv") || internal_desc.contains("tprv")) - { - eprintln!( - "WARNING: Your internal descriptor contains PRIVATE KEYS. - Private keys will be saved in PLAINTEXT in the config file. - This is a security risk. Consider using public descriptors instead.\n" - ); - } - - let mut config = WalletConfig::load(datadir)?.unwrap_or(WalletConfig { - wallets: HashMap::new(), - }); - - if config.wallets.contains_key(&wallet) && !force { - return Err(Error::Generic(format!( - "Wallet '{wallet}' already exists in config.toml. Use --force to overwrite." - ))); - } - - #[cfg(any( - feature = "electrum", - feature = "esplora", - feature = "rpc", - feature = "cbf" - ))] - let client_type = wallet_opts.client_type.clone(); - #[cfg(any(feature = "electrum", feature = "esplora", feature = "rpc"))] - let url = &wallet_opts.url.clone(); - #[cfg(any(feature = "sqlite", feature = "redb"))] - let database_type = match wallet_opts.database_type { - #[cfg(feature = "sqlite")] - DatabaseType::Sqlite => "sqlite".to_string(), - #[cfg(feature = "redb")] - DatabaseType::Redb => "redb".to_string(), - }; - - #[cfg(any( - feature = "electrum", - feature = "esplora", - feature = "rpc", - feature = "cbf" - ))] - let client_type = match client_type { - #[cfg(feature = "electrum")] - ClientType::Electrum => "electrum".to_string(), - #[cfg(feature = "esplora")] - ClientType::Esplora => "esplora".to_string(), - #[cfg(feature = "rpc")] - ClientType::Rpc => "rpc".to_string(), - #[cfg(feature = "cbf")] - ClientType::Cbf => "cbf".to_string(), - }; - - let wallet_config = WalletConfigInner { - wallet: wallet.clone(), - network: network.to_string(), - ext_descriptor, - int_descriptor, - #[cfg(any(feature = "sqlite", feature = "redb"))] - database_type, - #[cfg(any( - feature = "electrum", - feature = "esplora", - feature = "rpc", - feature = "cbf" - ))] - client_type: Some(client_type), - #[cfg(any(feature = "electrum", feature = "esplora", feature = "rpc",))] - server_url: Some(url.to_string()), - #[cfg(feature = "rpc")] - rpc_user: Some(wallet_opts.basic_auth.0.clone()), - #[cfg(feature = "rpc")] - rpc_password: Some(wallet_opts.basic_auth.1.clone()), - #[cfg(feature = "electrum")] - batch_size: Some(wallet_opts.batch_size), - #[cfg(feature = "esplora")] - parallel_requests: Some(wallet_opts.parallel_requests), - #[cfg(feature = "rpc")] - cookie: wallet_opts.cookie.clone(), - }; - - config.wallets.insert(wallet.clone(), wallet_config); - config.save(datadir)?; - - Ok(serde_json::to_string_pretty(&json!({ - "message": format!("Wallet '{wallet}' initialized successfully in {:?}", datadir.join("config.toml")) - }))?) -} - -/// Determine if PSBT has final script sigs or witnesses for all unsigned tx inputs. -#[cfg(any( - feature = "electrum", - feature = "esplora", - feature = "cbf", - feature = "rpc" -))] -pub(crate) fn is_final(psbt: &Psbt) -> Result<(), Error> { - let unsigned_tx_inputs = psbt.unsigned_tx.input.len(); - let psbt_inputs = psbt.inputs.len(); - if unsigned_tx_inputs != psbt_inputs { - return Err(Error::Generic(format!( - "Malformed PSBT, {unsigned_tx_inputs} unsigned tx inputs and {psbt_inputs} psbt inputs." - ))); - } - let sig_count = psbt.inputs.iter().fold(0, |count, input| { - if input.final_script_sig.is_some() || input.final_script_witness.is_some() { - count + 1 - } else { - count - } - }); - if unsigned_tx_inputs > sig_count { - return Err(Error::Generic( - "The PSBT is not finalized, inputs are are not fully signed.".to_string(), - )); - } - Ok(()) -} - -#[cfg(feature = "silent-payments")] -pub(crate) fn handle_sp_subcommand( - scan_pubkey: bdk_sp::bitcoin::secp256k1::PublicKey, - spend_pubkey: bdk_sp::bitcoin::secp256k1::PublicKey, - network: Network, - pretty: bool, -) -> Result { - let sp_code = SilentPaymentCode::new_v0(scan_pubkey, spend_pubkey, network); - if pretty { - let table = vec![vec![ - "sp_code".cell().bold(true), - sp_code.to_string().cell(), - ]] - .table() - .display() - .map_err(|e| Error::Generic(e.to_string()))?; - Ok(format!("{table}")) - } else { - Ok(serde_json::to_string_pretty( - &json!({"sp_code": sp_code.to_string()}), - )?) - } -} - -/// Handle a key sub-command -/// -/// Key sub-commands are described in [`KeySubCommand`]. -pub(crate) fn handle_key_subcommand( - network: Network, - subcommand: KeySubCommand, - pretty: bool, -) -> Result { - let secp = Secp256k1::new(); - - match subcommand { - KeySubCommand::Generate { - word_count, - password, - } => { - let mnemonic_type = match word_count { - 12 => WordCount::Words12, - _ => WordCount::Words24, - }; - let mnemonic: GeneratedKey<_, miniscript::BareCtx> = - Mnemonic::generate((mnemonic_type, Language::English)) - .map_err(|_| Error::Generic("Mnemonic generation error".to_string()))?; - let mnemonic = mnemonic.into_key(); - let xkey: ExtendedKey = (mnemonic.clone(), password).into_extended_key()?; - let xprv = xkey.into_xprv(network).ok_or_else(|| { - Error::Generic("Privatekey info not found (should not happen)".to_string()) - })?; - let fingerprint = xprv.fingerprint(&secp); - let phrase = mnemonic - .words() - .fold("".to_string(), |phrase, w| phrase + w + " ") - .trim() - .to_string(); - if pretty { - let table = vec![ - vec![ - "Fingerprint".cell().bold(true), - fingerprint.to_string().cell(), - ], - vec!["Mnemonic".cell().bold(true), mnemonic.to_string().cell()], - vec!["Xprv".cell().bold(true), xprv.to_string().cell()], - ] - .table() - .display() - .map_err(|e| Error::Generic(e.to_string()))?; - Ok(format!("{table}")) - } else { - Ok(serde_json::to_string_pretty( - &json!({ "mnemonic": phrase, "xprv": xprv.to_string(), "fingerprint": fingerprint.to_string() }), - )?) - } - } - KeySubCommand::Restore { mnemonic, password } => { - let mnemonic = Mnemonic::parse_in(Language::English, mnemonic)?; - let xkey: ExtendedKey = (mnemonic.clone(), password).into_extended_key()?; - let xprv = xkey.into_xprv(network).ok_or_else(|| { - Error::Generic("Privatekey info not found (should not happen)".to_string()) - })?; - let fingerprint = xprv.fingerprint(&secp); - if pretty { - let table = vec![ - vec![ - "Fingerprint".cell().bold(true), - fingerprint.to_string().cell(), - ], - vec!["Mnemonic".cell().bold(true), mnemonic.to_string().cell()], - vec!["Xprv".cell().bold(true), xprv.to_string().cell()], - ] - .table() - .display() - .map_err(|e| Error::Generic(e.to_string()))?; - Ok(format!("{table}")) - } else { - Ok(serde_json::to_string_pretty( - &json!({ "xprv": xprv.to_string(), "fingerprint": fingerprint.to_string() }), - )?) - } - } - KeySubCommand::Derive { xprv, path } => { - if xprv.network != network.into() { - return Err(Error::Generic("Invalid network".to_string())); - } - let derived_xprv = &xprv.derive_priv(&secp, &path)?; - - let origin: KeySource = (xprv.fingerprint(&secp), path); - - let derived_xprv_desc_key: DescriptorKey = - derived_xprv.into_descriptor_key(Some(origin), DerivationPath::default())?; - - if let Secret(desc_seckey, _, _) = derived_xprv_desc_key { - let desc_pubkey = desc_seckey.to_public(&secp)?; - if pretty { - let table = vec![ - vec!["Xpub".cell().bold(true), desc_pubkey.to_string().cell()], - vec!["Xprv".cell().bold(true), xprv.to_string().cell()], - ] - .table() - .display() - .map_err(|e| Error::Generic(e.to_string()))?; - Ok(format!("{table}")) - } else { - Ok(serde_json::to_string_pretty( - &json!({"xpub": desc_pubkey.to_string(), "xprv": desc_seckey.to_string()}), - )?) - } - } else { - Err(Error::Generic("Invalid key variant".to_string())) - } - } - } -} - -/// Handle the miniscript compiler sub-command -/// -/// Compiler options are described in [`CliSubCommand::Compile`]. -#[cfg(feature = "compiler")] -pub(crate) fn handle_compile_subcommand( - _network: Network, - policy: String, - script_type: String, - pretty: bool, -) -> Result { - let policy = Concrete::::from_str(policy.as_str())?; - - let mut r = None; - - let descriptor = match script_type.as_str() { - "sh" => policy.compile::()?.pipe(Descriptor::new_sh), - "wsh" => policy.compile::()?.pipe(Descriptor::new_wsh), - "sh-wsh" => policy.compile::()?.pipe(Descriptor::new_sh_wsh), - "tr" => { - // For tr descriptors, we use a randomized unspendable key (H + rG). - // This improves privacy by preventing observers from determining if key path spending is disabled. - // See BIP-341: https://github.com/bitcoin/bips/blob/master/bip-0341.mediawiki#constructing-and-spending-taproot-outputs - - let secp = Secp256k1::new(); - let r_secret = SecretKey::new(&mut rand::thread_rng()); - r = Some(r_secret.display_secret().to_string()); - - let nums_key = XOnlyPublicKey::from_str(NUMS_UNSPENDABLE_KEY_HEX)?; - let nums_point = bdk_wallet::bitcoin::secp256k1::PublicKey::from_x_only_public_key( - nums_key, - Parity::Even, - ); - - let internal_key_point = nums_point.add_exp_tweak(&secp, &Scalar::from(r_secret))?; - let (xonly_internal_key, _) = internal_key_point.x_only_public_key(); - - let tree = TapTree::Leaf(Arc::new(policy.compile::()?)); - - Descriptor::new_tr(xonly_internal_key.to_string(), Some(tree)) - } - _ => { - return Err(Error::Generic( - "Invalid script type. Supported types: sh, wsh, sh-wsh, tr".to_string(), - )); - } - }?; - - if pretty { - let mut rows = vec![vec![ - "Descriptor".cell().bold(true), - shorten(&descriptor, 32, 29).cell(), - ]]; - - if let Some(r_value) = &r { - rows.push(vec!["r".cell().bold(true), shorten(r_value, 4, 4).cell()]); - } - - let table = rows - .table() - .display() - .map_err(|e| Error::Generic(e.to_string()))?; - - Ok(format!("{table}")) - } else { - let mut output = json!({"descriptor": descriptor}); - if let Some(r_value) = r { - output["r"] = json!(r_value); - } - Ok(serde_json::to_string_pretty(&output)?) - } -} - -#[cfg(feature = "dns_payment")] -pub(crate) async fn handle_resolve_dns_recipient_command( - hrn: &str, - resolver: &str, - network: Network, -) -> Result { - let resolved = resolve_dns_recipient(hrn, network, resolver) - .await - .map_err(|e| Error::Generic(format!("{:?}", e)))?; - - resolved.display() -} - -/// Handle wallets command to show all saved wallet configurations -pub fn handle_wallets_subcommand(datadir: &Path, pretty: bool) -> Result { - let load_config = WalletConfig::load(datadir)?; - - let config = match load_config { - Some(c) if !c.wallets.is_empty() => c, - _ => { - return Ok(if pretty { - "No wallet configurations found.".to_string() - } else { - serde_json::to_string_pretty(&json!({ - "wallets": [] - }))? - }); - } - }; - - if pretty { - let mut rows: Vec> = vec![]; - - for (name, wallet_config) in config.wallets.iter() { - let mut row = vec![name.cell(), wallet_config.network.clone().cell()]; - - #[cfg(any(feature = "sqlite", feature = "redb"))] - row.push(wallet_config.database_type.clone().cell()); - - #[cfg(any( - feature = "electrum", - feature = "esplora", - feature = "rpc", - feature = "cbf" - ))] - { - let client_str = wallet_config.client_type.as_deref().unwrap_or("N/A"); - row.push(client_str.cell()); - } - - #[cfg(any(feature = "electrum", feature = "esplora", feature = "rpc"))] - { - let url_str = wallet_config.server_url.as_deref().unwrap_or("N/A"); - let display_url = if url_str.len() > 20 { - shorten(url_str, 15, 10) - } else { - url_str.to_string() - }; - row.push(display_url.cell()); - } - - let ext_desc_display = if wallet_config.ext_descriptor.len() > 40 { - shorten(&wallet_config.ext_descriptor, 20, 15) - } else { - wallet_config.ext_descriptor.clone() - }; - row.push(ext_desc_display.cell()); - - let has_int_desc = if wallet_config.int_descriptor.is_some() { - "Yes" - } else { - "No" - }; - row.push(has_int_desc.cell()); - - rows.push(row); - } - - let mut title_cells = vec!["Wallet Name".cell().bold(true), "Network".cell().bold(true)]; - - #[cfg(any(feature = "sqlite", feature = "redb"))] - title_cells.push("Database".cell().bold(true)); - - #[cfg(any( - feature = "electrum", - feature = "esplora", - feature = "rpc", - feature = "cbf" - ))] - title_cells.push("Client".cell().bold(true)); - - #[cfg(any(feature = "electrum", feature = "esplora", feature = "rpc"))] - title_cells.push("Server URL".cell().bold(true)); - - title_cells.push("External Desc".cell().bold(true)); - title_cells.push("Internal Desc".cell().bold(true)); - - let table = rows - .table() - .title(title_cells) - .display() - .map_err(|e| Error::Generic(e.to_string()))?; - - Ok(format!("{table}")) - } else { - let wallets_summary: Vec<_> = config - .wallets - .iter() - .map(|(name, wallet_config)| { - #[allow(unused_mut)] - let mut wallet_json = json!({ - "name": name, - "network": wallet_config.network, - "ext_descriptor": wallet_config.ext_descriptor, - "int_descriptor": wallet_config.int_descriptor, - }); - - #[cfg(any(feature = "sqlite", feature = "redb"))] - { - wallet_json["database_type"] = json!(wallet_config.database_type.clone()); - } - - #[cfg(any( - feature = "electrum", - feature = "esplora", - feature = "rpc", - feature = "cbf" - ))] - { - wallet_json["client_type"] = json!(wallet_config.client_type.clone()); - } - - #[cfg(any(feature = "electrum", feature = "esplora", feature = "rpc"))] - { - wallet_json["server_url"] = json!(wallet_config.server_url.clone()); - } - - wallet_json - }) - .collect(); - - Ok(serde_json::to_string_pretty(&json!({ - "wallets": wallets_summary - }))?) - } -} - -/// The global top level handler. -pub(crate) async fn handle_command(cli_opts: CliOpts) -> Result { - let pretty = cli_opts.pretty; - let subcommand = cli_opts.subcommand.clone(); - - let result: Result = match subcommand { - #[cfg(any( - feature = "electrum", - feature = "esplora", - feature = "cbf", - feature = "rpc" - ))] - CliSubCommand::Wallet { - wallet: wallet_name, - subcommand: WalletSubCommand::OnlineWalletSubCommand(online_subcommand), - } => { - let home_dir = prepare_home_dir(cli_opts.datadir.clone())?; - - let (wallet_opts, network) = load_wallet_config(&home_dir, &wallet_name)?; - - let database_path = prepare_wallet_db_dir(&home_dir, &wallet_name)?; - - #[cfg(any(feature = "sqlite", feature = "redb"))] - let result = { - let mut persister: Persister = match &wallet_opts.database_type { - #[cfg(feature = "sqlite")] - DatabaseType::Sqlite => { - let db_file = database_path.join("wallet.sqlite"); - let connection = Connection::open(db_file)?; - log::debug!("Sqlite database opened successfully"); - Persister::Connection(connection) - } - #[cfg(feature = "redb")] - DatabaseType::Redb => { - let redb_store_wallet_name = &wallet_opts.wallet; - let db = Arc::new(bdk_redb::redb::Database::create( - home_dir.join("wallet.redb"), - )?); - let store = RedbStore::new( - db, - redb_store_wallet_name - .as_deref() - .unwrap_or("wallet") - .to_string(), - )?; - log::debug!("Redb database opened successfully"); - Persister::RedbStore(store) - } - }; - - let mut wallet = new_persisted_wallet(network, &mut persister, &wallet_opts)?; - let blockchain_client = - new_blockchain_client(&wallet_opts, &wallet, database_path)?; - - let result = handle_online_wallet_subcommand( - &mut wallet, - &blockchain_client, - online_subcommand, - cli_opts.datadir.clone(), - &wallet_name, - ) - .await?; - wallet.persist(&mut persister)?; - result - }; - #[cfg(not(any(feature = "sqlite", feature = "redb")))] - let result = { - let mut wallet = new_wallet(network, wallet_opts)?; - let blockchain_client = - crate::utils::new_blockchain_client(wallet_opts, &wallet, database_path)?; - handle_online_wallet_subcommand( - &mut wallet, - &blockchain_client, - online_subcommand, - cli_opts.datadir.clone(), - &wallet_name, - ) - .await? - }; - Ok(result) - } - CliSubCommand::Wallet { - wallet: wallet_name, - subcommand: WalletSubCommand::OfflineWalletSubCommand(offline_subcommand), - } => { - let datadir = cli_opts.datadir.clone(); - let home_dir = prepare_home_dir(datadir)?; - let (wallet_opts, network) = load_wallet_config(&home_dir, &wallet_name)?; - - #[cfg(any(feature = "sqlite", feature = "redb"))] - let result = { - let mut persister: Persister = match &wallet_opts.database_type { - #[cfg(feature = "sqlite")] - DatabaseType::Sqlite => { - let database_path = prepare_wallet_db_dir(&home_dir, &wallet_name)?; - let db_file = database_path.join("wallet.sqlite"); - let connection = Connection::open(db_file)?; - log::debug!("Sqlite database opened successfully"); - Persister::Connection(connection) - } - #[cfg(feature = "redb")] - DatabaseType::Redb => { - let db = Arc::new(bdk_redb::redb::Database::create( - home_dir.join("wallet.redb"), - )?); - let store = RedbStore::new(db, wallet_name)?; - log::debug!("Redb database opened successfully"); - Persister::RedbStore(store) - } - }; - - let mut wallet = new_persisted_wallet(network, &mut persister, &wallet_opts)?; - - let result = handle_offline_wallet_subcommand( - &mut wallet, - &wallet_opts, - &cli_opts, - offline_subcommand.clone(), - ) - .await?; - wallet.persist(&mut persister)?; - result - }; - #[cfg(not(any(feature = "sqlite", feature = "redb")))] - let result = { - let mut wallet = new_wallet(network, &wallet_opts)?; - handle_offline_wallet_subcommand( - &mut wallet, - &wallet_opts, - &cli_opts, - offline_subcommand.clone(), - ) - .await? - }; - Ok(result) - } - CliSubCommand::Wallet { - wallet, - subcommand: WalletSubCommand::Config { force, wallet_opts }, - } => { - let network = cli_opts.network; - let home_dir = prepare_home_dir(cli_opts.datadir)?; - let result = handle_config_subcommand(&home_dir, network, wallet, &wallet_opts, force)?; - Ok(result) - } - CliSubCommand::Wallets => { - let home_dir = prepare_home_dir(cli_opts.datadir)?; - let result = handle_wallets_subcommand(&home_dir, pretty)?; - Ok(result) - } - CliSubCommand::Key { - subcommand: key_subcommand, - } => { - let network = cli_opts.network; - let result = handle_key_subcommand(network, key_subcommand, pretty)?; - Ok(result) - } - #[cfg(feature = "silent-payments")] - CliSubCommand::SilentPaymentCode { scan, spend } => { - let network = cli_opts.network; - let result = handle_sp_subcommand(scan, spend, network, pretty)?; - Ok(result) - } - #[cfg(feature = "compiler")] - CliSubCommand::Compile { - policy, - script_type, - } => { - let network = cli_opts.network; - let result = handle_compile_subcommand(network, policy, script_type, pretty)?; - Ok(result) - } - #[cfg(feature = "repl")] - CliSubCommand::Repl { - wallet: wallet_name, - } => { - let home_dir = prepare_home_dir(cli_opts.datadir.clone())?; - let (wallet_opts, network) = load_wallet_config(&home_dir, &wallet_name)?; - - #[cfg(any(feature = "sqlite", feature = "redb"))] - let (mut wallet, mut persister) = { - let mut persister: Persister = match &wallet_opts.database_type { - #[cfg(feature = "sqlite")] - DatabaseType::Sqlite => { - let database_path = prepare_wallet_db_dir(&home_dir, &wallet_name)?; - let db_file = database_path.join("wallet.sqlite"); - let connection = Connection::open(db_file)?; - log::debug!("Sqlite database opened successfully"); - Persister::Connection(connection) - } - #[cfg(feature = "redb")] - DatabaseType::Redb => { - let db = Arc::new(bdk_redb::redb::Database::create( - home_dir.join("wallet.redb"), - )?); - let store = RedbStore::new(db, wallet_name.clone())?; - log::debug!("Redb database opened successfully"); - Persister::RedbStore(store) - } - }; - let wallet = new_persisted_wallet(network, &mut persister, &wallet_opts)?; - (wallet, persister) - }; - #[cfg(not(any(feature = "sqlite", feature = "redb")))] - let mut wallet = new_wallet(network, &loaded_wallet_opts)?; - let home_dir = prepare_home_dir(cli_opts.datadir.clone())?; - let database_path = prepare_wallet_db_dir(&home_dir, &wallet_name)?; - loop { - let line = readline()?; - let line = line.trim(); - if line.is_empty() { - continue; - } - - let result = respond( - network, - &mut wallet, - &wallet_name, - &mut wallet_opts.clone(), - line, - database_path.clone(), - &cli_opts, - ) - .await; - #[cfg(any(feature = "sqlite", feature = "redb"))] - wallet.persist(&mut persister)?; - - match result { - Ok(quit) => { - if quit { - break; - } - } - Err(err) => { - writeln!(std::io::stdout(), "{err}") - .map_err(|e| Error::Generic(e.to_string()))?; - std::io::stdout() - .flush() - .map_err(|e| Error::Generic(e.to_string()))?; - } - } - } - Ok("".to_string()) - } - CliSubCommand::Descriptor { desc_type, key } => { - let descriptor = handle_descriptor_command(cli_opts.network, desc_type, key, pretty)?; - Ok(descriptor) - } - CliSubCommand::Completions { shell } => { - clap_complete::generate( - shell, - &mut CliOpts::command(), - "bdk-cli", - &mut std::io::stdout(), - ); - - Ok("".to_string()) - } - - #[cfg(feature = "dns_payment")] - CliSubCommand::ResolveDnsRecipient { hrn, resolver } => { - let res = - handle_resolve_dns_recipient_command(&hrn, &resolver, cli_opts.network).await?; - Ok(res) - } - }; - result -} - -#[cfg(feature = "repl")] -async fn respond( - network: Network, - wallet: &mut Wallet, - wallet_name: &str, - wallet_opts: &mut WalletOpts, - line: &str, - _datadir: std::path::PathBuf, - cli_opts: &CliOpts, -) -> Result { - use clap::Parser; - - let args = shlex::split(line).ok_or("error: Invalid quoting".to_string())?; - let repl_subcommand = ReplSubCommand::try_parse_from(args).map_err(|e| e.to_string())?; - let response = match repl_subcommand { - #[cfg(any( - feature = "electrum", - feature = "esplora", - feature = "cbf", - feature = "rpc" - ))] - ReplSubCommand::Wallet { - subcommand: WalletSubCommand::OnlineWalletSubCommand(online_subcommand), - } => { - let blockchain = - new_blockchain_client(wallet_opts, wallet, _datadir).map_err(|e| e.to_string())?; - let value = handle_online_wallet_subcommand( - wallet, - &blockchain, - online_subcommand, - cli_opts.datadir.clone(), - wallet_name, - ) - .await - .map_err(|e| e.to_string())?; - Some(value) - } - ReplSubCommand::Wallet { - subcommand: WalletSubCommand::OfflineWalletSubCommand(offline_subcommand), - } => { - let value = - handle_offline_wallet_subcommand(wallet, wallet_opts, cli_opts, offline_subcommand) - .await - .map_err(|e| e.to_string())?; - Some(value) - } - ReplSubCommand::Wallet { - subcommand: WalletSubCommand::Config { force, wallet_opts }, - } => { - let value = handle_config_subcommand( - &_datadir, - network, - wallet_name.to_string(), - &wallet_opts, - force, - ) - .map_err(|e| e.to_string())?; - Some(value) - } - ReplSubCommand::Key { subcommand } => { - let value = handle_key_subcommand(network, subcommand, cli_opts.pretty) - .map_err(|e| e.to_string())?; - Some(value) - } - ReplSubCommand::Descriptor { desc_type, key } => { - let value = handle_descriptor_command(network, desc_type, key, cli_opts.pretty) - .map_err(|e| e.to_string())?; - Some(value) - } - ReplSubCommand::Exit => None, - }; - if let Some(value) = response { - writeln!(std::io::stdout(), "{value}").map_err(|e| e.to_string())?; - std::io::stdout().flush().map_err(|e| e.to_string())?; - Ok(false) - } else { - writeln!(std::io::stdout(), "Exiting...").map_err(|e| e.to_string())?; - std::io::stdout().flush().map_err(|e| e.to_string())?; - Ok(true) - } -} - -#[cfg(any( - feature = "electrum", - feature = "esplora", - feature = "cbf", - feature = "rpc" -))] -/// Syncs a given wallet using the blockchain client. -pub async fn sync_wallet(client: &BlockchainClient, wallet: &mut Wallet) -> Result<(), Error> { - #[cfg(any(feature = "electrum", feature = "esplora"))] - let request = wallet - .start_sync_with_revealed_spks() - .inspect(|item, progress| { - let pc = (100 * progress.consumed()) as f32 / progress.total() as f32; - eprintln!("[ SCANNING {pc:03.0}% ] {item}"); - }); - match client { - #[cfg(feature = "electrum")] - Electrum { client, batch_size } => { - // Populate the electrum client's transaction cache so it doesn't re-download transaction we - // already have. - client.populate_tx_cache(wallet.tx_graph().full_txs().map(|tx_node| tx_node.tx)); - - let update = client.sync(request, *batch_size, false)?; - let wallet_events = wallet - .apply_update_events(update) - .map_err(|e| Error::Generic(e.to_string()))?; - print_wallet_events(&wallet_events); - Ok(()) - } - #[cfg(feature = "esplora")] - Esplora { - client, - parallel_requests, - } => { - let update = client - .sync(request, *parallel_requests) - .await - .map_err(|e| *e)?; - let wallet_events = wallet - .apply_update_events(update) - .map_err(|e| Error::Generic(e.to_string()))?; - print_wallet_events(&wallet_events); - Ok(()) - } - #[cfg(feature = "rpc")] - RpcClient { client } => { - let blockchain_info = client.get_blockchain_info()?; - let wallet_cp = wallet.latest_checkpoint(); - - // reload the last 200 blocks in case of a reorg - let emitter_height = wallet_cp.height().saturating_sub(200); - let mut emitter = Emitter::new( - client.as_ref(), - wallet_cp, - emitter_height, - wallet - .tx_graph() - .list_canonical_txs( - wallet.local_chain(), - wallet.local_chain().tip().block_id(), - CanonicalizationParams::default(), - ) - .filter(|tx| tx.chain_position.is_unconfirmed()), - ); - - let block_events = wallet.events_helper(|w| { - while let Some(block_event) = emitter.next_block()? { - if block_event.block_height() % 10_000 == 0 { - let percent_done = f64::from(block_event.block_height()) - / f64::from(blockchain_info.headers as u32) - * 100f64; - println!( - "Applying block at height: {}, {:.2}% done.", - block_event.block_height(), - percent_done - ); - } - w.apply_block_connected_to( - &block_event.block, - block_event.block_height(), - block_event.connected_to(), - )?; - } - Ok::<_, Error>(()) - })?; - print_wallet_events(&block_events); - - let mempool_txs = emitter.mempool()?; - let mempool_events = wallet.apply_unconfirmed_txs_events(mempool_txs.update); - print_wallet_events(&mempool_events); - - let evicted_events = wallet.apply_evicted_txs_events(mempool_txs.evicted); - print_wallet_events(&evicted_events); - Ok(()) - } - #[cfg(feature = "cbf")] - KyotoClient { client } => sync_kyoto_client(wallet, client) - .await - .map_err(|e| Error::Generic(e.to_string())), - } -} - -#[cfg(any( - feature = "electrum", - feature = "esplora", - feature = "cbf", - feature = "rpc" -))] -/// Broadcasts a given transaction using the blockchain client. -pub async fn broadcast_transaction( - client: &BlockchainClient, - tx: Transaction, -) -> Result { - match client { - #[cfg(feature = "electrum")] - Electrum { - client, - batch_size: _, - } => client - .transaction_broadcast(&tx) - .map_err(|e| Error::Generic(e.to_string())), - #[cfg(feature = "esplora")] - Esplora { - client, - parallel_requests: _, - } => client - .broadcast(&tx) - .await - .map(|()| tx.compute_txid()) - .map_err(|e| Error::Generic(e.to_string())), - #[cfg(feature = "rpc")] - RpcClient { client } => client - .send_raw_transaction(&tx) - .map_err(|e| Error::Generic(e.to_string())), - - #[cfg(feature = "cbf")] - KyotoClient { client } => { - let txid = tx.compute_txid(); - let wtxid = client - .requester - .broadcast_random(tx.clone()) - .await - .map_err(|_| { - tracing::warn!("Broadcast was unsuccessful"); - Error::Generic("Transaction broadcast timed out after 30 seconds".into()) - })?; - tracing::info!("Successfully broadcast WTXID: {wtxid}"); - Ok(txid) - } - } -} - -#[cfg(feature = "repl")] -fn readline() -> Result { - write!(std::io::stdout(), "> ").map_err(|e| Error::Generic(e.to_string()))?; - std::io::stdout() - .flush() - .map_err(|e| Error::Generic(e.to_string()))?; - let mut buffer = String::new(); - std::io::stdin() - .read_line(&mut buffer) - .map_err(|e| Error::Generic(e.to_string()))?; - Ok(buffer) -} - -/// Handle the descriptor command -pub fn handle_descriptor_command( - network: Network, - desc_type: String, - key: Option, - pretty: bool, -) -> Result { - let result = match key { - Some(key) => { - if is_mnemonic(&key) { - // User provided mnemonic - generate_descriptor_from_mnemonic(&key, network, &desc_type) - } else { - // User provided xprv/xpub - generate_descriptors(&desc_type, &key, network) - } - } - // Generate new mnemonic and descriptors - None => generate_descriptor_with_mnemonic(network, &desc_type), - }?; - format_descriptor_output(&result, pretty) -} - -#[cfg(test)] -mod test { - #[cfg(any( - feature = "electrum", - feature = "esplora", - feature = "cbf", - feature = "rpc" - ))] - #[test] - fn test_psbt_is_final() { - use super::is_final; - use bdk_wallet::bitcoin::Psbt; - use std::str::FromStr; - - let unsigned_psbt = Psbt::from_str("cHNidP8BAIkBAAAAASWJHzxzyVORV/C3lAynKHVVL7+Rw7/Jj8U9fuvD24olAAAAAAD+////AiBOAAAAAAAAIgAgLzY9yE4jzTFJnHtTjkc+rFAtJ9NB7ENFQ1xLYoKsI1cfqgKVAAAAACIAIFsbWgDeLGU8EA+RGwBDIbcv4gaGG0tbEIhDvwXXa/E7LwEAAAABALUCAAAAAAEBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/////BALLAAD/////AgD5ApUAAAAAIgAgWxtaAN4sZTwQD5EbAEMhty/iBoYbS1sQiEO/Bddr8TsAAAAAAAAAACZqJKohqe3i9hw/cdHe/T+pmd+jaVN1XGkGiXmZYrSL69g2l06M+QEgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQErAPkClQAAAAAiACBbG1oA3ixlPBAPkRsAQyG3L+IGhhtLWxCIQ78F12vxOwEFR1IhA/JV2U/0pXW+iP49QcsYilEvkZEd4phmDM8nV8wC+MeDIQLKhV/gEZYmlsQXnsL5/Uqv5Y8O31tmWW1LQqIBkiqzCVKuIgYCyoVf4BGWJpbEF57C+f1Kr+WPDt9bZlltS0KiAZIqswkEboH3lCIGA/JV2U/0pXW+iP49QcsYilEvkZEd4phmDM8nV8wC+MeDBDS6ZSEAACICAsqFX+ARliaWxBeewvn9Sq/ljw7fW2ZZbUtCogGSKrMJBG6B95QiAgPyVdlP9KV1voj+PUHLGIpRL5GRHeKYZgzPJ1fMAvjHgwQ0umUhAA==").unwrap(); - assert!(is_final(&unsigned_psbt).is_err()); - - let part_signed_psbt = Psbt::from_str("cHNidP8BAIkBAAAAASWJHzxzyVORV/C3lAynKHVVL7+Rw7/Jj8U9fuvD24olAAAAAAD+////AiBOAAAAAAAAIgAgLzY9yE4jzTFJnHtTjkc+rFAtJ9NB7ENFQ1xLYoKsI1cfqgKVAAAAACIAIFsbWgDeLGU8EA+RGwBDIbcv4gaGG0tbEIhDvwXXa/E7LwEAAAABALUCAAAAAAEBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/////BALLAAD/////AgD5ApUAAAAAIgAgWxtaAN4sZTwQD5EbAEMhty/iBoYbS1sQiEO/Bddr8TsAAAAAAAAAACZqJKohqe3i9hw/cdHe/T+pmd+jaVN1XGkGiXmZYrSL69g2l06M+QEgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQErAPkClQAAAAAiACBbG1oA3ixlPBAPkRsAQyG3L+IGhhtLWxCIQ78F12vxOyICA/JV2U/0pXW+iP49QcsYilEvkZEd4phmDM8nV8wC+MeDSDBFAiEAnNPpu6wNX2HXYz8s2q5nXug4cWfvCGD3SSH2CNKm+yECIEQO7/URhUPsGoknMTE+GrYJf9Wxqn9QsuN9FGj32cQpAQEFR1IhA/JV2U/0pXW+iP49QcsYilEvkZEd4phmDM8nV8wC+MeDIQLKhV/gEZYmlsQXnsL5/Uqv5Y8O31tmWW1LQqIBkiqzCVKuIgYCyoVf4BGWJpbEF57C+f1Kr+WPDt9bZlltS0KiAZIqswkEboH3lCIGA/JV2U/0pXW+iP49QcsYilEvkZEd4phmDM8nV8wC+MeDBDS6ZSEAACICAsqFX+ARliaWxBeewvn9Sq/ljw7fW2ZZbUtCogGSKrMJBG6B95QiAgPyVdlP9KV1voj+PUHLGIpRL5GRHeKYZgzPJ1fMAvjHgwQ0umUhAA==").unwrap(); - assert!(is_final(&part_signed_psbt).is_err()); - - let full_signed_psbt = Psbt::from_str("cHNidP8BAIkBAAAAASWJHzxzyVORV/C3lAynKHVVL7+Rw7/Jj8U9fuvD24olAAAAAAD+////AiBOAAAAAAAAIgAgLzY9yE4jzTFJnHtTjkc+rFAtJ9NB7ENFQ1xLYoKsI1cfqgKVAAAAACIAIFsbWgDeLGU8EA+RGwBDIbcv4gaGG0tbEIhDvwXXa/E7LwEAAAABALUCAAAAAAEBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/////BALLAAD/////AgD5ApUAAAAAIgAgWxtaAN4sZTwQD5EbAEMhty/iBoYbS1sQiEO/Bddr8TsAAAAAAAAAACZqJKohqe3i9hw/cdHe/T+pmd+jaVN1XGkGiXmZYrSL69g2l06M+QEgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQErAPkClQAAAAAiACBbG1oA3ixlPBAPkRsAQyG3L+IGhhtLWxCIQ78F12vxOwEFR1IhA/JV2U/0pXW+iP49QcsYilEvkZEd4phmDM8nV8wC+MeDIQLKhV/gEZYmlsQXnsL5/Uqv5Y8O31tmWW1LQqIBkiqzCVKuIgYCyoVf4BGWJpbEF57C+f1Kr+WPDt9bZlltS0KiAZIqswkEboH3lCIGA/JV2U/0pXW+iP49QcsYilEvkZEd4phmDM8nV8wC+MeDBDS6ZSEBBwABCNsEAEgwRQIhAJzT6busDV9h12M/LNquZ17oOHFn7whg90kh9gjSpvshAiBEDu/1EYVD7BqJJzExPhq2CX/Vsap/ULLjfRRo99nEKQFHMEQCIGoFCvJ2zPB7PCpznh4+1jsY03kMie49KPoPDdr7/T9TAiB3jV7wzR9BH11FSbi+8U8gSX95PrBlnp1lOBgTUIUw3QFHUiED8lXZT/Sldb6I/j1ByxiKUS+RkR3imGYMzydXzAL4x4MhAsqFX+ARliaWxBeewvn9Sq/ljw7fW2ZZbUtCogGSKrMJUq4AACICAsqFX+ARliaWxBeewvn9Sq/ljw7fW2ZZbUtCogGSKrMJBG6B95QiAgPyVdlP9KV1voj+PUHLGIpRL5GRHeKYZgzPJ1fMAvjHgwQ0umUhAA==").unwrap(); - assert!(is_final(&full_signed_psbt).is_ok()); - } - - #[cfg(feature = "compiler")] - #[test] - fn test_compile_taproot() { - use super::handle_compile_subcommand; - use bdk_wallet::bitcoin::Network; - use claims::assert_ok; - - // Test simple pk policy compilation to taproot - let json_string = assert_ok!(handle_compile_subcommand( - Network::Testnet, - "pk(A)".to_string(), - "tr".to_string(), - false, - )); - let json_result: serde_json::Value = serde_json::from_str(&json_string).unwrap(); - - let descriptor = json_result.get("descriptor").unwrap().as_str().unwrap(); - assert!(descriptor.starts_with("tr(")); - assert!(descriptor.contains(",pk(A))#")); - assert!(json_result.get("r").is_some()); - - // Test more complex policy - let json_string = assert_ok!(handle_compile_subcommand( - Network::Testnet, - "and(pk(A),pk(B))".to_string(), - "tr".to_string(), - false, - )); - let json_result: serde_json::Value = serde_json::from_str(&json_string).unwrap(); - - let descriptor = json_result.get("descriptor").unwrap().as_str().unwrap(); - assert!(descriptor.starts_with("tr(")); - assert!(descriptor.contains(",and_v(v:pk(A),pk(B)))#")); - assert!(json_result.get("r").is_some()); - } - - #[cfg(feature = "compiler")] - #[test] - fn test_compile_non_taproot_has_no_r() { - use super::handle_compile_subcommand; - use bdk_wallet::bitcoin::Network; - use claims::assert_ok; - - let json_string = assert_ok!(handle_compile_subcommand( - Network::Testnet, - "pk(A)".to_string(), - "wsh".to_string(), - false, - )); - let json_result: serde_json::Value = serde_json::from_str(&json_string).unwrap(); - - let descriptor = json_result.get("descriptor").unwrap().as_str().unwrap(); - assert!(descriptor.starts_with("wsh(pk(A))#")); - assert!(json_result.get("r").is_none()); - } - - #[cfg(feature = "compiler")] - #[test] - fn test_compile_taproot_randomness() { - use super::handle_compile_subcommand; - use bdk_wallet::bitcoin::Network; - use claims::assert_ok; - - // Two compilations of the same policy should produce different internal keys - let result1 = assert_ok!(handle_compile_subcommand( - Network::Testnet, - "pk(A)".to_string(), - "tr".to_string(), - false, - )); - let result2 = assert_ok!(handle_compile_subcommand( - Network::Testnet, - "pk(A)".to_string(), - "tr".to_string(), - false, - )); - - let json1: serde_json::Value = serde_json::from_str(&result1).unwrap(); - let json2: serde_json::Value = serde_json::from_str(&result2).unwrap(); - - let r1 = json1.get("r").unwrap().as_str().unwrap(); - let r2 = json2.get("r").unwrap().as_str().unwrap(); - assert_ne!(r1, r2, "Each compilation should produce a unique r value"); - } - - #[cfg(feature = "compiler")] - #[test] - fn test_compile_invalid_cases() { - use super::handle_compile_subcommand; - use bdk_wallet::bitcoin::Network; - use claims::assert_err; - - // Test invalid policy syntax - assert_err!(handle_compile_subcommand( - Network::Testnet, - "invalid_policy".to_string(), - "tr".to_string(), - false, - )); - - // Test invalid script type - assert_err!(handle_compile_subcommand( - Network::Testnet, - "pk(A)".to_string(), - "invalid_type".to_string(), - false, - )); - - // Test empty policy - assert_err!(handle_compile_subcommand( - Network::Testnet, - "".to_string(), - "tr".to_string(), - false, - )); - - // Test malformed policy with unmatched parentheses - assert_err!(handle_compile_subcommand( - Network::Testnet, - "pk(A".to_string(), - "tr".to_string(), - false, - )); - - // Test policy with unknown function - assert_err!(handle_compile_subcommand( - Network::Testnet, - "unknown_func(A)".to_string(), - "tr".to_string(), - false, - )); - } -} diff --git a/src/handlers/config.rs b/src/handlers/config.rs new file mode 100644 index 00000000..f3b1772c --- /dev/null +++ b/src/handlers/config.rs @@ -0,0 +1,159 @@ +use std::collections::HashMap; + +#[cfg(any( + feature = "electrum", + feature = "esplora", + feature = "rpc", + feature = "cbf" +))] +use crate::client::ClientType; +use crate::commands::WalletOpts; +use crate::config::{WalletConfig, WalletConfigInner}; +use crate::error::BDKCliError as Error; +use crate::handlers::Init; +use crate::handlers::{AppCommand, AppContext}; +#[cfg(feature = "sqlite")] +use crate::persister::DatabaseType; +use crate::utils::types::{StatusResult, WalletsListResult}; +use bdk_wallet::bitcoin::Network; +use clap::Args; + +#[derive(Args, Debug, Clone, PartialEq)] +pub struct SaveConfigCommand { + /// Overwrite existing wallet configuration if it exists. + #[arg(short = 'f', long = "force", default_value_t = false)] + pub(crate) force: bool, + + #[command(flatten)] + pub(crate) wallet_opts: WalletOpts, +} + +impl AppCommand> for SaveConfigCommand { + type Output = StatusResult; + + fn execute(&self, ctx: &mut AppContext) -> Result { + if ctx.network == Network::Bitcoin { + eprintln!("WARNING: Configuring for Bitcoin MAINNET. Experimental software!"); + } + + let wallet_name = match &self.wallet_opts.wallet { + Some(wallet) => wallet, + None => return Err(Error::Generic("wallet is required".to_owned())), + }; + + let ext_descriptor = self.wallet_opts.ext_descriptor.clone(); + let int_descriptor = self.wallet_opts.int_descriptor.clone(); + + if ext_descriptor.contains("xprv") || ext_descriptor.contains("tprv") { + eprintln!( + "WARNING: Your external descriptor contains PRIVATE KEYS. + Private keys will be saved in PLAINTEXT in the config file. + This is a security risk. Consider using public descriptors instead.\n" + ); + } + + if let Some(ref internal_desc) = int_descriptor + && (internal_desc.contains("xprv") || internal_desc.contains("tprv")) + { + eprintln!( + "WARNING: Your internal descriptor contains PRIVATE KEYS. + Private keys will be saved in PLAINTEXT in the config file. + This is a security risk. Consider using public descriptors instead.\n" + ); + } + + let mut config = WalletConfig::load(&ctx.datadir)?.unwrap_or(WalletConfig { + wallets: HashMap::new(), + }); + + if config.wallets.contains_key(wallet_name.as_str()) && !self.force { + return Err(Error::Generic(format!( + "Wallet '{}' already exists. Use --force to overwrite.", + wallet_name + ))); + }; + + #[cfg(any( + feature = "electrum", + feature = "esplora", + feature = "rpc", + feature = "cbf" + ))] + let client_type = match self.wallet_opts.client_type.clone() { + #[cfg(feature = "electrum")] + ClientType::Electrum => "electrum".to_string(), + #[cfg(feature = "esplora")] + ClientType::Esplora => "esplora".to_string(), + #[cfg(feature = "rpc")] + ClientType::Rpc => "rpc".to_string(), + #[cfg(feature = "cbf")] + ClientType::Cbf => "cbf".to_string(), + }; + + let wallet_config = WalletConfigInner { + wallet: wallet_name.clone(), + network: ctx.network.to_string(), + ext_descriptor: self.wallet_opts.ext_descriptor.clone(), + int_descriptor: self.wallet_opts.int_descriptor.clone(), + + #[cfg(any(feature = "sqlite", feature = "redb"))] + database_type: match self.wallet_opts.database_type { + #[cfg(feature = "sqlite")] + DatabaseType::Sqlite => "sqlite".to_string(), + #[cfg(feature = "redb")] + DatabaseType::Redb => "redb".to_string(), + }, + + #[cfg(any( + feature = "electrum", + feature = "esplora", + feature = "rpc", + feature = "cbf" + ))] + client_type: Some(client_type), + + #[cfg(any(feature = "electrum", feature = "esplora", feature = "rpc"))] + server_url: Some(self.wallet_opts.url.clone()), + + #[cfg(feature = "rpc")] + rpc_user: Some(self.wallet_opts.basic_auth.0.clone()), + #[cfg(feature = "rpc")] + rpc_password: Some(self.wallet_opts.basic_auth.1.clone()), + #[cfg(feature = "electrum")] + batch_size: Some(self.wallet_opts.batch_size), + #[cfg(feature = "esplora")] + parallel_requests: Some(self.wallet_opts.parallel_requests), + #[cfg(feature = "rpc")] + cookie: self.wallet_opts.cookie.clone(), + }; + + config.wallets.insert(wallet_name.clone(), wallet_config); + config + .save(&ctx.datadir) + .map_err(|error| Error::Generic(error.to_string()))?; + + Ok(StatusResult { + message: format!( + "Wallet '{}' initialized successfully in {:?}", + wallet_name, + ctx.datadir.join("config.toml") + ), + }) + } +} + +#[derive(Args, Debug, Clone, PartialEq)] +pub struct ListWalletsCommand; + +impl AppCommand> for ListWalletsCommand { + type Output = WalletsListResult; + + fn execute(&self, ctx: &mut AppContext) -> Result { + let config = match WalletConfig::load(&ctx.datadir)? { + Some(cfg) => cfg, + None => return Err(Error::Generic("No wallets configured yet.".into())), + }; + + Ok(WalletsListResult(config.wallets)) + } +} diff --git a/src/handlers/descriptor.rs b/src/handlers/descriptor.rs new file mode 100644 index 00000000..e52863a8 --- /dev/null +++ b/src/handlers/descriptor.rs @@ -0,0 +1,166 @@ +use crate::handlers::Init; +use crate::utils::types::DescriptorResult; +use crate::{ + error::BDKCliError as Error, + handlers::{AppCommand, AppContext}, + utils::{ + descriptors::{ + generate_descriptor_from_mnemonic, generate_descriptor_with_mnemonic, + generate_descriptors, + }, + is_mnemonic, + }, +}; +use clap::Parser; +#[cfg(feature = "compiler")] +use { + bdk_wallet::{ + bitcoin::{ + XOnlyPublicKey, + key::{Parity, rand}, + secp256k1::{PublicKey, Scalar, Secp256k1, SecretKey}, + }, + miniscript::{Descriptor, Miniscript, descriptor::TapTree, policy::Concrete}, + }, + std::{str::FromStr, sync::Arc}, +}; + +#[cfg(feature = "silent-payments")] +use crate::utils::types::StatusResult; + +#[cfg(feature = "compiler")] +const NUMS_UNSPENDABLE_KEY_HEX: &str = + "50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0"; + +#[derive(Parser, Debug, Clone, PartialEq)] +pub struct DescriptorCommand { + /// Descriptor type (script type) + #[arg( + long = "type", + short = 't', + value_parser = ["pkh", "wpkh", "sh", "wsh", "tr"], + default_value = "wsh" + )] + pub(crate) desc_type: String, + + /// Optional key: xprv, xpub, or mnemonic phrase + key: Option, +} +impl AppCommand> for DescriptorCommand { + type Output = DescriptorResult; + + fn execute(&self, ctx: &mut AppContext) -> Result { + match &self.key { + Some(key) => { + if is_mnemonic(key) { + generate_descriptor_from_mnemonic(key, ctx.network, &self.desc_type) + } else { + generate_descriptors(&self.desc_type, key, ctx.network) + } + } + None => generate_descriptor_with_mnemonic(ctx.network, &self.desc_type), + } + } +} + +#[cfg(feature = "compiler")] +#[derive(Parser, Debug, Clone, PartialEq)] +pub struct CompileCommand { + /// Sets the spending policy to compile. + #[arg(env = "POLICY", required = true, index = 1)] + policy: String, + /// Sets the script type used to embed the compiled policy. + #[arg(env = "TYPE", short = 't', long = "type", default_value = "wsh", value_parser = ["sh","wsh", "sh-wsh", "tr"] + )] + script_type: String, +} + +#[cfg(feature = "compiler")] +impl AppCommand> for CompileCommand { + type Output = DescriptorResult; + + fn execute(&self, _ctx: &mut AppContext) -> Result { + let policy: Concrete = Concrete::from_str(&self.policy) + .map_err(|e| Error::Generic(format!("Invalid policy: {e}")))?; + + let legacy_policy: Miniscript = policy + .compile() + .map_err(|e| Error::Generic(e.to_string()))?; + let segwit_policy: Miniscript = policy + .compile() + .map_err(|e| Error::Generic(e.to_string()))?; + let taproot_policy: Miniscript = policy + .compile() + .map_err(|e| Error::Generic(e.to_string()))?; + + let mut r = None; + + let descriptor = match self.script_type.as_str() { + "sh" => Descriptor::new_sh(legacy_policy), + "wsh" => Descriptor::new_wsh(segwit_policy), + "sh-wsh" => Descriptor::new_sh_wsh(segwit_policy), + "tr" => { + // Use a randomized unspendable internal key (H + rG) instead of a fixed NUMS + // point. This improves privacy by preventing observers from determining whether + // key-path spending is disabled. `r` is returned so the user can verify the key + // is derived from the NUMS point. See BIP-341. + let secp = Secp256k1::new(); + let r_secret = SecretKey::new(&mut rand::thread_rng()); + r = Some(r_secret.display_secret().to_string()); + + let nums_key = XOnlyPublicKey::from_str(NUMS_UNSPENDABLE_KEY_HEX) + .map_err(|e| Error::Generic(format!("Invalid NUMS key: {e}")))?; + let nums_point = PublicKey::from_x_only_public_key(nums_key, Parity::Even); + + let internal_key_point = + nums_point + .add_exp_tweak(&secp, &Scalar::from(r_secret)) + .map_err(|e| Error::Generic(format!("Failed to tweak NUMS key: {e}")))?; + let (xonly_internal_key, _) = internal_key_point.x_only_public_key(); + + let tree = TapTree::Leaf(Arc::new(taproot_policy)); + Descriptor::new_tr(xonly_internal_key.to_string(), Some(tree)) + } + _ => { + return Err(Error::Generic( + "Invalid script type. Supported: sh, wsh, sh-wsh, tr".into(), + )); + } + }?; + + Ok(DescriptorResult { + descriptor: Some(descriptor.to_string()), + mnemonic: None, + multipath_descriptor: None, + public_descriptors: None, + private_descriptors: None, + fingerprint: None, + r, + }) + } +} + +#[cfg(feature = "silent-payments")] +#[derive(Parser, Debug, Clone, PartialEq)] +pub struct SilentPaymentCodeCommand { + /// The scan public key to use on the silent payment code. + #[arg(long = "scan_key")] + scan: bdk_sp::bitcoin::secp256k1::PublicKey, + /// The spend public key to use on the silent payment code. + #[arg(long = "spend_key")] + spend: bdk_sp::bitcoin::secp256k1::PublicKey, +} + +#[cfg(feature = "silent-payments")] +impl AppCommand> for SilentPaymentCodeCommand { + type Output = StatusResult; + + fn execute(&self, ctx: &mut AppContext) -> Result { + let sp_code = + bdk_sp::encoding::SilentPaymentCode::new_v0(self.scan, self.spend, ctx.network); + + Ok(StatusResult { + message: sp_code.to_string(), + }) + } +} diff --git a/src/dns_payment_instructions.rs b/src/handlers/dns/dns_payment_instructions.rs similarity index 100% rename from src/dns_payment_instructions.rs rename to src/handlers/dns/dns_payment_instructions.rs diff --git a/src/handlers/dns/mod.rs b/src/handlers/dns/mod.rs new file mode 100644 index 00000000..6e682bc6 --- /dev/null +++ b/src/handlers/dns/mod.rs @@ -0,0 +1,175 @@ +mod dns_payment_instructions; + +use crate::error::BDKCliError as Error; +use crate::handlers::dns::dns_payment_instructions::{ + parse_dns_instructions, process_instructions, resolve_dns_recipient, +}; +use crate::handlers::{AppContext, AsyncAppCommand, Init, OfflineOperations}; +use crate::utils::types::{PsbtResult, StatusResult}; +use crate::utils::{parse_dns_recipient, parse_outpoint, parse_recipient}; +use bdk_wallet::KeychainKind; +use bdk_wallet::bitcoin::base64::Engine; +use bdk_wallet::bitcoin::base64::prelude::BASE64_STANDARD; +use bdk_wallet::bitcoin::script::PushBytesBuf; +use bdk_wallet::bitcoin::{Amount, FeeRate, OutPoint, ScriptBuf, Sequence}; +use clap::Parser; +use std::collections::BTreeMap; + +/// Resolves BIP-353 DNS payment instructions for a human-readable name. +#[derive(Parser, Debug, Clone, PartialEq)] +pub struct ResolveDnsRecipientCommand { + /// Human-readable name (e.g. user@domain.com) + pub hrn: String, + /// DNS resolver address + #[arg(long, default_value = "8.8.8.8")] + pub resolver: String, +} + +impl AsyncAppCommand> for ResolveDnsRecipientCommand { + type Output = StatusResult; + + async fn execute(&self, ctx: &mut AppContext) -> Result { + let resolved = resolve_dns_recipient(&self.hrn, ctx.network, &self.resolver) + .await + .map_err(|e| Error::Generic(format!("{:?}", e)))?; + Ok(StatusResult { + message: resolved.display()?, + }) + } +} + +/// Creates a new unsigned transaction from DNS payment instructions. +#[derive(Parser, Debug, Clone, PartialEq)] +pub struct CreateDnsTxCommand { + #[arg(env = "ADDRESS:SAT", long = "to", value_parser = parse_recipient)] + pub recipients: Vec<(ScriptBuf, u64)>, + #[arg(long = "to_dns", value_parser = parse_dns_recipient)] + pub dns_recipients: Vec<(String, u64)>, + #[arg(long = "dns_resolver", default_value = "8.8.8.8")] + pub dns_resolver: String, + #[arg(long = "send_all", short = 'a')] + pub send_all: bool, + #[arg(long = "enable_rbf", short = 'r', default_value_t = true)] + pub enable_rbf: bool, + #[arg(long = "offline_signer")] + pub offline_signer: bool, + #[arg(env = "MUST_SPEND_TXID:VOUT", long = "utxos", value_parser = parse_outpoint)] + pub utxos: Option>, + #[arg(env = "CANT_SPEND_TXID:VOUT", long = "unspendable", value_parser = parse_outpoint)] + pub unspendable: Option>, + #[arg(env = "SATS_VBYTE", short = 'f', long = "fee_rate")] + pub fee_rate: Option, + #[arg(env = "EXT_POLICY", long = "external_policy")] + pub external_policy: Option, + #[arg(env = "INT_POLICY", long = "internal_policy")] + pub internal_policy: Option, + #[arg( + env = "ADD_STRING", + long = "add_string", + short = 's', + conflicts_with = "add_data" + )] + pub add_string: Option, + #[arg( + env = "ADD_DATA", + long = "add_data", + short = 'o', + conflicts_with = "add_string" + )] + pub add_data: Option, +} + +impl AsyncAppCommand>> for CreateDnsTxCommand { + type Output = PsbtResult; + + async fn execute( + &self, + ctx: &mut AppContext>, + ) -> Result { + let network = ctx.network; + let mut recipients: Vec<(ScriptBuf, u64)> = self.recipients.clone(); + + for (hrn, amount_sat) in &self.dns_recipients { + log::info!("Resolving DNS instructions for recipient {hrn}"); + let amount = Amount::from_sat(*amount_sat); + let (resolver, instructions) = parse_dns_instructions(hrn, network, &self.dns_resolver) + .await + .map_err(|e| Error::Generic(format!("Parsing error occured {e:#?}")))?; + let payment = process_instructions(amount, &instructions, resolver).await?; + recipients.push((payment.0.into(), payment.1.to_sat())); + } + + if recipients.is_empty() { + return Err(Error::Generic( + "Either --to or --to_dns parameters must be specified".to_string(), + )); + } + + let mut tx_builder = ctx.state.wallet.build_tx(); + + if self.send_all { + if recipients.len() == 1 { + tx_builder.drain_wallet().drain_to(recipients[0].0.clone()); + } else { + return Err(Error::Generic( + "Wallet can only be drained to a single output".to_string(), + )); + } + } else { + let recipients = recipients + .into_iter() + .map(|(script, amount)| (script, Amount::from_sat(amount))) + .collect(); + tx_builder.set_recipients(recipients); + } + + if !self.enable_rbf { + tx_builder.set_exact_sequence(Sequence::MAX); + } + if self.offline_signer { + tx_builder.include_output_redeem_witness_script(); + } + if let Some(fee_rate) = self.fee_rate + && let Some(fee_rate) = FeeRate::from_sat_per_vb(fee_rate as u64) + { + tx_builder.fee_rate(fee_rate); + } + if let Some(utxos) = &self.utxos { + tx_builder + .add_utxos(&utxos[..]) + .map_err(|_| bdk_wallet::error::CreateTxError::UnknownUtxo)?; + } + if let Some(unspendable) = &self.unspendable { + tx_builder.unspendable(unspendable.to_vec()); + } + if let Some(base64_data) = &self.add_data { + let op_return_data = BASE64_STANDARD + .decode(base64_data) + .map_err(|e| Error::Generic(e.to_string()))?; + tx_builder.add_data( + &PushBytesBuf::try_from(op_return_data) + .map_err(|e| Error::Generic(e.to_string()))?, + ); + } else if let Some(string_data) = &self.add_string { + let data = PushBytesBuf::try_from(string_data.as_bytes().to_vec()) + .map_err(|e| Error::Generic(e.to_string()))?; + tx_builder.add_data(&data); + } + + let policies = vec![ + self.external_policy + .as_ref() + .map(|p| (p, KeychainKind::External)), + self.internal_policy + .as_ref() + .map(|p| (p, KeychainKind::Internal)), + ]; + for (policy, keychain) in policies.into_iter().flatten() { + let policy = serde_json::from_str::>>(policy)?; + tx_builder.policy_path(policy, keychain); + } + + let psbt = tx_builder.finish()?; + Ok(PsbtResult::new(&psbt, Some(false))) + } +} diff --git a/src/handlers/key.rs b/src/handlers/key.rs new file mode 100644 index 00000000..c1a9e763 --- /dev/null +++ b/src/handlers/key.rs @@ -0,0 +1,154 @@ +use crate::commands::KeySubCommand; +use crate::error::BDKCliError as Error; +use crate::handlers::{AppCommand, AppContext, Init}; +use crate::utils::{output::FormatOutput, types::KeyResult}; +use bdk_wallet::bip39::{Language, Mnemonic}; +use bdk_wallet::bitcoin::bip32::{DerivationPath, KeySource, Xpriv}; +use bdk_wallet::bitcoin::key::Secp256k1; +use bdk_wallet::keys::{ + DerivableKey, DescriptorKey, ExtendedKey, GeneratableKey, GeneratedKey, bip39::WordCount, +}; +use bdk_wallet::miniscript::{self, Segwitv0}; +use clap::Parser; + +impl KeySubCommand { + pub fn execute(&self, ctx: &mut AppContext) -> Result<(), Error> { + match self { + KeySubCommand::Generate(generate_key_command) => generate_key_command + .execute(ctx)? + .write_out(std::io::stdout()), + KeySubCommand::Restore(restore_key_command) => restore_key_command + .execute(ctx)? + .write_out(std::io::stdout()), + KeySubCommand::Derive(derive_key_command) => derive_key_command + .execute(ctx)? + .write_out(std::io::stdout()), + } + } +} +#[derive(Parser, Debug, Clone, PartialEq, Eq)] +pub struct GenerateKeyCommand { + /// Entropy level based on number of random seed mnemonic words. + #[arg( + env = "WORD_COUNT", + short = 'e', + long = "entropy", + default_value = "12" + )] + word_count: usize, + /// Seed password. + #[arg(env = "PASSWORD", short = 'p', long = "password")] + password: Option, +} + +impl AppCommand> for GenerateKeyCommand { + type Output = KeyResult; + + fn execute(&self, ctx: &mut AppContext) -> Result { + let secp = Secp256k1::new(); + let mnemonic_type = match self.word_count { + 12 => WordCount::Words12, + _ => WordCount::Words24, + }; + + let mnemonic: GeneratedKey<_, miniscript::BareCtx> = + Mnemonic::generate((mnemonic_type, Language::English)) + .map_err(|_| Error::Generic("Mnemonic generation error".to_string()))?; + let mnemonic = mnemonic.into_key(); + let xkey: ExtendedKey = (mnemonic.clone(), self.password.clone()).into_extended_key()?; + let xprv = xkey.into_xprv(ctx.network).ok_or_else(|| { + Error::Generic("Privatekey info not found (should not happen)".to_string()) + })?; + let fingerprint = xprv.fingerprint(&secp); + let phrase = mnemonic + .words() + .fold("".to_string(), |phrase, w| phrase + w + " ") + .trim() + .to_string(); + + Ok(KeyResult { + xprv: xprv.to_string(), + mnemonic: Some(phrase), + fingerprint: Some(fingerprint.to_string()), + xpub: None, + }) + } +} + +#[derive(Parser, Debug, Clone, PartialEq, Eq)] +pub struct DeriveKeyCommand { + /// Extended private key to derive from. + #[arg(env = "XPRV", short = 'x', long = "xprv")] + xprv: Xpriv, + /// Path to use to derive extended public key from extended private key. + #[arg(env = "DERIVATION_PATH", short = 'p', long = "derivation_path")] + path: DerivationPath, +} + +impl AppCommand> for DeriveKeyCommand { + type Output = KeyResult; + + fn execute(&self, ctx: &mut AppContext) -> Result { + let secp = Secp256k1::new(); + + let derived_xprv = &self.xprv.derive_priv(&secp, &self.path)?; + + let origin: KeySource = (self.xprv.fingerprint(&secp), self.path.clone()); + + if self.xprv.network != ctx.network.into() { + return Err(Error::Generic( + "Extended key network does not match current network".to_string(), + )); + } + + let derived_xprv_desc_key: DescriptorKey = + derived_xprv.into_descriptor_key(Some(origin), DerivationPath::default())?; + + if let DescriptorKey::Secret(desc_seckey, _, _) = derived_xprv_desc_key { + let desc_pubkey = desc_seckey.to_public(&secp)?; + + Ok(KeyResult { + xprv: desc_seckey.to_string(), + xpub: Some(desc_pubkey.to_string()), + mnemonic: None, + fingerprint: None, + }) + } else { + Err(Error::Generic( + "Derived key is not a secret key".to_string(), + )) + } + } +} + +#[derive(Parser, Debug, Clone, PartialEq, Eq)] +pub struct RestoreKeyCommand { + /// Seed mnemonic words, must be quoted (eg. "word1 word2 ..."). + #[arg(env = "MNEMONIC", short = 'm', long = "mnemonic")] + mnemonic: String, + /// Seed password. + #[arg(env = "PASSWORD", short = 'p', long = "password")] + password: Option, +} + +impl AppCommand> for RestoreKeyCommand { + type Output = KeyResult; + + fn execute(&self, ctx: &mut AppContext) -> Result { + let secp = Secp256k1::new(); + + let mnemonic = Mnemonic::parse_in(Language::English, &self.mnemonic)?; + let xkey: ExtendedKey = (mnemonic.clone(), &self.password).0.into_extended_key()?; + let xprv = xkey.into_xprv(ctx.network).ok_or_else(|| { + Error::Generic("Privatekey info not found (should not happen)".to_string()) + })?; + let fingerprint = xprv.fingerprint(&secp); + + Ok(KeyResult { + xprv: xprv.to_string(), + mnemonic: Some(mnemonic.to_string()), + fingerprint: Some(fingerprint.to_string()), + xpub: None, + }) + } +} diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs new file mode 100644 index 00000000..a2c4ad5b --- /dev/null +++ b/src/handlers/mod.rs @@ -0,0 +1,123 @@ +pub mod config; +pub mod descriptor; +#[cfg(feature = "dns_payment")] +pub mod dns; +pub mod key; +pub mod offline; +pub mod online; +#[cfg(any( + feature = "electrum", + feature = "esplora", + feature = "cbf", + feature = "rpc" +))] +pub mod payjoin; +pub mod repl; + +#[cfg(any( + feature = "electrum", + feature = "esplora", + feature = "rpc", + feature = "cbf" +))] +use crate::client::BlockchainClient; +use std::path::PathBuf; + +use crate::{error::BDKCliError as Error, utils::output::FormatOutput}; +use bdk_wallet::{Wallet, bitcoin::Network}; + +// The state for no wallet, no client. +pub struct Init; + +/// Offline wallet operations. +/// Requires only a wallet. +pub struct OfflineOperations<'a> { + pub wallet: &'a mut Wallet, +} + +#[cfg(any( + feature = "electrum", + feature = "esplora", + feature = "rpc", + feature = "cbf" +))] +/// Online wallet operations. +/// Requires a wallet and a client. +pub struct OnlineOperations<'a> { + pub wallet: &'a mut Wallet, + pub client: &'a BlockchainClient, + pub wallet_name: String, +} + +/// The generic context +pub struct AppContext { + pub network: Network, + pub datadir: PathBuf, + pub state: S, +} + +/// Construct for a specific state. +impl AppContext { + pub fn new(network: Network, datadir: PathBuf) -> Self { + Self { + network, + datadir, + state: Init, + } + } +} + +impl<'a> AppContext> { + pub fn new_offline_wallet(network: Network, datadir: PathBuf, wallet: &'a mut Wallet) -> Self { + Self { + network, + datadir, + state: OfflineOperations { wallet }, + } + } +} + +#[cfg(any( + feature = "electrum", + feature = "esplora", + feature = "rpc", + feature = "cbf" +))] +impl<'a> AppContext> { + pub fn new_online_wallet( + network: Network, + datadir: PathBuf, + wallet: &'a mut Wallet, + client: &'a BlockchainClient, + wallet_name: String, + ) -> Self { + Self { + network, + datadir, + state: OnlineOperations { + wallet, + client, + wallet_name, + }, + } + } +} + +pub trait AppCommand { + type Output: FormatOutput; + + fn execute(&self, ctx: &mut C) -> Result; +} + +#[cfg(any( + feature = "electrum", + feature = "esplora", + feature = "rpc", + feature = "cbf", + feature = "dns_payment" +))] +pub trait AsyncAppCommand { + type Output: FormatOutput; + + async fn execute(&self, ctx: &mut C) -> Result; +} diff --git a/src/handlers/offline.rs b/src/handlers/offline.rs new file mode 100644 index 00000000..96e23cf4 --- /dev/null +++ b/src/handlers/offline.rs @@ -0,0 +1,854 @@ +use crate::commands::OfflineWalletSubCommand; +use crate::error::BDKCliError as Error; +use crate::handlers::{AppCommand, AppContext, OfflineOperations}; +use crate::utils::output::{FormatOutput, ListResult}; +use crate::utils::parse_address; +use crate::utils::types::{ + AddressResult, BalanceResult, KeychainPair, PsbtResult, RawPsbt, TransactionDetails, + UnspentDetails, +}; +use crate::utils::{parse_outpoint, parse_recipient}; +use bdk_wallet::bitcoin::base64::Engine; +use bdk_wallet::bitcoin::base64::prelude::BASE64_STANDARD; +use bdk_wallet::bitcoin::script::PushBytesBuf; +use bdk_wallet::bitcoin::{Address, Amount, FeeRate, OutPoint, Psbt, ScriptBuf, Sequence, Txid}; +use bdk_wallet::{KeychainKind, SignOptions}; +use clap::Parser; +use serde_json::json; +use std::collections::BTreeMap; +use std::str::FromStr; +#[cfg(feature = "silent-payments")] +use { + crate::utils::common::parse_sp_code_value_pairs, + bdk_sp::{ + bitcoin::{PrivateKey, PublicKey}, + encoding::SilentPaymentCode, + send::psbt::derive_sp, + }, + bdk_wallet::bitcoin::key::Secp256k1, + bdk_wallet::keys::{DescriptorPublicKey, DescriptorSecretKey, SinglePubKey}, + std::collections::HashMap, +}; +#[cfg(feature = "bip322")] +use { + crate::utils::parse_signature_format, + crate::utils::types::MessageResult, + bdk_bip322::{BIP322, MessageProof}, +}; + +impl OfflineWalletSubCommand { + pub fn execute(&self, ctx: &mut AppContext>) -> Result<(), Error> { + match self { + Self::NewAddress(new_address) => new_address.execute(ctx)?.write_out(std::io::stdout()), + Self::Balance(balance) => balance.execute(ctx)?.write_out(std::io::stdout()), + Self::UnusedAddress(unused_address_command) => unused_address_command + .execute(ctx)? + .write_out(std::io::stdout()), + Self::Unspent(unspent_command) => { + unspent_command.execute(ctx)?.write_out(std::io::stdout()) + } + Self::Transactions(transactions_command) => transactions_command + .execute(ctx)? + .write_out(std::io::stdout()), + Self::CreateTx(createtx_command) => { + createtx_command.execute(ctx)?.write_out(std::io::stdout()) + } + #[cfg(feature = "silent-payments")] + Self::CreateSpTx(cmd) => cmd.execute(ctx)?.write_out(std::io::stdout()), + Self::BumpFee(bumpfee_command) => { + bumpfee_command.execute(ctx)?.write_out(std::io::stdout()) + } + Self::Policies(policies_command) => { + policies_command.execute(ctx)?.write_out(std::io::stdout()) + } + Self::PublicDescriptor(public_descriptor_command) => public_descriptor_command + .execute(ctx)? + .write_out(std::io::stdout()), + Self::Sign(sign_command) => sign_command.execute(ctx)?.write_out(std::io::stdout()), + Self::ExtractPsbt(extract_psbt_command) => extract_psbt_command + .execute(ctx)? + .write_out(std::io::stdout()), + Self::FinalizePsbt(finalize_psbt_command) => finalize_psbt_command + .execute(ctx)? + .write_out(std::io::stdout()), + Self::CombinePsbt(combine_psbt_command) => combine_psbt_command + .execute(ctx)? + .write_out(std::io::stdout()), + #[cfg(feature = "bip322")] + Self::SignMessage(sign_message_command) => sign_message_command + .execute(ctx)? + .write_out(std::io::stdout()), + #[cfg(feature = "bip322")] + Self::VerifyMessage(verify_message_command) => verify_message_command + .execute(ctx)? + .write_out(std::io::stdout()), + #[cfg(feature = "dns_payment")] + Self::CreateDnsTx(_) => Err(Error::Generic( + "CreateDnsTx is dispatched asynchronously through main".to_string(), + )), + } + } +} + +#[derive(Parser, Debug, Clone, PartialEq)] +pub struct NewAddressCommand; + +impl AppCommand>> for NewAddressCommand { + type Output = AddressResult; + + fn execute(&self, ctx: &mut AppContext>) -> Result { + let wallet = &mut ctx.state.wallet; + let address_info = wallet.reveal_next_address(KeychainKind::External); + Ok(AddressResult::from(address_info)) + } +} + +#[derive(Parser, Debug, PartialEq, Clone)] +pub struct UnusedAddressCommand; + +impl AppCommand>> for UnusedAddressCommand { + type Output = AddressResult; + + fn execute(&self, ctx: &mut AppContext>) -> Result { + let wallet = &mut ctx.state.wallet; + let address_info = wallet.next_unused_address(KeychainKind::External); + Ok(AddressResult::from(address_info)) + } +} + +#[derive(Parser, Debug, PartialEq, Clone)] +pub struct UnspentCommand; + +impl AppCommand>> for UnspentCommand { + type Output = ListResult; + + fn execute(&self, ctx: &mut AppContext>) -> Result { + let wallet = &mut ctx.state.wallet; + let utxos = wallet + .list_unspent() + .map(|utxo| UnspentDetails::from_local_output(&utxo, ctx.network)) + .collect(); + + Ok(ListResult::new(utxos)) + } +} + +#[derive(Parser, Debug, PartialEq, Clone)] +pub struct TransactionsCommand; + +impl AppCommand>> for TransactionsCommand { + type Output = ListResult; + + fn execute(&self, ctx: &mut AppContext>) -> Result { + let transactions = &mut ctx.state.wallet.transactions(); + + let txns: Vec = transactions + .map(|tx| { + let total_value = tx + .tx_node + .output + .iter() + .map(|output| output.value.to_sat()) + .sum::(); + + TransactionDetails { + txid: tx.tx_node.txid.to_string(), + is_coinbase: tx.tx_node.is_coinbase(), + wtxid: tx.tx_node.compute_wtxid().to_string(), + version: serde_json::to_value(tx.tx_node.version).unwrap_or(json!(1)), + version_display: tx.tx_node.version.to_string(), + is_rbf: tx.tx_node.is_explicitly_rbf(), + inputs: serde_json::to_value(&tx.tx_node.input).unwrap_or_default(), + outputs: serde_json::to_value(&tx.tx_node.output).unwrap_or_default(), + input_count: tx.tx_node.input.len(), + output_count: tx.tx_node.output.len(), + total_value, + } + }) + .collect(); + + Ok(ListResult::new(txns)) + } +} + +#[derive(Parser, Debug, PartialEq, Clone)] +pub struct BalanceCommand; + +impl AppCommand>> for BalanceCommand { + type Output = BalanceResult; + + fn execute(&self, ctx: &mut AppContext>) -> Result { + let balance = ctx.state.wallet.balance(); + Ok(BalanceResult::from(balance)) + } +} +#[derive(Debug, Parser, Clone, PartialEq)] +pub struct CreateTxCommand { + /// Adds a recipient to the transaction. + #[arg(env = "ADDRESS:SAT", long = "to", required = true, value_parser = parse_recipient)] + pub recipients: Vec<(ScriptBuf, u64)>, + + /// Sends all the funds (or all the selected utxos). Requires only one recipient with value 0. + #[arg(long = "send_all", short = 'a')] + pub send_all: bool, + + /// Enables Replace-By-Fee (BIP125). + #[arg(long = "enable_rbf", short = 'r', default_value_t = true)] + pub enable_rbf: bool, + + /// Make a PSBT that can be signed by offline signers and hardware wallets. Forces the addition of `non_witness_utxo` and more details to let the signer identify the change output. + #[arg(long = "offline_signer")] + pub offline_signer: bool, + + /// Selects which utxos *must* be spent. + #[arg(env = "MUST_SPEND_TXID:VOUT", long = "utxos", value_parser = parse_outpoint)] + pub utxos: Option>, + + /// Marks a utxo as unspendable. + #[arg(env = "CANT_SPEND_TXID:VOUT", long = "unspendable", value_parser = parse_outpoint)] + pub unspendable: Option>, + + /// Fee rate to use in sat/vbyte. + #[arg(env = "SATS_VBYTE", short = 'f', long = "fee_rate")] + pub fee_rate: Option, + + /// Selects which policy should be used to satisfy the external descriptor. + #[arg(env = "EXT_POLICY", long = "external_policy")] + pub external_policy: Option, + + /// Selects which policy should be used to satisfy the internal descriptor. + #[arg(env = "INT_POLICY", long = "internal_policy")] + pub internal_policy: Option, + + /// Optionally create an OP_RETURN output containing given String in utf8 encoding (max 80 bytes) + #[arg( + env = "ADD_STRING", + long = "add_string", + short = 's', + conflicts_with = "add_data" + )] + pub add_string: Option, + + /// Optionally create an OP_RETURN output containing given base64 encoded String. (max 80 bytes) + #[arg( + env = "ADD_DATA", + long = "add_data", + short = 'o', + conflicts_with = "add_string" + )] + pub add_data: Option, +} + +impl AppCommand>> for CreateTxCommand { + type Output = PsbtResult; + + fn execute(&self, ctx: &mut AppContext>) -> Result { + let mut tx_builder = ctx.state.wallet.build_tx(); + + if self.send_all { + if self.recipients.len() == 1 { + tx_builder + .drain_wallet() + .drain_to(self.recipients[0].0.clone()); + } else { + return Err(Error::Generic( + "Wallet can only be drained to a single output".to_string(), + )); + } + } else { + let recipients = self + .recipients + .clone() + .into_iter() + .map(|(script, amount)| (script, Amount::from_sat(amount))) + .collect(); + tx_builder.set_recipients(recipients); + } + + if !self.enable_rbf { + tx_builder.set_exact_sequence(Sequence::MAX); + } + + if self.offline_signer { + tx_builder.include_output_redeem_witness_script(); + } + + if let Some(fee_rate) = self.fee_rate + && let Some(fee_rate) = FeeRate::from_sat_per_vb(fee_rate as u64) + { + tx_builder.fee_rate(fee_rate); + } + + if let Some(utxos) = &self.utxos { + tx_builder.add_utxos(&utxos[..]).unwrap(); + } + + if let Some(unspendable) = &self.unspendable { + tx_builder.unspendable(unspendable.to_vec()); + } + + if let Some(base64_data) = &self.add_data { + let op_return_data = BASE64_STANDARD.decode(base64_data).unwrap(); + tx_builder.add_data(&PushBytesBuf::try_from(op_return_data).unwrap()); + } else if let Some(string_data) = &self.add_string { + let data = PushBytesBuf::try_from(string_data.as_bytes().to_vec()).unwrap(); + tx_builder.add_data(&data); + } + + let policies = vec![ + self.external_policy + .clone() + .map(|p| (p, KeychainKind::External)), + self.internal_policy + .clone() + .map(|p| (p, KeychainKind::Internal)), + ]; + + for (policy, keychain) in policies.into_iter().flatten() { + let policy = serde_json::from_str::>>(&policy)?; + tx_builder.policy_path(policy, keychain); + } + + let psbt = tx_builder.finish()?; + + Ok(PsbtResult::new(&psbt, Some(false))) + } +} + +#[cfg(feature = "silent-payments")] +#[derive(Debug, Parser, Clone, PartialEq)] +pub struct CreateSpTxCommand { + /// Adds a recipient to the transaction. + // Clap Doesn't support complex vector parsing https://github.com/clap-rs/clap/issues/1704. + // Address and amount parsing is done at run time in handler function. + #[arg(env = "ADDRESS:SAT", long = "to", required = false, value_parser = parse_recipient)] + pub recipients: Option>, + /// Parse silent payment recipients + #[arg(long = "to-sp", required = true, value_parser = parse_sp_code_value_pairs)] + pub silent_payment_recipients: Vec<(SilentPaymentCode, u64)>, + /// Sends all the funds (or all the selected utxos). Requires only one recipient with value 0. + #[arg(long = "send_all", short = 'a')] + pub send_all: bool, + /// Make a PSBT that can be signed by offline signers and hardware wallets. Forces the addition of `non_witness_utxo` and more details to let the signer identify the change output. + #[arg(long = "offline_signer")] + pub offline_signer: bool, + /// Selects which utxos *must* be spent. + #[arg(env = "MUST_SPEND_TXID:VOUT", long = "utxos", value_parser = parse_outpoint)] + pub utxos: Option>, + /// Marks a utxo as unspendable. + #[arg(env = "CANT_SPEND_TXID:VOUT", long = "unspendable", value_parser = parse_outpoint)] + pub unspendable: Option>, + /// Fee rate to use in sat/vbyte. + #[arg(env = "SATS_VBYTE", short = 'f', long = "fee_rate")] + pub fee_rate: Option, + /// Selects which policy should be used to satisfy the external descriptor. + #[arg(env = "EXT_POLICY", long = "external_policy")] + pub external_policy: Option, + /// Selects which policy should be used to satisfy the internal descriptor. + #[arg(env = "INT_POLICY", long = "internal_policy")] + pub internal_policy: Option, + /// Optionally create an OP_RETURN output containing given String in utf8 encoding (max 80 bytes) + #[arg( + env = "ADD_STRING", + long = "add_string", + short = 's', + conflicts_with = "add_data" + )] + pub add_string: Option, + /// Optionally create an OP_RETURN output containing given base64 encoded String. (max 80 bytes) + #[arg( + env = "ADD_DATA", + long = "add_data", + short = 'o', + conflicts_with = "add_string" + )] + pub add_data: Option, //base 64 econding +} + +#[cfg(feature = "silent-payments")] +impl AppCommand>> for CreateSpTxCommand { + type Output = RawPsbt; + + fn execute(&self, ctx: &mut AppContext>) -> Result { + let mut tx_builder = ctx.state.wallet.build_tx(); + + let sp_recipients: Vec = self + .silent_payment_recipients + .iter() + .map(|(sp_code, _)| sp_code.clone()) + .collect(); + + if self.send_all { + if sp_recipients.len() == 1 && self.recipients.is_none() { + tx_builder + .drain_wallet() + .drain_to(sp_recipients[0].get_placeholder_p2tr_spk()); + } else if let Some(ref recipients) = self.recipients + && sp_recipients.is_empty() + { + if recipients.len() == 1 { + tx_builder.drain_wallet().drain_to(recipients[0].0.clone()); + } else { + return Err(Error::Generic( + "Wallet can only be drain to a single output".to_string(), + )); + } + } else { + return Err(Error::Generic( + "Wallet can only be drain to a single output".to_string(), + )); + } + } else { + let mut outputs: Vec<(ScriptBuf, Amount)> = self + .silent_payment_recipients + .iter() + .map(|(sp_code, amount)| { + let script = sp_code.get_placeholder_p2tr_spk(); + (script, Amount::from_sat(*amount)) + }) + .collect(); + + if let Some(recipients) = &self.recipients { + let recipients = recipients + .iter() + .map(|(script, amount)| (script.clone(), Amount::from_sat(*amount))); + + outputs.extend(recipients); + } + + tx_builder.set_recipients(outputs); + } + + // Do not enable RBF for this transaction + tx_builder.set_exact_sequence(Sequence::MAX); + + if self.offline_signer { + tx_builder.include_output_redeem_witness_script(); + } + + if let Some(fee_rate) = self.fee_rate + && let Some(fee_rate) = FeeRate::from_sat_per_vb(fee_rate as u64) + { + tx_builder.fee_rate(fee_rate); + } + + if let Some(utxos) = &self.utxos { + tx_builder + .add_utxos(&utxos[..]) + .map_err(|_| bdk_wallet::error::CreateTxError::UnknownUtxo)?; + } + + if let Some(unspendable) = &self.unspendable { + tx_builder.unspendable(unspendable.to_vec()); + } + + if let Some(base64_data) = &self.add_data { + let op_return_data = BASE64_STANDARD + .decode(base64_data) + .map_err(|e| Error::Generic(e.to_string()))?; + tx_builder.add_data( + &PushBytesBuf::try_from(op_return_data) + .map_err(|e| Error::Generic(e.to_string()))?, + ); + } else if let Some(string_data) = &self.add_string { + let data = PushBytesBuf::try_from(string_data.as_bytes().to_vec()) + .map_err(|e| Error::Generic(e.to_string()))?; + tx_builder.add_data(&data); + } + + let policies = vec![ + self.external_policy + .as_ref() + .map(|p| (p, KeychainKind::External)), + self.internal_policy + .as_ref() + .map(|p| (p, KeychainKind::Internal)), + ]; + + for (policy, keychain) in policies.into_iter().flatten() { + let policy = serde_json::from_str::>>(policy)?; + tx_builder.policy_path(policy, keychain); + } + + let mut psbt = tx_builder.finish()?; + + let unsigned_psbt = psbt.clone(); + + let finalized = ctx.state.wallet.sign(&mut psbt, SignOptions::default())?; + + if !finalized { + return Err(Error::Generic( + "Cannot produce silent payment outputs without intermediate signing phase." + .to_string(), + )); + } + + for (full_input, psbt_input) in unsigned_psbt.inputs.iter().zip(psbt.inputs.iter_mut()) { + // repopulate key derivation data + psbt_input.bip32_derivation = full_input.bip32_derivation.clone(); + psbt_input.tap_key_origins = full_input.tap_key_origins.clone(); + } + + let secp = Secp256k1::new(); + let mut external_signers = ctx + .state + .wallet + .get_signers(KeychainKind::External) + .as_key_map(&secp); + let internal_signers = ctx + .state + .wallet + .get_signers(KeychainKind::Internal) + .as_key_map(&secp); + external_signers.extend(internal_signers); + + match external_signers.iter().next().expect("not empty") { + (DescriptorPublicKey::Single(single_pub), DescriptorSecretKey::Single(prv)) => { + match single_pub.key { + SinglePubKey::FullKey(pk) => { + let keys: HashMap = [(pk, prv.key)].into(); + derive_sp(&mut psbt, &keys, &sp_recipients, &secp).expect("will fix later"); + } + SinglePubKey::XOnly(xonly) => { + let keys: HashMap = + [(xonly, prv.key)].into(); + derive_sp(&mut psbt, &keys, &sp_recipients, &secp).expect("will fix later"); + } + }; + } + (_, DescriptorSecretKey::XPrv(k)) => { + derive_sp(&mut psbt, &k.xkey, &sp_recipients, &secp).expect("will fix later"); + } + _ => unimplemented!("multi xkey signer"), + }; + + // Unfinalize PSBT to resign + for psbt_input in psbt.inputs.iter_mut() { + psbt_input.final_script_sig = None; + psbt_input.final_script_witness = None; + } + + let _resigned = ctx.state.wallet.sign(&mut psbt, SignOptions::default())?; + + let raw_tx = psbt.extract_tx()?; + + Ok(RawPsbt::new(&raw_tx)) + } +} + +#[derive(Debug, Parser, Clone, PartialEq)] +pub struct BumpFeeCommand { + /// TXID of the transaction to update. + #[arg(env = "TXID", long = "txid")] + pub txid: String, + + /// Allows the wallet to reduce the amount to the specified address in order to increase fees. + #[arg(env = "SHRINK_ADDRESS", long = "shrink", value_parser = parse_address)] + pub shrink_address: Option
, + + /// Make a PSBT that can be signed by offline signers and hardware wallets. Forces the addition of `non_witness_utxo` and more details to let the signer identify the change output. + #[arg(long = "offline_signer")] + pub offline_signer: bool, + + /// Selects which utxos *must* be added to the tx. Unconfirmed utxos cannot be used. + #[arg(env = "MUST_SPEND_TXID:VOUT", long = "utxos", value_parser = parse_outpoint)] + pub utxos: Option>, + + /// Marks an utxo as unspendable, in case more inputs are needed to cover the extra fees. + #[arg(env = "CANT_SPEND_TXID:VOUT", long = "unspendable", value_parser = parse_outpoint)] + pub unspendable: Option>, + + /// The new targeted fee rate in sat/vbyte. + #[arg( + env = "SATS_VBYTE", + short = 'f', + long = "fee_rate", + default_value = "1.0" + )] + pub fee_rate: f32, +} + +impl AppCommand>> for BumpFeeCommand { + type Output = PsbtResult; + + fn execute(&self, ctx: &mut AppContext>) -> Result { + let wallet = &mut ctx.state.wallet; + + let txid = Txid::from_str(self.txid.as_str())?; + + let mut tx_builder = wallet.build_fee_bump(txid)?; + let fee_rate = + FeeRate::from_sat_per_vb(self.fee_rate as u64).unwrap_or(FeeRate::BROADCAST_MIN); + tx_builder.fee_rate(fee_rate); + + if let Some(address) = &self.shrink_address { + let script_pubkey = address.script_pubkey(); + tx_builder.drain_to(script_pubkey); + } + + if self.offline_signer { + tx_builder.include_output_redeem_witness_script(); + } + + if let Some(utxos) = &self.utxos { + tx_builder.add_utxos(&utxos[..]).unwrap(); + } + + if let Some(unspendable) = &self.unspendable { + tx_builder.unspendable(unspendable.to_vec()); + } + + let psbt = tx_builder.finish()?; + + // let psbt_base64 = BASE64_STANDARD.encode(psbt.serialize()); + + Ok(PsbtResult::new(&psbt, Some(false))) + } +} + +#[derive(Parser, Debug, PartialEq, Clone)] +pub struct PoliciesCommand; + +impl AppCommand>> for PoliciesCommand { + type Output = KeychainPair; + + fn execute(&self, ctx: &mut AppContext>) -> Result { + let wallet = &mut ctx.state.wallet; + let external_policy = wallet.policies(KeychainKind::External)?; + let internal_policy = wallet.policies(KeychainKind::Internal)?; + + Ok(KeychainPair { + external: serde_json::to_value(&external_policy).unwrap_or(serde_json::json!(null)), + internal: serde_json::to_value(&internal_policy).unwrap_or(serde_json::json!(null)), + }) + } +} + +#[derive(Parser, Debug, PartialEq, Clone)] +pub struct PublicDescriptorCommand; + +impl AppCommand>> for PublicDescriptorCommand { + type Output = KeychainPair; + + fn execute(&self, ctx: &mut AppContext>) -> Result { + let wallet = &mut ctx.state.wallet; + Ok(KeychainPair { + external: wallet.public_descriptor(KeychainKind::External).to_string(), + internal: wallet.public_descriptor(KeychainKind::Internal).to_string(), + }) + } +} + +#[derive(Debug, Parser, Clone, PartialEq)] +pub struct SignCommand { + /// Sets the PSBT to sign. + #[arg(env = "BASE64_PSBT")] + pub psbt: String, + + /// Assume the blockchain has reached a specific height. This affects the transaction finalization, if there are timelocks in the descriptor. + #[arg(env = "HEIGHT", long = "assume_height")] + pub assume_height: Option, + + /// Whether the signer should trust the witness_utxo, if the non_witness_utxo hasn’t been provided. + #[arg(env = "WITNESS", long = "trust_witness_utxo")] + pub trust_witness_utxo: Option, +} + +impl AppCommand>> for SignCommand { + type Output = PsbtResult; + + fn execute(&self, ctx: &mut AppContext>) -> Result { + let wallet = &mut ctx.state.wallet; + let psbt_bytes = BASE64_STANDARD + .decode(&self.psbt) + .map_err(|e| Error::Generic(e.to_string()))?; + let mut psbt = Psbt::deserialize(&psbt_bytes).map_err(|e| Error::Generic(e.to_string()))?; + + let signopt = SignOptions { + assume_height: self.assume_height, + trust_witness_utxo: self.trust_witness_utxo.unwrap_or(false), + ..Default::default() + }; + let finalized = wallet.sign(&mut psbt, signopt)?; + Ok(PsbtResult::new(&psbt, Some(finalized))) + } +} + +#[derive(Debug, Parser, Clone, PartialEq)] +pub struct ExtractPsbtCommand { + /// Sets the PSBT to extract + #[arg(env = "BASE64_PSBT")] + pub psbt: String, +} + +impl AppCommand>> for ExtractPsbtCommand { + type Output = RawPsbt; + + fn execute(&self, _ctx: &mut AppContext>) -> Result { + let psbt_serialized = BASE64_STANDARD.decode(self.psbt.clone())?; + let psbt = Psbt::deserialize(&psbt_serialized)?; + let raw_tx = psbt.extract_tx()?; + + Ok(RawPsbt::new(&raw_tx)) + } +} + +#[derive(Debug, Parser, Clone, PartialEq)] +pub struct FinalizePsbtCommand { + /// Sets the PSBT to finalize. + #[arg(env = "BASE64_PSBT")] + pub psbt: String, + + /// Assume the blockchain has reached a specific height. + #[arg(env = "HEIGHT", long = "assume_height")] + pub assume_height: Option, + + /// Whether the signer should trust the witness_utxo, if the non_witness_utxo hasn’t been provided. + #[arg(env = "WITNESS", long = "trust_witness_utxo")] + pub trust_witness_utxo: Option, +} + +impl AppCommand>> for FinalizePsbtCommand { + type Output = PsbtResult; + + fn execute(&self, ctx: &mut AppContext>) -> Result { + let wallet = &mut ctx.state.wallet; + let psbt_bytes = BASE64_STANDARD + .decode(&self.psbt) + .map_err(|e| Error::Generic(e.to_string()))?; + let mut psbt = Psbt::deserialize(&psbt_bytes).map_err(|e| Error::Generic(e.to_string()))?; + + let signopt = SignOptions { + assume_height: self.assume_height, + trust_witness_utxo: self.trust_witness_utxo.unwrap_or(false), + ..Default::default() + }; + + let finalized = wallet.finalize_psbt(&mut psbt, signopt)?; + + Ok(PsbtResult::new(&psbt, Some(finalized))) + } +} + +#[derive(Debug, Parser, Clone, PartialEq)] +pub struct CombinePsbtCommand { + /// Add one PSBT to combine. This option can be repeated multiple times, one for each PSBT. + #[arg(env = "BASE64_PSBT", required = true)] + pub psbt: Vec, +} + +impl AppCommand>> for CombinePsbtCommand { + type Output = PsbtResult; + + fn execute(&self, _ctx: &mut AppContext>) -> Result { + let mut psbts = self + .psbt + .iter() + .map(|s| { + let psbt = BASE64_STANDARD.decode(s)?; + Ok(Psbt::deserialize(&psbt)?) + }) + .collect::, Error>>()?; + + let init_psbt = psbts + .pop() + .ok_or_else(|| Error::Generic("Invalid PSBT input".to_string()))?; + let final_psbt = + psbts + .into_iter() + .try_fold::<_, _, Result>(init_psbt, |mut acc, x| { + let _ = acc.combine(x); + Ok(acc) + })?; + + Ok(PsbtResult::new(&final_psbt, None)) + } +} + +#[cfg(feature = "bip322")] +#[derive(Debug, Parser, Clone, PartialEq)] +pub struct SignMessageCommand { + /// The message to sign + #[arg(long)] + pub message: String, + + /// The signature format (e.g., Legacy, Simple, Full) + #[arg(long, default_value = "simple")] + pub signature_type: String, + + /// Address to sign + #[arg(long)] + pub address: String, + + /// Optional list of specific UTXOs for proof-of-funds (only for `FullWithProofOfFunds`) + #[arg(long)] + pub utxos: Option>, +} + +#[cfg(feature = "bip322")] +impl AppCommand>> for SignMessageCommand { + type Output = MessageResult; + + fn execute(&self, ctx: &mut AppContext>) -> Result { + let wallet = &mut ctx.state.wallet; + let address: Address = parse_address(&self.address)?; + let signature_format = parse_signature_format(&self.signature_type)?; + + if !wallet.is_mine(address.script_pubkey()) { + return Err(Error::Generic(format!( + "Address {} does not belong to this wallet.", + address + ))); + } + + let proof = wallet.sign_message( + self.message.as_str(), + signature_format, + &address, + self.utxos.clone(), + )?; + + Ok(MessageResult { + proof: Some(proof.to_base64()), + ..Default::default() + }) + } +} + +#[cfg(feature = "bip322")] +#[derive(Debug, Parser, Clone, PartialEq)] +pub struct VerifyMessageCommand { + /// The signature proof to verify + #[arg(long)] + pub proof: String, + + /// The message that was signed + #[arg(long)] + pub message: String, + + /// The address associated with the signature + #[arg(long)] + pub address: String, +} + +#[cfg(feature = "bip322")] +impl AppCommand>> for VerifyMessageCommand { + type Output = MessageResult; + + fn execute(&self, ctx: &mut AppContext>) -> Result { + let wallet = &ctx.state.wallet; + + let address: Address = parse_address(&self.address)?; + + let parsed_proof = MessageProof::from_base64(&self.proof) + .map_err(|e| Error::Generic(format!("Invalid proof format: {e}")))?; + + let is_valid = wallet.verify_message(&parsed_proof, &self.message, &address)?; + + Ok(MessageResult { + valid: Some(is_valid.valid), + proven_amount: is_valid.proven_amount.map(|a| a.to_sat()), + ..Default::default() + }) + } +} diff --git a/src/handlers/online.rs b/src/handlers/online.rs new file mode 100644 index 00000000..2aa2ffca --- /dev/null +++ b/src/handlers/online.rs @@ -0,0 +1,545 @@ +use clap::Parser; + +#[cfg(feature = "electrum")] +use crate::client::BlockchainClient::Electrum; +#[cfg(feature = "cbf")] +use crate::client::{BlockchainClient::KyotoClient, sync_kyoto_client}; +#[cfg(feature = "esplora")] +use {crate::client::BlockchainClient::Esplora, bdk_esplora::EsploraAsyncExt}; +#[cfg(feature = "rpc")] +use { + crate::client::BlockchainClient::RpcClient, + bdk_bitcoind_rpc::{Emitter, NO_EXPECTED_MEMPOOL_TXS, bitcoincore_rpc::RpcApi}, + bdk_wallet::chain::{BlockId, CanonicalizationParams, CheckPoint}, +}; +#[cfg(any(feature = "electrum", feature = "esplora"))] +use {bdk_wallet::KeychainKind, std::collections::HashSet, std::io::Write}; + +#[cfg(any(feature = "electrum", feature = "esplora", feature = "rpc"))] +use crate::utils::print_wallet_events; +#[cfg(any( + feature = "electrum", + feature = "esplora", + feature = "cbf", + feature = "rpc" +))] +use { + crate::commands::OnlineWalletSubCommand, + crate::error::BDKCliError as Error, + crate::handlers::{AppContext, AsyncAppCommand, OnlineOperations, payjoin::PayjoinManager}, + crate::utils::{ + is_final, + output::FormatOutput, + types::{StatusResult, TransactionResult}, + }, + bdk_wallet::bitcoin::{ + Psbt, Transaction, Txid, base64::Engine, base64::prelude::BASE64_STANDARD, + consensus::Decodable, hex::FromHex, + }, +}; +#[cfg(any( + feature = "electrum", + feature = "esplora", + feature = "cbf", + feature = "rpc" +))] +impl OnlineWalletSubCommand { + pub async fn execute(&self, ctx: &mut AppContext>) -> Result<(), Error> { + match self { + OnlineWalletSubCommand::FullScan(full_scan_command) => { + let response: StatusResult = full_scan_command.execute(ctx).await?; + response.write_out(std::io::stdout()) + } + OnlineWalletSubCommand::Sync(sync_command) => { + let response: StatusResult = sync_command.execute(ctx).await?; + response.write_out(std::io::stdout()) + } + OnlineWalletSubCommand::Broadcast(broadcast_command) => { + let response: TransactionResult = broadcast_command.execute(ctx).await?; + response.write_out(std::io::stdout()) + } + OnlineWalletSubCommand::ReceivePayjoin(receive_payjoin_command) => { + let response: StatusResult = receive_payjoin_command.execute(ctx).await?; + response.write_out(std::io::stdout()) + } + OnlineWalletSubCommand::SendPayjoin(send_payjoin_command) => { + let response: StatusResult = send_payjoin_command.execute(ctx).await?; + response.write_out(std::io::stdout()) + } + OnlineWalletSubCommand::ResumePayjoin(resume_payjoin_command) => { + let response: StatusResult = resume_payjoin_command.execute(ctx).await?; + response.write_out(std::io::stdout()) + } + OnlineWalletSubCommand::PayjoinHistory(payjoin_history_command) => { + let response: StatusResult = payjoin_history_command.execute(ctx).await?; + response.write_out(std::io::stdout()) + } + } + } +} + +#[derive(Parser, Debug, PartialEq, Clone, Eq)] +pub struct FullScanCommand { + /// Stop searching addresses for transactions after finding an unused gap of this length. + #[arg(env = "STOP_GAP", long = "scan-stop-gap", default_value = "20")] + stop_gap: usize, + // #[clap(long, default_value = "5")] + pub parallel_request: usize, +} + +#[cfg(any( + feature = "electrum", + feature = "esplora", + feature = "cbf", + feature = "rpc" +))] +impl AsyncAppCommand>> for FullScanCommand { + type Output = StatusResult; + + async fn execute( + &self, + ctx: &mut AppContext>, + ) -> Result { + let wallet = &mut ctx.state.wallet; + let client = ctx.state.client; + + #[cfg(any(feature = "electrum", feature = "esplora"))] + let request = wallet.start_full_scan().inspect({ + let mut stderr = std::io::stderr(); + let mut once = HashSet::::new(); + move |k, spk_i, _| { + if once.insert(k) { + eprint!("\nScanning keychain [{k:?}]"); + } + eprint!(" {spk_i:<3}"); + stderr.flush().expect("must flush"); + } + }); + + match client { + #[cfg(feature = "electrum")] + Electrum { client, batch_size } => { + client.populate_tx_cache(wallet.tx_graph().full_txs().map(|tx_node| tx_node.tx)); + let update = client.full_scan(request, self.stop_gap, *batch_size, false)?; + let events = wallet.apply_update_events(update)?; + print_wallet_events(&events); + } + + #[cfg(feature = "esplora")] + Esplora { + client, + parallel_requests, + } => { + let update = client + .full_scan(request, self.stop_gap, *parallel_requests) + .await + .map_err(|e| *e)?; + let events = wallet.apply_update_events(update)?; + print_wallet_events(&events); + } + #[cfg(feature = "rpc")] + RpcClient { client } => { + let blockchain_info = client.get_blockchain_info()?; + + let genesis_block = bdk_wallet::bitcoin::constants::genesis_block(wallet.network()); + let genesis_cp = CheckPoint::new(BlockId { + height: 0, + hash: genesis_block.block_hash(), + }); + let mut emitter = Emitter::new( + client.as_ref(), + genesis_cp.clone(), + genesis_cp.height(), + NO_EXPECTED_MEMPOOL_TXS, + ); + + let block_events = wallet.events_helper(|w| { + while let Some(block_event) = emitter.next_block()? { + if block_event.block_height() % 10_000 == 0 { + let percent_done = f64::from(block_event.block_height()) + / f64::from(blockchain_info.headers as u32) + * 100f64; + println!( + "Applying block at height: {}, {:.2}% done.", + block_event.block_height(), + percent_done + ); + } + + w.apply_block_connected_to( + &block_event.block, + block_event.block_height(), + block_event.connected_to(), + )?; + } + Ok::<_, Error>(()) + })?; + print_wallet_events(&block_events); + + let mempool_txs = emitter.mempool()?; + let mempool_events = wallet.apply_unconfirmed_txs_events(mempool_txs.update); + print_wallet_events(&mempool_events); + + let evicted_events = wallet.apply_evicted_txs_events(mempool_txs.evicted); + print_wallet_events(&evicted_events); + } + + #[cfg(feature = "cbf")] + KyotoClient { client } => { + sync_kyoto_client(wallet, client).await?; + } + } + Ok(StatusResult { + message: "Full scan completed successfully.".to_string(), + }) + } +} + +#[derive(Parser, Debug, PartialEq, Eq, Clone)] +pub struct SyncCommand; + +#[cfg(any( + feature = "electrum", + feature = "esplora", + feature = "cbf", + feature = "rpc" +))] +impl AsyncAppCommand>> for SyncCommand { + type Output = StatusResult; + + async fn execute( + &self, + ctx: &mut AppContext>, + ) -> Result { + let wallet = &mut ctx.state.wallet; + let client = ctx.state.client; + #[cfg(any(feature = "electrum", feature = "esplora"))] + let request = wallet + .start_sync_with_revealed_spks() + .inspect(|item, progress| { + let pc = (100 * progress.consumed()) as f32 / progress.total() as f32; + eprintln!("[ SCANNING {pc:03.0}% ] {item}"); + }); + + match client { + #[cfg(feature = "electrum")] + Electrum { client, batch_size } => { + // Populate the electrum client's transaction cache so it doesn't re-download transaction we + // already have. + client.populate_tx_cache(wallet.tx_graph().full_txs().map(|tx_node| tx_node.tx)); + + let update = client.sync(request, *batch_size, false)?; + let events = wallet.apply_update_events(update)?; + print_wallet_events(&events); + } + #[cfg(feature = "esplora")] + Esplora { + client, + parallel_requests, + } => { + let update = client + .sync(request, *parallel_requests) + .await + .map_err(|e| *e)?; + let events = wallet.apply_update_events(update)?; + print_wallet_events(&events); + } + #[cfg(feature = "rpc")] + RpcClient { client } => { + let blockchain_info = client.get_blockchain_info()?; + let wallet_cp = wallet.latest_checkpoint(); + + let emitter_height = wallet_cp.height().saturating_sub(200); + let mut emitter = Emitter::new( + client.as_ref(), + wallet_cp, + emitter_height, + wallet + .tx_graph() + .list_canonical_txs( + wallet.local_chain(), + wallet.local_chain().tip().block_id(), + CanonicalizationParams::default(), + ) + .filter(|tx| tx.chain_position.is_unconfirmed()), + ); + + let block_events = wallet.events_helper(|w| { + while let Some(block_event) = emitter.next_block()? { + if block_event.block_height() % 10_000 == 0 { + let percent_done = f64::from(block_event.block_height()) + / f64::from(blockchain_info.headers as u32) + * 100f64; + println!( + "Applying block at height: {}, {:.2}% done.", + block_event.block_height(), + percent_done + ); + } + + w.apply_block_connected_to( + &block_event.block, + block_event.block_height(), + block_event.connected_to(), + )?; + } + Ok::<_, Error>(()) + })?; + print_wallet_events(&block_events); + + let mempool_txs = emitter.mempool()?; + let mempool_events = wallet.apply_unconfirmed_txs_events(mempool_txs.update); + print_wallet_events(&mempool_events); + + let evicted_events = wallet.apply_evicted_txs_events(mempool_txs.evicted); + print_wallet_events(&evicted_events); + } + #[cfg(feature = "cbf")] + KyotoClient { client } => sync_kyoto_client(wallet, client) + .await + .map_err(|e| Error::Generic(e.to_string()))?, + } + Ok(StatusResult { + message: "Wallet synced successfully.".to_string(), + }) + } +} + +#[derive(Parser, Debug, Clone, PartialEq, Eq)] +pub struct BroadcastCommand { + /// Sets the PSBT to sign. + #[arg( + env = "BASE64_PSBT", + long = "psbt", + required_unless_present = "tx", + conflicts_with = "tx" + )] + psbt: Option, + /// Sets the raw transaction to broadcast. + #[arg( + env = "RAWTX", + long = "tx", + required_unless_present = "psbt", + conflicts_with = "psbt" + )] + tx: Option, +} + +#[cfg(any( + feature = "electrum", + feature = "esplora", + feature = "cbf", + feature = "rpc" +))] +impl AsyncAppCommand>> for BroadcastCommand { + type Output = TransactionResult; + + async fn execute( + &self, + ctx: &mut AppContext>, + ) -> Result { + let client = ctx.state.client; + + let tx = match (&self.psbt, &self.tx) { + (Some(psbt), None) => { + let psbt = BASE64_STANDARD + .decode(psbt) + .map_err(|e| Error::Generic(e.to_string()))?; + let psbt: Psbt = Psbt::deserialize(&psbt)?; + is_final(&psbt)?; + psbt.extract_tx()? + } + (None, Some(tx)) => { + let tx_bytes = Vec::::from_hex(tx)?; + Transaction::consensus_decode(&mut tx_bytes.as_slice())? + } + (Some(_), Some(_)) => { + return Err(Error::Generic( + "Both `psbt` and `tx` options are not allowed".into(), + )); + } + (None, None) => { + return Err(Error::Generic( + "Must provide either a `psbt` or `tx` to broadcast".into(), + )); + } + }; + + let txid: Txid = client.broadcast(tx).await?; + + Ok(TransactionResult { + txid: txid.to_string(), + }) + } +} + +#[derive(Parser, Debug, Clone, PartialEq, Eq)] +pub struct ReceivePayjoinCommand { + /// Amount to be received in sats. + #[arg(env = "PAYJOIN_AMOUNT", long = "amount", required = true)] + amount: u64, + /// Payjoin directory which will be used to store the PSBTs which are pending action + /// from one of the parties. + #[arg(env = "PAYJOIN_DIRECTORY", long = "directory", required = true)] + directory: String, + /// URL of the Payjoin OHTTP relay. Can be repeated multiple times to attempt the + /// operation with multiple relays for redundancy. + #[arg(env = "PAYJOIN_OHTTP_RELAY", long = "ohttp_relay", required = true)] + ohttp_relay: Vec, + /// Maximum effective fee rate the receiver is willing to pay for their own input/output contributions. + #[arg(env = "PAYJOIN_RECEIVER_MAX_FEE_RATE", long = "max_fee_rate")] + max_fee_rate: Option, +} + +#[cfg(any( + feature = "electrum", + feature = "esplora", + feature = "cbf", + feature = "rpc" +))] +impl AsyncAppCommand>> for ReceivePayjoinCommand { + type Output = StatusResult; + + async fn execute( + &self, + ctx: &mut AppContext>, + ) -> Result { + let wallet_name = ctx.state.wallet_name.clone(); + let datadir = ctx.datadir.clone(); + let wallet = &mut ctx.state.wallet; + let client = ctx.state.client; + + let mut payjoin_manager = PayjoinManager::new(wallet, Some(datadir), &wallet_name)?; + let result = payjoin_manager + .receive_payjoin( + self.amount, + self.directory.clone(), + self.max_fee_rate, + self.ohttp_relay.clone(), + client, + ) + .await?; + + Ok(StatusResult { message: result }) + } +} + +#[derive(Parser, Debug, Clone, PartialEq, Eq)] +pub struct SendPayjoinCommand { + /// BIP 21 URI for the Payjoin. + #[arg(env = "PAYJOIN_URI", long = "uri", required = true)] + uri: String, + /// URL of the Payjoin OHTTP relay. Can be repeated multiple times to attempt the + /// operation with multiple relays for redundancy. + #[arg(env = "PAYJOIN_OHTTP_RELAY", long = "ohttp_relay", required = true)] + ohttp_relay: Vec, + /// Fee rate to use in sat/vbyte. + #[arg( + env = "PAYJOIN_SENDER_FEE_RATE", + short = 'f', + long = "fee_rate", + required = true + )] + fee_rate: u64, +} +#[cfg(any( + feature = "electrum", + feature = "esplora", + feature = "cbf", + feature = "rpc" +))] +impl AsyncAppCommand>> for SendPayjoinCommand { + type Output = StatusResult; + + async fn execute( + &self, + ctx: &mut AppContext>, + ) -> Result { + let wallet_name = ctx.state.wallet_name.clone(); + let datadir = ctx.datadir.clone(); + let wallet = &mut ctx.state.wallet; + let client = ctx.state.client; + + let mut payjoin_manager = PayjoinManager::new(wallet, Some(datadir), &wallet_name)?; + let result = payjoin_manager + .send_payjoin( + self.uri.clone(), + self.fee_rate, + self.ohttp_relay.clone(), + client, + ) + .await?; + + Ok(StatusResult { message: result }) + } +} + +#[derive(Parser, Debug, Clone, PartialEq, Eq)] +pub struct ResumePayjoinCommand { + /// Payjoin directory for the session + #[arg(env = "PAYJOIN_DIRECTORY", long = "directory", required = true)] + directory: String, + /// URL of the Payjoin OHTTP relay. Can be repeated multiple times. + #[arg(env = "PAYJOIN_OHTTP_RELAY", long = "ohttp_relay", required = true)] + ohttp_relay: Vec, + /// Resume only a specific active session ID (sender and/or receiver). + #[arg(env = "PAYJOIN_SESSION_ID", long = "session_id")] + session_id: Option, +} + +#[cfg(any( + feature = "electrum", + feature = "esplora", + feature = "cbf", + feature = "rpc" +))] +impl AsyncAppCommand>> for ResumePayjoinCommand { + type Output = StatusResult; + + async fn execute( + &self, + ctx: &mut AppContext>, + ) -> Result { + let wallet_name = ctx.state.wallet_name.clone(); + let datadir = ctx.datadir.clone(); + let wallet = &mut ctx.state.wallet; + let client = ctx.state.client; + + let mut payjoin_manager = PayjoinManager::new(wallet, Some(datadir), &wallet_name)?; + let result = payjoin_manager + .resume_payjoins( + self.directory.clone(), + self.ohttp_relay.clone(), + self.session_id, + client, + ) + .await?; + + Ok(StatusResult { message: result }) + } +} + +#[derive(Parser, Debug, Clone, PartialEq, Eq)] +pub struct PayjoinHistoryCommand; + +#[cfg(any( + feature = "electrum", + feature = "esplora", + feature = "cbf", + feature = "rpc" +))] +impl AsyncAppCommand>> for PayjoinHistoryCommand { + type Output = StatusResult; + + async fn execute( + &self, + ctx: &mut AppContext>, + ) -> Result { + let wallet_name = ctx.state.wallet_name.clone(); + let datadir = ctx.datadir.clone(); + + let result = PayjoinManager::history(Some(datadir), &wallet_name)?; + + Ok(StatusResult { message: result }) + } +} diff --git a/src/payjoin/db.rs b/src/handlers/payjoin/db.rs similarity index 99% rename from src/payjoin/db.rs rename to src/handlers/payjoin/db.rs index f21524d3..dd64e4d9 100644 --- a/src/payjoin/db.rs +++ b/src/handlers/payjoin/db.rs @@ -814,8 +814,9 @@ mod tests { .get_inactive_recv_session_ids() .expect("inactive receiver ids should load"); - let table = crate::payjoin::PayjoinManager::history(Some(datadir.clone()), wallet_name) - .expect("history should render"); + let table = + crate::handlers::payjoin::PayjoinManager::history(Some(datadir.clone()), wallet_name) + .expect("history should render"); assert!(table.contains("Sender")); assert!(table.contains("Receiver")); diff --git a/src/payjoin/mod.rs b/src/handlers/payjoin/mod.rs similarity index 98% rename from src/payjoin/mod.rs rename to src/handlers/payjoin/mod.rs index 1658c756..7a2fe36a 100644 --- a/src/payjoin/mod.rs +++ b/src/handlers/payjoin/mod.rs @@ -1,6 +1,5 @@ +use crate::client::BlockchainClient; use crate::error::BDKCliError as Error; -use crate::handlers::{broadcast_transaction, sync_wallet}; -use crate::utils::BlockchainClient; use bdk_wallet::{ SignOptions, Wallet, bitcoin::{FeeRate, Psbt, Txid, consensus::encode::serialize_hex}, @@ -25,8 +24,8 @@ use payjoin::{HpkePublicKey, ImplementationError, UriExt}; use serde_json::{json, to_string_pretty}; use std::{path::PathBuf, sync::Arc}; -use crate::payjoin::db::{ReceiverPersister, SenderPersister, open_payjoin_db}; -use crate::payjoin::ohttp::RelayManager; +use crate::handlers::payjoin::db::{ReceiverPersister, SenderPersister, open_payjoin_db}; +use crate::handlers::payjoin::ohttp::RelayManager; pub mod db; pub mod ohttp; @@ -39,7 +38,7 @@ pub mod ohttp; pub(crate) struct PayjoinManager<'a> { wallet: &'a mut Wallet, relay_manager: RelayManager, - db: Arc, + db: Arc, } trait StatusText { @@ -137,7 +136,7 @@ impl<'a> PayjoinManager<'a> { self.relay_manager.configure(ohttp_relays)?; let ohttp_keys = self.relay_manager.fetch_ohttp_keys(&directory).await?; - let persister = crate::payjoin::db::ReceiverPersister::new(self.db.clone())?; + let persister = crate::handlers::payjoin::db::ReceiverPersister::new(self.db.clone())?; let checked_max_fee_rate = max_fee_rate .map(FeeRate::from_sat_per_kwu) @@ -621,8 +620,8 @@ impl<'a> PayjoinManager<'a> { /// sender. /// /// This function syncs the wallet at regular intervals and checks for the Payjoin transaction - /// in a loop until it is detected or a timeout is reached. Since [`sync_wallet`] now accepts - /// a reference to the BlockchainClient, we can call it multiple times in a loop. + /// in a loop until it is detected or a timeout is reached. Since [`BlockchainClient::sync_wallet`] + /// accepts a reference to the BlockchainClient, we can call it multiple times in a loop. async fn monitor_payjoin_proposal( &mut self, receiver: Receiver, @@ -642,7 +641,7 @@ impl<'a> PayjoinManager<'a> { let mut sync_timer = tokio::time::interval(sync_interval); poll_timer.tick().await; sync_timer.tick().await; - sync_wallet(blockchain_client, self.wallet).await?; + blockchain_client.sync_wallet(self.wallet).await?; loop { tokio::select! { @@ -673,7 +672,7 @@ impl<'a> PayjoinManager<'a> { } _ = sync_timer.tick() => { // Time to sync wallet - sync_wallet(blockchain_client, self.wallet).await?; + blockchain_client.sync_wallet(self.wallet).await?; } } } @@ -803,7 +802,9 @@ impl<'a> PayjoinManager<'a> { )); } - broadcast_transaction(blockchain_client, psbt.extract_tx_fee_rate_limit()?).await + blockchain_client + .broadcast(psbt.extract_tx_fee_rate_limit()?) + .await } async fn send_payjoin_post_request( diff --git a/src/payjoin/ohttp.rs b/src/handlers/payjoin/ohttp.rs similarity index 100% rename from src/payjoin/ohttp.rs rename to src/handlers/payjoin/ohttp.rs diff --git a/src/handlers/repl.rs b/src/handlers/repl.rs new file mode 100644 index 00000000..c2a5ae85 --- /dev/null +++ b/src/handlers/repl.rs @@ -0,0 +1,140 @@ +#[cfg(feature = "repl")] +use { + crate::commands::ReplSubCommand, + crate::handlers::{AppCommand, AppContext}, + crate::utils::output::FormatOutput, + bdk_wallet::{Wallet, bitcoin::Network}, + clap::Parser, +}; + +#[cfg(all( + feature = "repl", + any( + feature = "electrum", + feature = "esplora", + feature = "rpc", + feature = "cbf" + ) +))] +use crate::client::BlockchainClient; +#[cfg(feature = "repl")] +use {crate::commands::WalletSubCommand, crate::error::BDKCliError as Error, std::io::Write}; + +#[cfg(feature = "repl")] +pub(crate) async fn respond( + network: Network, + wallet: &mut Wallet, + #[cfg(any( + feature = "electrum", + feature = "esplora", + feature = "rpc", + feature = "cbf" + ))] + client: Option<&BlockchainClient>, + line: &str, + datadir: std::path::PathBuf, + #[cfg(any( + feature = "electrum", + feature = "esplora", + feature = "rpc", + feature = "cbf" + ))] + wallet_name: &str, +) -> Result { + let args = shlex::split(line).ok_or("error: Invalid quoting".to_string())?; + + let mut repl_args = vec!["repl".to_string()]; + repl_args.extend(args); + + let repl_subcommand = match ReplSubCommand::try_parse_from(&repl_args) { + Ok(cmd) => cmd, + Err(e) => { + writeln!(std::io::stdout(), "{}", e).map_err(|e| e.to_string())?; + return Ok(false); + } + }; + + let response = match repl_subcommand { + ReplSubCommand::Wallet { subcommand } => match subcommand { + WalletSubCommand::OfflineWalletSubCommand(cmd) => { + let mut ctx = AppContext::new_offline_wallet(network, datadir, wallet); + cmd.execute(&mut ctx) + .map_err(|e| e.to_string())? + .write_out(std::io::stdout()) + .map_err(|e| e.to_string())?; + Some(()) + } + #[cfg(any( + feature = "electrum", + feature = "esplora", + feature = "cbf", + feature = "rpc" + ))] + WalletSubCommand::OnlineWalletSubCommand(cmd) => { + let client_ref = client.ok_or("Online commands require a client.".to_string())?; + let mut ctx = AppContext::new_online_wallet( + network, + datadir, + wallet, + client_ref, + wallet_name.to_string(), + ); + + cmd.execute(&mut ctx) + .await + .map_err(|e| e.to_string())? + .write_out(std::io::stdout()) + .map_err(|e| e.to_string())?; + Some(()) + } + WalletSubCommand::Config(config_cmd) => { + let mut ctx = AppContext::new(network, datadir); + config_cmd + .execute(&mut ctx) + .map_err(|e| e.to_string())? + .write_out(std::io::stdout()) + .map_err(|e| e.to_string())?; + Some(()) + } + }, + + ReplSubCommand::Descriptor(cmd) => { + let mut ctx = AppContext::new(network, datadir); + cmd.execute(&mut ctx) + .map_err(|e| e.to_string())? + .write_out(std::io::stdout()) + .map_err(|e| e.to_string())?; + Some(()) + } + + ReplSubCommand::Key { subcommand } => { + let mut ctx = AppContext::new(network, datadir); + subcommand.execute(&mut ctx).map_err(|e| e.to_string())?; + Some(()) + } + + ReplSubCommand::Exit => None, + }; + + if response.is_some() { + std::io::stdout().flush().map_err(|e| e.to_string())?; + Ok(false) + } else { + writeln!(std::io::stdout(), "Exiting...").map_err(|e| e.to_string())?; + std::io::stdout().flush().map_err(|e| e.to_string())?; + Ok(true) + } +} + +#[cfg(feature = "repl")] +pub(crate) fn readline() -> Result { + write!(std::io::stdout(), "> ").map_err(|e| Error::Generic(e.to_string()))?; + std::io::stdout() + .flush() + .map_err(|e| Error::Generic(e.to_string()))?; + let mut buffer = String::new(); + std::io::stdin() + .read_line(&mut buffer) + .map_err(|e| Error::Generic(e.to_string()))?; + Ok(buffer) +} diff --git a/src/main.rs b/src/main.rs index 6b7745c1..a79492e7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,29 +10,25 @@ #![doc(html_logo_url = "https://github.com/bitcoindevkit/bdk/raw/master/static/bdk.png")] #![warn(missing_docs)] +mod client; mod commands; mod config; mod error; mod handlers; -#[cfg(any( - feature = "electrum", - feature = "esplora", - feature = "cbf", - feature = "rpc" -))] -mod payjoin; -#[cfg(any(feature = "sqlite", feature = "redb"))] mod persister; mod utils; -#[cfg(feature = "dns_payment")] -mod dns_payment_instructions; - use bdk_wallet::bitcoin::Network; -use log::{debug, error, warn}; +use log::{debug, warn}; -use crate::commands::CliOpts; -use crate::handlers::*; +use crate::commands::{CliOpts, CliSubCommand, WalletSubCommand}; +use crate::error::BDKCliError as Error; +#[cfg(feature = "dns_payment")] +use crate::handlers::AsyncAppCommand; +use crate::handlers::{AppCommand, AppContext}; +use crate::utils::output::FormatOutput; +use crate::utils::runtime::WalletRuntime; +use crate::utils::{command_requires_db, prepare_home_dir}; use clap::Parser; #[tokio::main] @@ -48,11 +44,175 @@ async fn main() { ) } - match handle_command(cli_opts).await { - Ok(result) => println!("{result}"), - Err(e) => { - error!("{e}"); - std::process::exit(1); + if let Err(e) = run(cli_opts).await { + eprintln!("Error: {}", e); + std::process::exit(1); + } +} + +async fn run(cli_opts: CliOpts) -> Result<(), Error> { + let datadir = cli_opts.datadir.clone(); + let home_dir = prepare_home_dir(datadir)?; + + match cli_opts.subcommand.clone() { + CliSubCommand::Wallet { + wallet: wallet_name, + subcommand, + } => match subcommand { + #[cfg(any( + feature = "electrum", + feature = "esplora", + feature = "rpc", + feature = "cbf" + ))] + WalletSubCommand::OnlineWalletSubCommand(cmd) => { + let runtime = WalletRuntime::load(&home_dir, &wallet_name)?; + let mut wallet = runtime.build_wallet(true)?; + let client = runtime.build_client(&wallet)?; + { + let mut ctx = AppContext::new_online_wallet( + runtime.network, + runtime.home_dir.clone(), + &mut wallet, + &client, + runtime.wallet_name.clone(), + ); + + cmd.execute(&mut ctx).await?; + } + wallet.persist()?; + } + + WalletSubCommand::OfflineWalletSubCommand(cmd) => { + let runtime = WalletRuntime::load(&home_dir, &wallet_name)?; + let mut wallet = runtime.build_wallet(command_requires_db(&cmd))?; + + { + let mut ctx = AppContext::new_offline_wallet( + runtime.network, + runtime.home_dir.clone(), + &mut wallet, + ); + + match cmd { + #[cfg(feature = "dns_payment")] + commands::OfflineWalletSubCommand::CreateDnsTx(dns_cmd) => { + dns_cmd + .execute(&mut ctx) + .await? + .write_out(std::io::stdout())?; + } + other => other.execute(&mut ctx)?, + } + } + wallet.persist()?; + } + + WalletSubCommand::Config(mut config_cmd) => { + config_cmd.wallet_opts.wallet = Some(wallet_name); + + let mut ctx = AppContext::new(cli_opts.network, home_dir); + + config_cmd.execute(&mut ctx)?.write_out(std::io::stdout())?; + } + }, + + CliSubCommand::Key { subcommand } => { + let mut ctx = AppContext::new(cli_opts.network, home_dir); + + subcommand.execute(&mut ctx)?; + } + + CliSubCommand::Descriptor(cmd) => { + let mut ctx = AppContext::new(cli_opts.network, home_dir); + + cmd.execute(&mut ctx)?.write_out(std::io::stdout())?; + } + + CliSubCommand::Wallets(cmd) => { + let mut ctx = AppContext::new(cli_opts.network, home_dir); + + cmd.execute(&mut ctx)?.write_out(std::io::stdout())?; + } + + #[cfg(feature = "repl")] + CliSubCommand::Repl { + wallet: wallet_name, + } => { + let runtime = WalletRuntime::load(&home_dir, &wallet_name)?; + + let mut wallet = runtime.build_wallet(true)?; + + #[cfg(any( + feature = "electrum", + feature = "esplora", + feature = "rpc", + feature = "cbf" + ))] + let client = runtime.build_client(&wallet).ok(); + + println!( + "Entering REPL mode for wallet '{}'. \ + Type 'exit' to quit.", + wallet_name + ); + + loop { + let line = crate::handlers::repl::readline()?; + + if line.trim().is_empty() { + continue; + } + + let should_exit = crate::handlers::repl::respond( + runtime.network, + &mut wallet, + #[cfg(any( + feature = "electrum", + feature = "esplora", + feature = "rpc", + feature = "cbf" + ))] + client.as_ref(), + &line, + runtime.home_dir.clone(), + #[cfg(any( + feature = "electrum", + feature = "esplora", + feature = "rpc", + feature = "cbf" + ))] + &wallet_name, + ) + .await + .map_err(Error::Generic)?; + + if should_exit { + break; + } + } + } + + #[cfg(feature = "compiler")] + CliSubCommand::Compile(cmd) => { + let mut ctx = AppContext::new(cli_opts.network, home_dir); + + cmd.execute(&mut ctx)?.write_out(std::io::stdout())?; + } + CliSubCommand::Completions { shell: _ } => unimplemented!(), + + #[cfg(feature = "silent-payments")] + CliSubCommand::SilentPaymentCode(cmd) => { + let mut ctx = AppContext::new(cli_opts.network, home_dir); + + cmd.execute(&mut ctx)?.write_out(std::io::stdout())?; + } + #[cfg(feature = "dns_payment")] + CliSubCommand::ResolveDnsRecipient(cmd) => { + let mut ctx = AppContext::new(cli_opts.network, home_dir); + cmd.execute(&mut ctx).await?.write_out(std::io::stdout())?; } } + + Ok(()) } diff --git a/src/persister.rs b/src/persister.rs index 1f9a742c..564000fe 100644 --- a/src/persister.rs +++ b/src/persister.rs @@ -1,7 +1,23 @@ -use crate::error::BDKCliError; -use bdk_wallet::WalletPersister; +use crate::commands::WalletOpts; +use crate::error::BDKCliError as Error; +use bdk_wallet::Wallet; +use bdk_wallet::bitcoin::Network; +#[cfg(any(feature = "sqlite", feature = "redb"))] +use bdk_wallet::{KeychainKind, PersistedWallet, WalletPersister}; +use clap::ValueEnum; + +#[derive(Clone, ValueEnum, Debug, Eq, PartialEq)] +pub enum DatabaseType { + /// Sqlite database + #[cfg(feature = "sqlite")] + Sqlite, + /// Redb database + #[cfg(feature = "redb")] + Redb, +} // Types of Persistence backends supported by bdk-cli +#[cfg(any(feature = "sqlite", feature = "redb"))] pub(crate) enum Persister { #[cfg(feature = "sqlite")] Connection(bdk_wallet::rusqlite::Connection), @@ -9,19 +25,18 @@ pub(crate) enum Persister { RedbStore(bdk_redb::Store), } +#[cfg(any(feature = "sqlite", feature = "redb"))] impl WalletPersister for Persister { - type Error = BDKCliError; + type Error = Error; fn initialize(persister: &mut Self) -> Result { match persister { #[cfg(feature = "sqlite")] Persister::Connection(connection) => { - WalletPersister::initialize(connection).map_err(BDKCliError::from) + WalletPersister::initialize(connection).map_err(Error::from) } #[cfg(feature = "redb")] - Persister::RedbStore(store) => { - WalletPersister::initialize(store).map_err(BDKCliError::from) - } + Persister::RedbStore(store) => WalletPersister::initialize(store).map_err(Error::from), } } @@ -29,12 +44,76 @@ impl WalletPersister for Persister { match persister { #[cfg(feature = "sqlite")] Persister::Connection(connection) => { - WalletPersister::persist(connection, changeset).map_err(BDKCliError::from) + WalletPersister::persist(connection, changeset).map_err(Error::from) } #[cfg(feature = "redb")] Persister::RedbStore(store) => { - WalletPersister::persist(store, changeset).map_err(BDKCliError::from) + WalletPersister::persist(store, changeset).map_err(Error::from) } } } } + +#[cfg(any(feature = "sqlite", feature = "redb"))] +pub(crate) fn new_persisted_wallet( + network: Network, + persister: &mut P, + wallet_opts: &WalletOpts, +) -> Result, Error> +where + P::Error: std::fmt::Display, +{ + let ext_descriptor = wallet_opts.ext_descriptor.clone(); + let int_descriptor = wallet_opts.int_descriptor.clone(); + + let mut wallet_load_params = Wallet::load(); + wallet_load_params = + wallet_load_params.descriptor(KeychainKind::External, Some(ext_descriptor.clone())); + + if int_descriptor.is_some() { + wallet_load_params = + wallet_load_params.descriptor(KeychainKind::Internal, int_descriptor.clone()); + } + wallet_load_params = wallet_load_params.extract_keys(); + + let wallet_opt = wallet_load_params + .check_network(network) + .load_wallet(persister) + .map_err(|e| Error::Generic(e.to_string()))?; + + let wallet = match wallet_opt { + Some(wallet) => wallet, + None => match int_descriptor { + Some(int_descriptor) => Wallet::create(ext_descriptor, int_descriptor) + .network(network) + .create_wallet(persister) + .map_err(|e| Error::Generic(e.to_string()))?, + None => Wallet::create_single(ext_descriptor) + .network(network) + .create_wallet(persister) + .map_err(|e| Error::Generic(e.to_string()))?, + }, + }; + + Ok(wallet) +} + +pub(crate) fn new_wallet(network: Network, wallet_opts: &WalletOpts) -> Result { + let ext_descriptor = wallet_opts.ext_descriptor.clone(); + let int_descriptor = wallet_opts.int_descriptor.clone(); + + match int_descriptor { + Some(int_descriptor) => { + let wallet = Wallet::create(ext_descriptor, int_descriptor) + .network(network) + .create_wallet_no_persist()?; + Ok(wallet) + } + None => { + let wallet = Wallet::create_single(ext_descriptor) + .network(network) + .create_wallet_no_persist()?; + Ok(wallet) + } + } +} diff --git a/src/utils.rs b/src/utils.rs deleted file mode 100644 index 5c71dfb9..00000000 --- a/src/utils.rs +++ /dev/null @@ -1,776 +0,0 @@ -// Copyright (c) 2020-2025 Bitcoin Dev Kit Developers -// -// This file is licensed under the Apache License, Version 2.0 or the MIT license -// , at your option. -// You may not use this file except in accordance with one or both of these -// licenses. - -//! Utility Tools -//! -//! This module includes all the utility tools used by the App. -use crate::config::WalletConfig; -use crate::error::BDKCliError as Error; -use std::{ - fmt::Display, - path::{Path, PathBuf}, - str::FromStr, - sync::Arc, -}; - -use crate::commands::WalletOpts; -#[cfg(feature = "cbf")] -use bdk_kyoto::{ - BuilderExt, Info, LightClient, Receiver, ScanType::Sync, UnboundedReceiver, Warning, - builder::Builder, -}; -#[cfg(feature = "silent-payments")] -use bdk_sp::encoding::SilentPaymentCode; -use bdk_wallet::{ - KeychainKind, - bitcoin::bip32::{DerivationPath, Xpub}, - keys::DescriptorPublicKey, - miniscript::{ - Descriptor, Miniscript, Terminal, - descriptor::{DescriptorXKey, Wildcard}, - }, - template::DescriptorTemplate, -}; -use cli_table::{Cell, CellStruct, Style, Table}; - -#[cfg(any( - feature = "electrum", - feature = "esplora", - feature = "rpc", - feature = "cbf" -))] -use {crate::commands::ClientType, bdk_wallet::event::WalletEvent}; - -use bdk_wallet::Wallet; -#[cfg(any(feature = "sqlite", feature = "redb"))] -use bdk_wallet::{PersistedWallet, WalletPersister}; - -use bdk_wallet::bip39::{Language, Mnemonic}; -use bdk_wallet::bitcoin::{ - Address, Network, OutPoint, ScriptBuf, bip32::Xpriv, secp256k1::Secp256k1, -}; -use bdk_wallet::descriptor::Segwitv0; -use bdk_wallet::keys::{GeneratableKey, GeneratedKey, bip39::WordCount}; -use serde_json::{Value, json}; - -#[cfg(feature = "bip322")] -use bdk_bip322::SignatureFormat; - -/// Parse the recipient (Address,Amount) argument from cli input. -pub(crate) fn parse_recipient(s: &str) -> Result<(ScriptBuf, u64), String> { - let parts: Vec<_> = s.split(':').collect(); - if parts.len() != 2 { - return Err("Invalid format".to_string()); - } - let addr = Address::from_str(parts[0]) - .map_err(|e| e.to_string())? - .assume_checked(); - let val = u64::from_str(parts[1]).map_err(|e| e.to_string())?; - - Ok((addr.script_pubkey(), val)) -} - -#[cfg(feature = "silent-payments")] -pub(crate) fn parse_sp_code_value_pairs(s: &str) -> Result<(SilentPaymentCode, u64), Error> { - let parts: Vec<&str> = s.split(':').collect(); - if parts.len() != 2 { - return Err(Error::Generic(format!( - "Invalid format '{}'. Expected 'key:value'", - s - ))); - } - - let value_0 = parts[0].trim(); - let key = SilentPaymentCode::try_from(value_0)?; - - let value = parts[1] - .trim() - .parse::() - .map_err(|_| Error::Generic(format!("Invalid number '{}' for key '{}'", parts[1], key)))?; - - Ok((key, value)) -} - -#[cfg(feature = "dns_payment")] -/// Parse dns recipients in the form "test@me.com:10000" from cli input -pub(crate) fn parse_dns_recipient(s: &str) -> Result<(String, u64), String> { - let parts: Vec<_> = s.split(':').collect(); - if parts.len() != 2 { - return Err("Invalid format".to_string()); - } - let sending_amount = u64::from_str(parts[1]).map_err(|e| e.to_string())?; - Ok((parts[0].to_string(), sending_amount)) -} - -#[cfg(any(feature = "electrum", feature = "esplora", feature = "rpc"))] -/// Parse the proxy (Socket:Port) argument from the cli input. -pub(crate) fn parse_proxy_auth(s: &str) -> Result<(String, String), Error> { - let parts: Vec<_> = s.split(':').collect(); - if parts.len() != 2 { - return Err(Error::Generic("Invalid format".to_string())); - } - - let user = parts[0].to_string(); - let passwd = parts[1].to_string(); - - Ok((user, passwd)) -} - -/// Parse a outpoint (Txid:Vout) argument from cli input. -pub(crate) fn parse_outpoint(s: &str) -> Result { - Ok(OutPoint::from_str(s)?) -} - -/// Parse an address string into `Address`. -pub(crate) fn parse_address(address_str: &str) -> Result { - let unchecked_address = Address::from_str(address_str)?; - Ok(unchecked_address.assume_checked()) -} - -/// Function to parse the signature format from a string -#[cfg(feature = "bip322")] -pub(crate) fn parse_signature_format(format_str: &str) -> Result { - match format_str.to_lowercase().as_str() { - "legacy" => Ok(SignatureFormat::Legacy), - "simple" => Ok(SignatureFormat::Simple), - "full" => Ok(SignatureFormat::Full), - "fullproofoffunds" => Ok(SignatureFormat::FullProofOfFunds), - _ => Err(Error::Generic( - "Invalid signature format. Use 'legacy', 'simple', 'full', or 'fullproofoffunds'" - .to_string(), - )), - } -} - -/// Prepare bdk-cli home directory -/// -/// This function is called to check if [`crate::CliOpts`] datadir is set. -/// If not the default home directory is created at `~/.bdk-bitcoin`. -#[allow(dead_code)] -pub(crate) fn prepare_home_dir(home_path: Option) -> Result { - let dir = home_path.unwrap_or_else(|| { - let mut dir = PathBuf::new(); - dir.push( - dirs::home_dir() - .ok_or_else(|| Error::Generic("home dir not found".to_string())) - .unwrap(), - ); - dir.push(".bdk-bitcoin"); - dir - }); - - if !dir.exists() { - std::fs::create_dir(&dir).map_err(|e| Error::Generic(e.to_string()))?; - } - - Ok(dir) -} - -/// Prepare wallet database directory. -#[allow(dead_code)] -pub(crate) fn prepare_wallet_db_dir( - home_path: &Path, - wallet_name: &str, -) -> Result { - let mut dir = home_path.to_owned(); - dir.push(wallet_name); - - if !dir.exists() { - std::fs::create_dir(&dir).map_err(|e| Error::Generic(e.to_string()))?; - } - - Ok(dir) -} -#[cfg(any( - feature = "electrum", - feature = "esplora", - feature = "rpc", - feature = "cbf", -))] -pub(crate) enum BlockchainClient { - #[cfg(feature = "electrum")] - Electrum { - client: Box>, - batch_size: usize, - }, - #[cfg(feature = "esplora")] - Esplora { - client: Box, - parallel_requests: usize, - }, - #[cfg(feature = "rpc")] - RpcClient { - client: Box, - }, - - #[cfg(feature = "cbf")] - KyotoClient { client: Box }, -} - -/// Handle for the Kyoto client after the node has been started. -/// Contains only the components needed for sync and broadcast operations. -#[cfg(feature = "cbf")] -pub struct KyotoClientHandle { - pub requester: bdk_kyoto::Requester, - pub update_subscriber: tokio::sync::Mutex, -} - -#[cfg(any( - feature = "electrum", - feature = "esplora", - feature = "rpc", - feature = "cbf", -))] -/// Create a new blockchain from the wallet configuration options. -pub(crate) fn new_blockchain_client( - wallet_opts: &WalletOpts, - _wallet: &Wallet, - _datadir: PathBuf, -) -> Result { - #[cfg(any(feature = "electrum", feature = "esplora", feature = "rpc"))] - let url = &wallet_opts.url; - let client = match wallet_opts.client_type { - #[cfg(feature = "electrum")] - ClientType::Electrum => { - let client = bdk_electrum::electrum_client::Client::new(url) - .map(bdk_electrum::BdkElectrumClient::new)?; - BlockchainClient::Electrum { - client: Box::new(client), - batch_size: wallet_opts.batch_size, - } - } - #[cfg(feature = "esplora")] - ClientType::Esplora => { - let client = bdk_esplora::esplora_client::Builder::new(url).build_async()?; - BlockchainClient::Esplora { - client: Box::new(client), - parallel_requests: wallet_opts.parallel_requests, - } - } - - #[cfg(feature = "rpc")] - ClientType::Rpc => { - let auth = match &wallet_opts.cookie { - Some(cookie) => bdk_bitcoind_rpc::bitcoincore_rpc::Auth::CookieFile(cookie.into()), - None => bdk_bitcoind_rpc::bitcoincore_rpc::Auth::UserPass( - wallet_opts.basic_auth.0.clone(), - wallet_opts.basic_auth.1.clone(), - ), - }; - let client = bdk_bitcoind_rpc::bitcoincore_rpc::Client::new(url, auth) - .map_err(|e| Error::Generic(e.to_string()))?; - BlockchainClient::RpcClient { - client: Box::new(client), - } - } - - #[cfg(feature = "cbf")] - ClientType::Cbf => { - let scan_type = Sync; - let builder = Builder::new(_wallet.network()); - - let light_client = builder - .required_peers(wallet_opts.compactfilter_opts.conn_count) - .data_dir(&_datadir) - .build_with_wallet(_wallet, scan_type)?; - - let LightClient { - requester, - info_subscriber, - warning_subscriber, - update_subscriber, - node, - } = light_client; - - let subscriber = tracing_subscriber::FmtSubscriber::new(); - let _ = tracing::subscriber::set_global_default(subscriber); - - tokio::task::spawn(async move { node.run().await }); - tokio::task::spawn( - async move { trace_logger(info_subscriber, warning_subscriber).await }, - ); - - BlockchainClient::KyotoClient { - client: Box::new(KyotoClientHandle { - requester, - update_subscriber: tokio::sync::Mutex::new(update_subscriber), - }), - } - } - }; - Ok(client) -} - -#[cfg(any(feature = "sqlite", feature = "redb"))] -/// Create a new persisted wallet from given wallet configuration options. -pub(crate) fn new_persisted_wallet( - network: Network, - persister: &mut P, - wallet_opts: &WalletOpts, -) -> Result, Error> -where - P::Error: std::fmt::Display, -{ - let ext_descriptor = wallet_opts.ext_descriptor.clone(); - let int_descriptor = wallet_opts.int_descriptor.clone(); - - let mut wallet_load_params = Wallet::load(); - wallet_load_params = - wallet_load_params.descriptor(KeychainKind::External, Some(ext_descriptor.clone())); - - if int_descriptor.is_some() { - wallet_load_params = - wallet_load_params.descriptor(KeychainKind::Internal, int_descriptor.clone()); - } - wallet_load_params = wallet_load_params.extract_keys(); - - let wallet_opt = wallet_load_params - .check_network(network) - .load_wallet(persister) - .map_err(|e| Error::Generic(e.to_string()))?; - - let wallet = match wallet_opt { - Some(wallet) => wallet, - None => match int_descriptor { - Some(int_descriptor) => Wallet::create(ext_descriptor, int_descriptor) - .network(network) - .create_wallet(persister) - .map_err(|e| Error::Generic(e.to_string()))?, - None => Wallet::create_single(ext_descriptor) - .network(network) - .create_wallet(persister) - .map_err(|e| Error::Generic(e.to_string()))?, - }, - }; - - Ok(wallet) -} - -#[cfg(not(any(feature = "sqlite", feature = "redb")))] -/// Create a new non-persisted wallet from given wallet configuration options. -pub(crate) fn new_wallet(network: Network, wallet_opts: &WalletOpts) -> Result { - let ext_descriptor = wallet_opts.ext_descriptor.clone(); - let int_descriptor = wallet_opts.int_descriptor.clone(); - - match int_descriptor { - Some(int_descriptor) => { - let wallet = Wallet::create(ext_descriptor, int_descriptor) - .network(network) - .create_wallet_no_persist()?; - Ok(wallet) - } - None => { - let wallet = Wallet::create_single(ext_descriptor) - .network(network) - .create_wallet_no_persist()?; - Ok(wallet) - } - } -} - -#[cfg(feature = "cbf")] -pub async fn trace_logger( - mut info_subcriber: Receiver, - mut warning_subscriber: UnboundedReceiver, -) { - loop { - tokio::select! { - info = info_subcriber.recv() => { - if let Some(info) = info { - tracing::info!("{info}") - } - } - warn = warning_subscriber.recv() => { - if let Some(warn) = warn { - tracing::warn!("{warn}") - } - } - } - } -} - -// Handle Kyoto Client sync -#[cfg(feature = "cbf")] -pub async fn sync_kyoto_client( - wallet: &mut Wallet, - handle: &KyotoClientHandle, -) -> Result<(), Error> { - if !handle.requester.is_running() { - tracing::error!("Kyoto node is not running"); - return Err(Error::Generic("Kyoto node failed to start".to_string())); - } - tracing::info!("Kyoto node is running"); - - let update = handle.update_subscriber.lock().await.update().await?; - tracing::info!("Received update: applying to wallet"); - let events = wallet - .apply_update_events(update) - .map_err(|e| Error::Generic(format!("Failed to apply update: {e}")))?; - - print_wallet_events(&events); - - tracing::info!( - "Chain tip: {}, Transactions: {}, Balance: {}", - wallet.local_chain().tip().height(), - wallet.transactions().count(), - wallet.balance().total().to_sat() - ); - - tracing::info!( - "Sync completed: tx_count={}, balance={}", - wallet.transactions().count(), - wallet.balance().total().to_sat() - ); - - Ok(()) -} - -pub(crate) fn shorten(displayable: impl Display, start: u8, end: u8) -> String { - let displayable = displayable.to_string(); - - if displayable.len() <= (start + end) as usize { - return displayable; - } - - let start_str: &str = &displayable[0..start as usize]; - let end_str: &str = &displayable[displayable.len() - end as usize..]; - format!("{start_str}...{end_str}") -} - -pub fn is_mnemonic(s: &str) -> bool { - let word_count = s.split_whitespace().count(); - (12..=24).contains(&word_count) && s.chars().all(|c| c.is_alphanumeric() || c.is_whitespace()) -} - -pub fn generate_descriptors(desc_type: &str, key: &str, network: Network) -> Result { - let is_private = key.starts_with("xprv") || key.starts_with("tprv"); - - if is_private { - generate_private_descriptors(desc_type, key, network) - } else { - let purpose = match desc_type.to_lowercase().as_str() { - "pkh" => 44u32, - "sh" => 49u32, - "wpkh" | "wsh" => 84u32, - "tr" => 86u32, - _ => 84u32, - }; - let coin_type = match network { - Network::Bitcoin => 0u32, - _ => 1u32, - }; - let derivation_path = DerivationPath::from_str(&format!("m/{purpose}h/{coin_type}h/0h"))?; - generate_public_descriptors(desc_type, key, &derivation_path) - } -} - -/// Generate descriptors from private key using BIP templates -fn generate_private_descriptors( - desc_type: &str, - key: &str, - network: Network, -) -> Result { - use bdk_wallet::template::{Bip44, Bip49, Bip84, Bip86}; - - let secp = Secp256k1::new(); - let xprv: Xpriv = key.parse()?; - let fingerprint = xprv.fingerprint(&secp); - - let (external_desc, external_keymap, _) = match desc_type.to_lowercase().as_str() { - "pkh" => Bip44(xprv, KeychainKind::External).build(network)?, - "sh" => Bip49(xprv, KeychainKind::External).build(network)?, - "wpkh" | "wsh" => Bip84(xprv, KeychainKind::External).build(network)?, - "tr" => Bip86(xprv, KeychainKind::External).build(network)?, - _ => { - return Err(Error::Generic(format!( - "Unsupported descriptor type: {desc_type}" - ))); - } - }; - - let (internal_desc, internal_keymap, _) = match desc_type.to_lowercase().as_str() { - "pkh" => Bip44(xprv, KeychainKind::Internal).build(network)?, - "sh" => Bip49(xprv, KeychainKind::Internal).build(network)?, - "wpkh" | "wsh" => Bip84(xprv, KeychainKind::Internal).build(network)?, - "tr" => Bip86(xprv, KeychainKind::Internal).build(network)?, - _ => { - return Err(Error::Generic(format!( - "Unsupported descriptor type: {desc_type}" - ))); - } - }; - - let external_priv = external_desc.to_string_with_secret(&external_keymap); - let external_pub = external_desc.to_string(); - let internal_priv = internal_desc.to_string_with_secret(&internal_keymap); - let internal_pub = internal_desc.to_string(); - - Ok(json!({ - "public_descriptors": { - "external": external_pub, - "internal": internal_pub - }, - "private_descriptors": { - "external": external_priv, - "internal": internal_priv - }, - "fingerprint": fingerprint.to_string() - })) -} - -/// Generate descriptors from public key (xpub/tpub) -pub fn generate_public_descriptors( - desc_type: &str, - key: &str, - derivation_path: &DerivationPath, -) -> Result { - let xpub: Xpub = key.parse()?; - let fingerprint = xpub.fingerprint(); - - let build_descriptor = |branch: &str| -> Result { - let branch_path = DerivationPath::from_str(branch)?; - let desc_xpub = DescriptorXKey { - origin: Some((fingerprint, derivation_path.clone())), - xkey: xpub, - derivation_path: branch_path, - wildcard: Wildcard::Unhardened, - }; - let desc_pub = DescriptorPublicKey::XPub(desc_xpub); - let descriptor = build_public_descriptor(desc_type, desc_pub)?; - Ok(descriptor.to_string()) - }; - - let external_pub = build_descriptor("0")?; - let internal_pub = build_descriptor("1")?; - - Ok(json!({ - "public_descriptors": { - "external": external_pub, - "internal": internal_pub - }, - "fingerprint": fingerprint.to_string() - })) -} - -/// Build a descriptor from a public key -pub fn build_public_descriptor( - desc_type: &str, - key: DescriptorPublicKey, -) -> Result, Error> { - match desc_type.to_lowercase().as_str() { - "pkh" => Descriptor::new_pkh(key).map_err(Error::from), - "wpkh" => Descriptor::new_wpkh(key).map_err(Error::from), - "sh" => Descriptor::new_sh_wpkh(key).map_err(Error::from), - "wsh" => { - let pk_k = Miniscript::from_ast(Terminal::PkK(key)).map_err(Error::from)?; - let pk_ms: Miniscript = - Miniscript::from_ast(Terminal::Check(Arc::new(pk_k))).map_err(Error::from)?; - Descriptor::new_wsh(pk_ms).map_err(Error::from) - } - "tr" => Descriptor::new_tr(key, None).map_err(Error::from), - _ => Err(Error::Generic(format!( - "Unsupported descriptor type: {desc_type}" - ))), - } -} - -/// Generate new mnemonic and descriptors -pub fn generate_descriptor_with_mnemonic( - network: Network, - desc_type: &str, -) -> Result { - let mnemonic: GeneratedKey = - Mnemonic::generate((WordCount::Words12, Language::English)).map_err(Error::BIP39Error)?; - - let seed = mnemonic.to_seed(""); - let xprv = Xpriv::new_master(network, &seed)?; - - let mut result = generate_descriptors(desc_type, &xprv.to_string(), network)?; - result["mnemonic"] = json!(mnemonic.to_string()); - Ok(result) -} - -/// Generate descriptors from existing mnemonic -pub fn generate_descriptor_from_mnemonic( - mnemonic_str: &str, - network: Network, - desc_type: &str, -) -> Result { - let mnemonic = Mnemonic::parse_in(Language::English, mnemonic_str)?; - let seed = mnemonic.to_seed(""); - let xprv = Xpriv::new_master(network, &seed)?; - - let mut result = generate_descriptors(desc_type, &xprv.to_string(), network)?; - result["mnemonic"] = json!(mnemonic_str); - Ok(result) -} - -pub fn format_descriptor_output(result: &Value, pretty: bool) -> Result { - if !pretty { - return Ok(serde_json::to_string_pretty(result)?); - } - - let mut rows: Vec> = vec![]; - - if let Some(desc_type) = result.get("type") { - rows.push(vec![ - "Type".cell().bold(true), - desc_type.as_str().unwrap_or("N/A").cell(), - ]); - } - - if let Some(finger_print) = result.get("fingerprint") { - rows.push(vec![ - "Fingerprint".cell().bold(true), - finger_print.as_str().unwrap_or("N/A").cell(), - ]); - } - - if let Some(network) = result.get("network") { - rows.push(vec![ - "Network".cell().bold(true), - network.as_str().unwrap_or("N/A").cell(), - ]); - } - if let Some(multipath_desc) = result.get("multipath_descriptor") { - rows.push(vec![ - "Multipart Descriptor".cell().bold(true), - multipath_desc.as_str().unwrap_or("N/A").cell(), - ]); - } - if let Some(pub_descs) = result.get("public_descriptors").and_then(|v| v.as_object()) { - if let Some(ext) = pub_descs.get("external") { - rows.push(vec![ - "External Public".cell().bold(true), - ext.as_str().unwrap_or("N/A").cell(), - ]); - } - if let Some(int) = pub_descs.get("internal") { - rows.push(vec![ - "Internal Public".cell().bold(true), - int.as_str().unwrap_or("N/A").cell(), - ]); - } - } - if let Some(priv_descs) = result - .get("private_descriptors") - .and_then(|v| v.as_object()) - { - if let Some(ext) = priv_descs.get("external") { - rows.push(vec![ - "External Private".cell().bold(true), - ext.as_str().unwrap_or("N/A").cell(), - ]); - } - if let Some(int) = priv_descs.get("internal") { - rows.push(vec![ - "Internal Private".cell().bold(true), - int.as_str().unwrap_or("N/A").cell(), - ]); - } - } - if let Some(mnemonic) = result.get("mnemonic") { - rows.push(vec![ - "Mnemonic".cell().bold(true), - mnemonic.as_str().unwrap_or("N/A").cell(), - ]); - } - - let table = rows - .table() - .display() - .map_err(|e| Error::Generic(e.to_string()))?; - - Ok(format!("{table}")) -} - -pub fn load_wallet_config( - home_dir: &Path, - wallet_name: &str, -) -> Result<(WalletOpts, Network), Error> { - let config = WalletConfig::load(home_dir)?.ok_or(Error::Generic(format!( - "No config found for wallet {wallet_name}", - )))?; - - let wallet_opts = config.get_wallet_opts(wallet_name)?; - let wallet_config = config - .wallets - .get(wallet_name) - .ok_or(Error::Generic(format!( - "Wallet '{wallet_name}' not found in config" - )))?; - - let network = match wallet_config.network.as_str() { - "bitcoin" => Ok(Network::Bitcoin), - "testnet" => Ok(Network::Testnet), - "regtest" => Ok(Network::Regtest), - "signet" => Ok(Network::Signet), - "testnet4" => Ok(Network::Testnet4), - _ => Err(Error::Generic("Invalid network in config".to_string())), - }?; - - Ok((wallet_opts, network)) -} - -#[cfg(any( - feature = "electrum", - feature = "esplora", - feature = "cbf", - feature = "rpc" -))] -pub fn print_wallet_events(events: &[WalletEvent]) { - for event in events { - match event { - WalletEvent::ChainTipChanged { old_tip, new_tip } => { - eprintln!( - "Chain tip advanced from height {} to {}", - old_tip.height, new_tip.height - ); - } - WalletEvent::TxConfirmed { - txid, - block_time, - old_block_time, - .. - } => match old_block_time { - Some(old) => eprintln!( - "Transaction {txid} re-confirmed at height {} (was height {}, likely a reorg)", - block_time.block_id.height, old.block_id.height - ), - None => eprintln!( - "Transaction {txid} confirmed at height {}", - block_time.block_id.height - ), - }, - WalletEvent::TxUnconfirmed { - txid, - old_block_time, - .. - } => match old_block_time { - Some(old) => eprintln!( - "Transaction {txid} became unconfirmed (was confirmed at height {}, likely a reorg)", - old.block_id.height - ), - None => eprintln!("Transaction {txid} seen in mempool"), - }, - WalletEvent::TxReplaced { - txid, conflicts, .. - } => { - let ids: Vec = conflicts.iter().map(|(_, c)| c.to_string()).collect(); - eprintln!( - "Transaction {txid} was replaced (conflicts with: {})", - ids.join(", ") - ); - } - WalletEvent::TxDropped { txid, .. } => { - eprintln!("Transaction {txid} dropped from the mempool"); - } - _ => {} - } - } -} diff --git a/src/utils/common.rs b/src/utils/common.rs new file mode 100644 index 00000000..d93ff023 --- /dev/null +++ b/src/utils/common.rs @@ -0,0 +1,321 @@ +use crate::{commands::WalletOpts, config::WalletConfig, error::BDKCliError as Error}; +#[cfg(feature = "bip322")] +use bdk_bip322::SignatureFormat; +#[cfg(feature = "cbf")] +use bdk_kyoto::{Info, Receiver, UnboundedReceiver, Warning}; +#[cfg(feature = "silent-payments")] +use bdk_sp::encoding::SilentPaymentCode; +use bdk_wallet::bitcoin::{Address, Network, OutPoint, ScriptBuf}; +#[cfg(any( + feature = "electrum", + feature = "esplora", + feature = "cbf", + feature = "rpc" +))] +use bdk_wallet::{bitcoin::Psbt, event::WalletEvent}; + +use crate::commands::OfflineWalletSubCommand; +use std::{ + path::{Path, PathBuf}, + str::FromStr, +}; + +/// Determine if PSBT has final script sigs or witnesses for all unsigned tx inputs. +#[cfg(any( + feature = "electrum", + feature = "esplora", + feature = "cbf", + feature = "rpc" +))] +pub(crate) fn is_final(psbt: &Psbt) -> Result<(), Error> { + let unsigned_tx_inputs = psbt.unsigned_tx.input.len(); + let psbt_inputs = psbt.inputs.len(); + if unsigned_tx_inputs != psbt_inputs { + return Err(Error::Generic(format!( + "Malformed PSBT, {unsigned_tx_inputs} unsigned tx inputs and {psbt_inputs} psbt inputs." + ))); + } + let sig_count = psbt.inputs.iter().fold(0, |count, input| { + if input.final_script_sig.is_some() || input.final_script_witness.is_some() { + count + 1 + } else { + count + } + }); + if unsigned_tx_inputs > sig_count { + return Err(Error::Generic( + "The PSBT is not finalized, inputs are are not fully signed.".to_string(), + )); + } + Ok(()) +} + +/// Parse the recipient (Address,Amount) argument from cli input. +pub(crate) fn parse_recipient(s: &str) -> Result<(ScriptBuf, u64), String> { + let parts: Vec<_> = s.split(':').collect(); + if parts.len() != 2 { + return Err("Invalid format".to_string()); + } + let addr = Address::from_str(parts[0]) + .map_err(|e| e.to_string())? + .assume_checked(); + let val = u64::from_str(parts[1]).map_err(|e| e.to_string())?; + + Ok((addr.script_pubkey(), val)) +} + +#[cfg(any(feature = "electrum", feature = "esplora", feature = "rpc"))] +/// Parse the proxy (Socket:Port) argument from the cli input. +pub(crate) fn parse_proxy_auth(s: &str) -> Result<(String, String), Error> { + let parts: Vec<_> = s.split(':').collect(); + if parts.len() != 2 { + return Err(Error::Generic("Invalid format".to_string())); + } + + let user = parts[0].to_string(); + let passwd = parts[1].to_string(); + + Ok((user, passwd)) +} + +/// Parse a outpoint (Txid:Vout) argument from cli input. +pub(crate) fn parse_outpoint(s: &str) -> Result { + Ok(OutPoint::from_str(s)?) +} + +/// Parse an address string into `Address`. +pub(crate) fn parse_address(address_str: &str) -> Result { + let unchecked_address = Address::from_str(address_str)?; + Ok(unchecked_address.assume_checked()) +} + +/// Prepare bdk-cli home directory +/// +/// This function is called to check if [`crate::CliOpts`] datadir is set. +/// If not the default home directory is created at `~/.bdk-bitcoin`. +#[allow(dead_code)] +pub(crate) fn prepare_home_dir(home_path: Option) -> Result { + let dir = home_path.unwrap_or_else(|| { + let mut dir = PathBuf::new(); + dir.push( + dirs::home_dir() + .ok_or_else(|| Error::Generic("home dir not found".to_string())) + .unwrap(), + ); + dir.push(".bdk-bitcoin"); + dir + }); + + if !dir.exists() { + std::fs::create_dir(&dir).map_err(|e| Error::Generic(e.to_string()))?; + } + + Ok(dir) +} + +/// Prepare wallet database directory. +#[allow(dead_code)] +pub(crate) fn prepare_wallet_db_dir( + home_path: &Path, + wallet_name: &str, +) -> Result { + let mut dir = home_path.to_owned(); + dir.push(wallet_name); + + if !dir.exists() { + std::fs::create_dir(&dir).map_err(|e| Error::Generic(e.to_string()))?; + } + + Ok(dir) +} + +pub fn is_mnemonic(s: &str) -> bool { + let word_count = s.split_whitespace().count(); + (12..=24).contains(&word_count) && s.chars().all(|c| c.is_alphanumeric() || c.is_whitespace()) +} + +#[cfg(feature = "cbf")] +pub async fn trace_logger( + mut info_subcriber: Receiver, + mut warning_subscriber: UnboundedReceiver, +) { + loop { + tokio::select! { + info = info_subcriber.recv() => { + if let Some(info) = info { + tracing::info!("{info}") + } + } + warn = warning_subscriber.recv() => { + if let Some(warn) = warn { + tracing::warn!("{warn}") + } + } + } + } +} + +pub fn load_wallet_config( + home_dir: &Path, + wallet_name: &str, +) -> Result<(WalletOpts, Network), Error> { + let config = WalletConfig::load(home_dir)?.ok_or(Error::Generic(format!( + "No config found for wallet {wallet_name}", + )))?; + + let wallet_opts = config.get_wallet_opts(wallet_name)?; + let wallet_config = config + .wallets + .get(wallet_name) + .ok_or(Error::Generic(format!( + "Wallet '{wallet_name}' not found in config" + )))?; + + let network = match wallet_config.network.as_str() { + "bitcoin" => Ok(Network::Bitcoin), + "testnet" => Ok(Network::Testnet), + "regtest" => Ok(Network::Regtest), + "signet" => Ok(Network::Signet), + "testnet4" => Ok(Network::Testnet4), + _ => Err(Error::Generic("Invalid network in config".to_string())), + }?; + + Ok((wallet_opts, network)) +} + +#[cfg(feature = "silent-payments")] +pub(crate) fn parse_sp_code_value_pairs(s: &str) -> Result<(SilentPaymentCode, u64), Error> { + let parts: Vec<&str> = s.split(':').collect(); + if parts.len() != 2 { + return Err(Error::Generic(format!( + "Invalid format '{}'. Expected 'key:value'", + s + ))); + } + + let value_0 = parts[0].trim(); + let key = SilentPaymentCode::try_from(value_0)?; + + let value = parts[1] + .trim() + .parse::() + .map_err(|_| Error::Generic(format!("Invalid number '{}' for key '{}'", parts[1], key)))?; + + Ok((key, value)) +} + +/// Function to parse the signature format from a string +#[cfg(feature = "bip322")] +pub(crate) fn parse_signature_format(format_str: &str) -> Result { + match format_str.to_lowercase().as_str() { + "legacy" => Ok(SignatureFormat::Legacy), + "simple" => Ok(SignatureFormat::Simple), + "full" => Ok(SignatureFormat::Full), + "fullproofoffunds" => Ok(SignatureFormat::FullProofOfFunds), + _ => Err(Error::Generic( + "Invalid signature format. Use 'legacy', 'simple', 'full', or 'fullproofoffunds'" + .to_string(), + )), + } +} + +pub fn command_requires_db(command: &OfflineWalletSubCommand) -> bool { + match command { + OfflineWalletSubCommand::Balance(_) + | OfflineWalletSubCommand::Unspent(_) + | OfflineWalletSubCommand::Transactions(_) + | OfflineWalletSubCommand::BumpFee(_) + | OfflineWalletSubCommand::NewAddress(_) + | OfflineWalletSubCommand::UnusedAddress(_) + | OfflineWalletSubCommand::CreateTx(_) => true, + + OfflineWalletSubCommand::Policies(_) + | OfflineWalletSubCommand::PublicDescriptor(_) + | OfflineWalletSubCommand::Sign(_) + | OfflineWalletSubCommand::ExtractPsbt(_) + | OfflineWalletSubCommand::FinalizePsbt(_) + | OfflineWalletSubCommand::CombinePsbt(_) => false, + + #[cfg(feature = "bip322")] + OfflineWalletSubCommand::SignMessage(_) => true, + + #[cfg(feature = "bip322")] + OfflineWalletSubCommand::VerifyMessage(_) => true, + #[cfg(feature = "silent-payments")] + OfflineWalletSubCommand::CreateSpTx(_) => true, + #[cfg(feature = "dns_payment")] + OfflineWalletSubCommand::CreateDnsTx(_) => true, + } +} + +/// Print a human-readable summary of the wallet events produced by a sync or scan. +/// +/// Emitted to stderr so it does not pollute the JSON result on stdout. +#[cfg(any( + feature = "electrum", + feature = "esplora", + feature = "rpc", + feature = "cbf" +))] +pub fn print_wallet_events(events: &[WalletEvent]) { + for event in events { + match event { + WalletEvent::ChainTipChanged { old_tip, new_tip } => { + eprintln!( + "Chain tip advanced from height {} to {}", + old_tip.height, new_tip.height + ); + } + WalletEvent::TxConfirmed { + txid, + block_time, + old_block_time, + .. + } => match old_block_time { + Some(old) => eprintln!( + "Transaction {txid} re-confirmed at height {} (was height {}, likely a reorg)", + block_time.block_id.height, old.block_id.height + ), + None => eprintln!( + "Transaction {txid} confirmed at height {}", + block_time.block_id.height + ), + }, + WalletEvent::TxUnconfirmed { + txid, + old_block_time, + .. + } => match old_block_time { + Some(old) => eprintln!( + "Transaction {txid} became unconfirmed (was confirmed at height {}, likely a reorg)", + old.block_id.height + ), + None => eprintln!("Transaction {txid} seen in mempool"), + }, + WalletEvent::TxReplaced { + txid, conflicts, .. + } => { + let ids: Vec = conflicts.iter().map(|(_, c)| c.to_string()).collect(); + eprintln!( + "Transaction {txid} was replaced (conflicts with: {})", + ids.join(", ") + ); + } + WalletEvent::TxDropped { txid, .. } => { + eprintln!("Transaction {txid} dropped from the mempool"); + } + _ => {} + } + } +} + +#[cfg(feature = "dns_payment")] +/// Parse dns recipients in the form "test@me.com:10000" from cli input +pub(crate) fn parse_dns_recipient(s: &str) -> Result<(String, u64), String> { + let parts: Vec<_> = s.split(':').collect(); + if parts.len() != 2 { + return Err("Invalid format".to_string()); + } + let sending_amount = u64::from_str(parts[1]).map_err(|e| e.to_string())?; + Ok((parts[0].to_string(), sending_amount)) +} diff --git a/src/utils/descriptors.rs b/src/utils/descriptors.rs new file mode 100644 index 00000000..44e795f1 --- /dev/null +++ b/src/utils/descriptors.rs @@ -0,0 +1,198 @@ +use bdk_wallet::keys::GeneratableKey; +use std::{str::FromStr, sync::Arc}; + +use bdk_wallet::keys::DescriptorPublicKey; +use bdk_wallet::{ + KeychainKind, + bip39::{Language, Mnemonic}, + bitcoin::{ + Network, + bip32::{DerivationPath, Xpriv, Xpub}, + secp256k1::Secp256k1, + }, + keys::{GeneratedKey, bip39::WordCount}, + miniscript::{ + Descriptor, Miniscript, Segwitv0, Terminal, + descriptor::{DescriptorXKey, Wildcard}, + }, + template::DescriptorTemplate, +}; + +use crate::error::BDKCliError as Error; +use crate::utils::types::DescriptorResult; +use crate::utils::types::KeychainPair; + +pub fn generate_descriptors( + desc_type: &str, + key: &str, + network: Network, +) -> Result { + let is_private = key.starts_with("xprv") || key.starts_with("tprv"); + + if is_private { + generate_private_descriptors(desc_type, key, network) + } else { + let purpose = match desc_type.to_lowercase().as_str() { + "pkh" => 44u32, + "sh" => 49u32, + "wpkh" | "wsh" => 84u32, + "tr" => 86u32, + _ => 84u32, + }; + let coin_type = match network { + Network::Bitcoin => 0u32, + _ => 1u32, + }; + let derivation_path = DerivationPath::from_str(&format!("m/{purpose}h/{coin_type}h/0h"))?; + generate_public_descriptors(desc_type, key, &derivation_path) + } +} + +/// Generate descriptors from private key using BIP templates +fn generate_private_descriptors( + desc_type: &str, + key: &str, + network: Network, +) -> Result { + use bdk_wallet::template::{Bip44, Bip49, Bip84, Bip86}; + + let secp = Secp256k1::new(); + let xprv: Xpriv = key.parse()?; + let fingerprint = xprv.fingerprint(&secp); + + let (external_desc, external_keymap, _) = match desc_type.to_lowercase().as_str() { + "pkh" => Bip44(xprv, KeychainKind::External).build(network)?, + "sh" => Bip49(xprv, KeychainKind::External).build(network)?, + "wpkh" | "wsh" => Bip84(xprv, KeychainKind::External).build(network)?, + "tr" => Bip86(xprv, KeychainKind::External).build(network)?, + _ => { + return Err(Error::Generic(format!( + "Unsupported descriptor type: {desc_type}" + ))); + } + }; + + let (internal_desc, internal_keymap, _) = match desc_type.to_lowercase().as_str() { + "pkh" => Bip44(xprv, KeychainKind::Internal).build(network)?, + "sh" => Bip49(xprv, KeychainKind::Internal).build(network)?, + "wpkh" | "wsh" => Bip84(xprv, KeychainKind::Internal).build(network)?, + "tr" => Bip86(xprv, KeychainKind::Internal).build(network)?, + _ => { + return Err(Error::Generic(format!( + "Unsupported descriptor type: {desc_type}" + ))); + } + }; + + let external_priv = external_desc.to_string_with_secret(&external_keymap); + let external_pub = external_desc.to_string(); + let internal_priv = internal_desc.to_string_with_secret(&internal_keymap); + let internal_pub = internal_desc.to_string(); + + Ok(DescriptorResult { + descriptor: None, + multipath_descriptor: None, + public_descriptors: Some(KeychainPair { + external: external_pub, + internal: internal_pub, + }), + private_descriptors: Some(KeychainPair { + external: external_priv, + internal: internal_priv, + }), + mnemonic: None, + fingerprint: Some(fingerprint.to_string()), + r: None, + }) +} + +/// Generate descriptors from public key (xpub/tpub) +pub fn generate_public_descriptors( + desc_type: &str, + key: &str, + derivation_path: &DerivationPath, +) -> Result { + let xpub: Xpub = key.parse()?; + let fingerprint = xpub.fingerprint(); + + let build_descriptor = |branch: &str| -> Result { + let branch_path = DerivationPath::from_str(branch)?; + let desc_xpub = DescriptorXKey { + origin: Some((fingerprint, derivation_path.clone())), + xkey: xpub, + derivation_path: branch_path, + wildcard: Wildcard::Unhardened, + }; + let desc_pub = DescriptorPublicKey::XPub(desc_xpub); + let descriptor = build_public_descriptor(desc_type, desc_pub)?; + Ok(descriptor.to_string()) + }; + + let external_pub = build_descriptor("0")?; + let internal_pub = build_descriptor("1")?; + Ok(DescriptorResult { + descriptor: None, + multipath_descriptor: None, + public_descriptors: Some(KeychainPair { + external: external_pub, + internal: internal_pub, + }), + private_descriptors: None, + mnemonic: None, + fingerprint: Some(fingerprint.to_string()), + r: None, + }) +} + +/// Build a descriptor from a public key +pub fn build_public_descriptor( + desc_type: &str, + key: DescriptorPublicKey, +) -> Result, Error> { + match desc_type.to_lowercase().as_str() { + "pkh" => Descriptor::new_pkh(key).map_err(Error::from), + "wpkh" => Descriptor::new_wpkh(key).map_err(Error::from), + "sh" => Descriptor::new_sh_wpkh(key).map_err(Error::from), + "wsh" => { + let pk_k = Miniscript::from_ast(Terminal::PkK(key)).map_err(Error::from)?; + let pk_ms: Miniscript = + Miniscript::from_ast(Terminal::Check(Arc::new(pk_k))).map_err(Error::from)?; + Descriptor::new_wsh(pk_ms).map_err(Error::from) + } + "tr" => Descriptor::new_tr(key, None).map_err(Error::from), + _ => Err(Error::Generic(format!( + "Unsupported descriptor type: {desc_type}" + ))), + } +} + +/// Generate new mnemonic and descriptors +pub fn generate_descriptor_with_mnemonic( + network: Network, + desc_type: &str, +) -> Result { + let mnemonic: GeneratedKey = + Mnemonic::generate((WordCount::Words12, Language::English)).map_err(Error::BIP39Error)?; + + let seed = mnemonic.to_seed(""); + let xprv = Xpriv::new_master(network, &seed)?; + + let mut result = generate_descriptors(desc_type, &xprv.to_string(), network)?; + result.mnemonic = Some(mnemonic.to_string()); + Ok(result) +} + +/// Generate descriptors from existing mnemonic +pub fn generate_descriptor_from_mnemonic( + mnemonic_str: &str, + network: Network, + desc_type: &str, +) -> Result { + let mnemonic = Mnemonic::parse_in(Language::English, mnemonic_str)?; + let seed = mnemonic.to_seed(""); + let xprv = Xpriv::new_master(network, &seed)?; + + let mut result = generate_descriptors(desc_type, &xprv.to_string(), network)?; + result.mnemonic = Some(mnemonic_str.to_string()); + Ok(result) +} diff --git a/src/utils/mod.rs b/src/utils/mod.rs new file mode 100644 index 00000000..084cab52 --- /dev/null +++ b/src/utils/mod.rs @@ -0,0 +1,6 @@ +pub mod common; +pub mod descriptors; +pub mod output; +pub use common::*; +pub mod runtime; +pub mod types; diff --git a/src/utils/output.rs b/src/utils/output.rs new file mode 100644 index 00000000..b2cd32a2 --- /dev/null +++ b/src/utils/output.rs @@ -0,0 +1,37 @@ +use std::io::Write; + +use crate::error::BDKCliError as Error; +use serde::Serialize; + +/// A trait for types that can be presented to the user. +pub trait FormatOutput: Serialize { + fn format(&self) -> Result { + serde_json::to_string_pretty(self) + .map_err(|e| Error::Generic(format!("JSON serialization failed: {e}"))) + } + + fn write_out(&self, mut writer: W) -> Result<(), Error> { + let output = self.format()?; + + writeln!(writer, "{}", output) + .map_err(|e| Error::Generic(format!("Failed to write output: {e}"))) + } +} + +impl FormatOutput for T {} + +/// A generic wrapper for commands that return a list of items. +#[derive(Serialize)] +pub struct ListResult { + pub count: usize, + pub items: Vec, +} + +impl ListResult { + pub fn new(items: Vec) -> Self { + Self { + count: items.len(), + items, + } + } +} diff --git a/src/utils/runtime.rs b/src/utils/runtime.rs new file mode 100644 index 00000000..29dac4d9 --- /dev/null +++ b/src/utils/runtime.rs @@ -0,0 +1,153 @@ +#[cfg(feature = "redb")] +use bdk_redb::Store as RedbStore; +use bdk_wallet::{Wallet, bitcoin::Network}; +use std::{ + ops::{Deref, DerefMut}, + path::{Path, PathBuf}, +}; + +use crate::{ + error::BDKCliError as Error, + persister::new_wallet, + utils::{load_wallet_config, prepare_wallet_db_dir}, +}; +#[cfg(any(feature = "sqlite", feature = "redb"))] +use { + crate::persister::{DatabaseType, Persister, new_persisted_wallet}, + bdk_wallet::PersistedWallet, +}; + +#[cfg(any( + feature = "electrum", + feature = "esplora", + feature = "rpc", + feature = "cbf" +))] +use crate::client::{BlockchainClient, new_blockchain_client}; + +pub enum RuntimeWallet { + Standard(Box), + #[cfg(any(feature = "sqlite", feature = "redb"))] + Persisted(Box>, Box), +} + +impl Deref for RuntimeWallet { + type Target = Wallet; + fn deref(&self) -> &Self::Target { + match self { + Self::Standard(wallet) => wallet, + #[cfg(any(feature = "sqlite", feature = "redb"))] + Self::Persisted(wallet, _) => wallet, + } + } +} + +impl DerefMut for RuntimeWallet { + fn deref_mut(&mut self) -> &mut Self::Target { + match self { + Self::Standard(wallet) => wallet, + #[cfg(any(feature = "sqlite", feature = "redb"))] + Self::Persisted(wallet, _) => wallet, + } + } +} + +impl RuntimeWallet { + pub fn persist(&mut self) -> Result<(), Error> { + match self { + Self::Standard(_) => Ok(()), + #[cfg(any(feature = "sqlite", feature = "redb"))] + Self::Persisted(wallet, persister) => { + wallet.persist(persister)?; + Ok(()) + } + } + } +} + +#[allow(unused)] +pub struct WalletRuntime { + pub wallet_name: String, + pub wallet_opts: crate::commands::WalletOpts, + pub network: Network, + pub home_dir: PathBuf, + pub database_path: PathBuf, +} + +impl WalletRuntime { + pub fn load(home_dir: &Path, wallet_name: &str) -> Result { + let (wallet_opts, network) = load_wallet_config(home_dir, wallet_name)?; + + let database_path = prepare_wallet_db_dir(home_dir, wallet_name)?; + + Ok(Self { + wallet_name: wallet_name.to_string(), + wallet_opts, + network, + home_dir: home_dir.to_path_buf(), + database_path, + }) + } + + pub fn build_wallet(&self, require_db: bool) -> Result { + if !require_db { + return Ok(RuntimeWallet::Standard(Box::new(new_wallet( + self.network, + &self.wallet_opts, + )?))); + } + + #[cfg(any(feature = "sqlite", feature = "redb"))] + { + let mut persister = self.create_persister()?; + let wallet = new_persisted_wallet(self.network, &mut persister, &self.wallet_opts)?; + Ok(RuntimeWallet::Persisted( + Box::new(wallet), + Box::new(persister), + )) + } + + #[cfg(not(any(feature = "sqlite", feature = "redb")))] + { + Ok(RuntimeWallet::Standard(Box::new(new_wallet( + self.network, + &self.wallet_opts, + )?))) + } + } + + #[cfg(any( + feature = "electrum", + feature = "esplora", + feature = "rpc", + feature = "cbf" + ))] + pub fn build_client(&self, wallet: &Wallet) -> Result { + new_blockchain_client(&self.wallet_opts, wallet, self.database_path.clone()) + } + + #[cfg(any(feature = "sqlite", feature = "redb"))] + fn create_persister(&self) -> Result { + match &self.wallet_opts.database_type { + #[cfg(feature = "sqlite")] + DatabaseType::Sqlite => { + let db_file = self.database_path.join("wallet.sqlite"); + + let connection = bdk_wallet::rusqlite::Connection::open(db_file)?; + + Ok(Persister::Connection(connection)) + } + + #[cfg(feature = "redb")] + DatabaseType::Redb => { + let db = std::sync::Arc::new(bdk_redb::redb::Database::create( + self.home_dir.join("wallet.redb"), + )?); + + let store = RedbStore::new(db, self.wallet_name.clone())?; + + Ok(Persister::RedbStore(store)) + } + } + } +} diff --git a/src/utils/types.rs b/src/utils/types.rs new file mode 100644 index 00000000..176fd155 --- /dev/null +++ b/src/utils/types.rs @@ -0,0 +1,205 @@ +use std::collections::HashMap; + +use crate::config::WalletConfigInner; +use bdk_wallet::Balance; +use bdk_wallet::bitcoin::{ + Network, Psbt, Transaction, base64::Engine, consensus::encode::serialize_hex, +}; +use bdk_wallet::{AddressInfo, LocalOutput}; +use serde::Serialize; +use serde_json::json; + +/// Represent address result +#[derive(Serialize)] +pub struct AddressResult { + pub address: String, + pub index: u32, +} + +impl From for AddressResult { + fn from(info: AddressInfo) -> Self { + Self { + address: info.address.to_string(), + index: info.index, + } + } +} + +#[allow(unused)] +/// Represents the data for a single transaction +#[derive(Serialize)] +pub struct TransactionDetails { + pub txid: String, + pub is_coinbase: bool, + pub wtxid: String, + pub version: serde_json::Value, + pub is_rbf: bool, + pub inputs: serde_json::Value, + pub outputs: serde_json::Value, + #[serde(skip)] + pub version_display: String, + #[serde(skip)] + pub input_count: usize, + #[serde(skip)] + pub output_count: usize, + #[serde(skip)] + pub total_value: u64, +} + +/// single UTXO +#[derive(Serialize)] +pub struct UnspentDetails { + pub outpoint: String, + pub txout: serde_json::Value, + pub keychain: String, + pub is_spent: bool, + pub derivation_index: u32, + pub chain_position: serde_json::Value, +} + +impl UnspentDetails { + pub fn from_local_output(utxo: &LocalOutput, _network: Network) -> Self { + let outpoint_str = utxo.outpoint.to_string(); + + Self { + outpoint: outpoint_str.clone(), + txout: serde_json::to_value(&utxo.txout).unwrap_or(json!({})), + keychain: format!("{:?}", utxo.keychain), + is_spent: utxo.is_spent, + derivation_index: utxo.derivation_index, + chain_position: serde_json::to_value(utxo.chain_position).unwrap_or(json!({})), + } + } +} + +#[derive(Serialize)] +pub struct PsbtResult { + pub psbt: String, + + #[serde(skip_serializing_if = "Option::is_none")] + pub is_finalized: Option, +} + +impl PsbtResult { + pub fn new(psbt: &Psbt, finalized: Option) -> Self { + Self { + psbt: bdk_wallet::bitcoin::base64::prelude::BASE64_STANDARD.encode(psbt.serialize()), + is_finalized: finalized, + } + } +} + +#[derive(Serialize)] +pub struct RawPsbt { + pub raw_tx: String, +} + +impl RawPsbt { + pub fn new(tx: &Transaction) -> Self { + Self { + raw_tx: serialize_hex(tx), + } + } +} + +#[derive(Serialize)] +pub struct KeychainPair { + pub external: T, + pub internal: T, +} + +#[cfg(feature = "bip322")] +#[derive(Serialize, Debug, Default)] +pub struct MessageResult { + #[serde(skip_serializing_if = "Option::is_none")] + pub proof: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub valid: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub proven_amount: Option, +} + +#[derive(Serialize, Debug)] +pub struct StatusResult { + pub message: String, +} + +#[cfg(any( + feature = "electrum", + feature = "esplora", + feature = "cbf", + feature = "rpc" +))] +#[derive(Serialize, Debug)] +pub struct TransactionResult { + pub txid: String, +} + +/// Return type definition +#[derive(Serialize)] +#[serde(transparent)] +pub struct WalletsListResult(pub HashMap); + +/// return type +#[derive(Serialize)] +pub struct DescriptorResult { + #[serde(skip_serializing_if = "Option::is_none")] + pub descriptor: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub multipath_descriptor: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub public_descriptors: Option>, + + #[serde(skip_serializing_if = "Option::is_none")] + pub private_descriptors: Option>, + + #[serde(skip_serializing_if = "Option::is_none")] + pub mnemonic: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub fingerprint: Option, + + /// Randomness factor `r` used to derive the taproot unspendable internal key (H + rG). + #[serde(skip_serializing_if = "Option::is_none")] + pub r: Option, +} + +#[derive(Serialize)] +pub struct KeyResult { + pub xprv: String, + + #[serde(skip_serializing_if = "Option::is_none")] + pub xpub: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub mnemonic: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub fingerprint: Option, +} + +/// Balance representation +#[derive(Serialize)] +pub struct BalanceResult { + pub total: u64, + pub trusted_pending: u64, + pub untrusted_pending: u64, + pub immature: u64, + pub confirmed: u64, +} + +impl From for BalanceResult { + fn from(b: Balance) -> Self { + Self { + total: b.total().to_sat(), + confirmed: b.confirmed.to_sat(), + trusted_pending: b.trusted_pending.to_sat(), + untrusted_pending: b.untrusted_pending.to_sat(), + immature: b.immature.to_sat(), + } + } +} diff --git a/tests/cli_flags.rs b/tests/cli_flags.rs index beec7983..23abc81a 100644 --- a/tests/cli_flags.rs +++ b/tests/cli_flags.rs @@ -24,23 +24,3 @@ fn test_without_pretty_flag() { let stdout = String::from_utf8_lossy(&output.stdout); assert!(serde_json::from_str::(&stdout).is_ok()); } - -#[test] -fn test_pretty_flag_before_subcommand() { - let output = Command::new("cargo") - .args("run -- --pretty key generate".split_whitespace()) - .output() - .unwrap(); - - assert!(output.status.success()); -} - -#[test] -fn test_pretty_flag_after_subcommand() { - let output = Command::new("cargo") - .args("run -- key generate --pretty".split_whitespace()) - .output() - .unwrap(); - - assert!(output.status.success()); -} diff --git a/tests/integration.rs b/tests/integration.rs index a45cf8aa..79c887f3 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -244,16 +244,4 @@ mod test { .unwrap(); assert_eq!(confirmed_balance, 1000000000u64); } - - // #[test] - // #[cfg(feature = "regtest-bitcoin")] - // fn test_basic_wallet_op_bitcoind() { - // basic_wallet_ops("regtest-bitcoin") - // } - // - // #[test] - // #[cfg(feature = "regtest-electrum")] - // fn test_basic_wallet_op_electrum() { - // basic_wallet_ops("regtest-electrum") - // } }