diff --git a/Cargo.lock b/Cargo.lock index a5826c4b..e0933eff 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "aead" version = "0.4.3" @@ -34,6 +40,17 @@ dependencies = [ "opaque-debug", ] +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher 0.4.4", + "cpufeatures 0.2.17", +] + [[package]] name = "aes-gcm" version = "0.9.2" @@ -41,7 +58,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bc3be92e19a7ef47457b8e6f90707e12b6ac5d20c6f3866584fa3be0787d839f" dependencies = [ "aead 0.4.3", - "aes", + "aes 0.7.5", "cipher 0.3.0", "ctr", "ghash", @@ -119,12 +136,33 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "anyhow" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" + [[package]] name = "arrayvec" version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" +[[package]] +name = "assert_cmd" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2aa3a22042e45de04255c7bf3626e239f450200fd0493c1e382263544b20aea6" +dependencies = [ + "anstyle", + "bstr", + "libc", + "predicates", + "predicates-core", + "predicates-tree", + "wait-timeout", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -142,6 +180,12 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + [[package]] name = "aws-lc-rs" version = "1.17.1" @@ -192,10 +236,17 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bdk-cli" version = "3.0.0" dependencies = [ + "assert_cmd", "bdk_bip322", "bdk_bitcoind_rpc", "bdk_electrum", @@ -203,6 +254,7 @@ dependencies = [ "bdk_kyoto", "bdk_redb", "bdk_sp", + "bdk_testenv", "bdk_wallet", "bitcoin-payment-instructions", "claims", @@ -213,11 +265,13 @@ dependencies = [ "env_logger", "log", "payjoin", + "predicates", "reqwest 0.13.4", "serde", "serde_json", "shlex 1.3.0", "tap", + "tempfile", "thiserror 2.0.18", "tokio", "toml 1.1.2+spec-1.1.0", @@ -278,7 +332,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "00a9846105bf6e751adbb6946b000ff919ce24a15a941cbfe68485549b552a9c" dependencies = [ "bdk_core", - "electrum-client", + "electrum-client 0.25.0", ] [[package]] @@ -324,6 +378,16 @@ dependencies = [ "bitcoin", ] +[[package]] +name = "bdk_testenv" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "543dc273dab3b9ec329772bcb15741a948cc3510deae2a30af3a116f03505fee" +dependencies = [ + "bdk_chain", + "electrsd", +] + [[package]] name = "bdk_wallet" version = "2.4.0" @@ -579,6 +643,24 @@ dependencies = [ "serde_json", ] +[[package]] +name = "bitcoind" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce6620b7c942dbe28cc49c21d95e792feb9ffd95a093205e7875ccfa69c2925" +dependencies = [ + "anyhow", + "bitcoin_hashes 0.14.101", + "bitcoincore-rpc", + "flate2", + "log", + "minreq", + "tar", + "tempfile", + "which", + "zip", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -609,6 +691,17 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bstr" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cee35f73844aa3014bb606320a6c1f010249dbdf43342fe54b5a4f6a8ed4b79" +dependencies = [ + "memchr", + "regex-automata", + "serde_core", +] + [[package]] name = "bumpalo" version = "3.20.3" @@ -627,6 +720,26 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" +[[package]] +name = "bzip2" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdb116a6ef3f6c3698828873ad02c3014b3c85cadb88496095628e3ef1e347f8" +dependencies = [ + "bzip2-sys", + "libc", +] + +[[package]] +name = "bzip2-sys" +version = "0.1.13+1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14" +dependencies = [ + "cc", + "pkg-config", +] + [[package]] name = "cc" version = "1.2.66" @@ -867,6 +980,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "constant_time_eq" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc" + [[package]] name = "core-foundation" version = "0.10.1" @@ -910,6 +1029,21 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + [[package]] name = "crunchy" version = "0.2.4" @@ -998,6 +1132,18 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "difflib" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" + [[package]] name = "digest" version = "0.9.0" @@ -1066,6 +1212,40 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "electrsd" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c3c57645202a05a47206ed81cc179cf32bf4c3ca5a9e60c06c49d0222d5844" +dependencies = [ + "bitcoin_hashes 0.14.101", + "bitcoind", + "electrum-client 0.20.0", + "log", + "minreq", + "nix", + "which", + "zip", +] + +[[package]] +name = "electrum-client" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c7b1f8783238bb18e6e137875b0a66f3dffe6c7ea84066e05d033cf180b150f" +dependencies = [ + "bitcoin", + "log", + "serde", + "serde_json", +] + [[package]] name = "electrum-client" version = "0.25.0" @@ -1156,12 +1336,41 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "float-cmp" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8" +dependencies = [ + "num-traits", +] + [[package]] name = "foreign-types" version = "0.3.2" @@ -1455,6 +1664,15 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "http" version = "1.4.2" @@ -1902,6 +2120,12 @@ dependencies = [ "bitcoin", ] +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -1941,6 +2165,15 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "memoffset" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aa361d4faea93603064a027415f07bd8e1d5c88c9fbf68bf56a285428fd79ce" +dependencies = [ + "autocfg", +] + [[package]] name = "miniscript" version = "12.3.7" @@ -1952,6 +2185,16 @@ dependencies = [ "serde", ] +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "minreq" version = "2.14.1" @@ -1994,6 +2237,26 @@ dependencies = [ "tempfile", ] +[[package]] +name = "nix" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f346ff70e7dbfd675fe90590b92d59ef2de15a8779ae305ebcbfd3f0caf59be4" +dependencies = [ + "autocfg", + "bitflags 1.3.2", + "cfg-if", + "libc", + "memoffset", + "pin-utils", +] + +[[package]] +name = "normalize-line-endings" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -2003,6 +2266,21 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -2093,6 +2371,17 @@ dependencies = [ "windows-link", ] +[[package]] +name = "password-hash" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7676374caaee8a325c9e7a2ae557f216c5563a171d6997b0ef8a65af35147700" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "payjoin" version = "0.25.0" @@ -2113,6 +2402,18 @@ dependencies = [ "web-time", ] +[[package]] +name = "pbkdf2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83a0692ec44e4cf1ef28ca317f14f8f07da2d95ec3fa01f86e4467b725e60917" +dependencies = [ + "digest 0.10.7", + "hmac 0.12.1", + "password-hash", + "sha2 0.10.9", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -2131,6 +2432,12 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + [[package]] name = "pkg-config" version = "0.3.33" @@ -2204,6 +2511,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -2213,6 +2526,36 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "predicates" +version = "3.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe" +dependencies = [ + "anstyle", + "difflib", + "float-cmp", + "normalize-line-endings", + "predicates-core", + "regex", +] + +[[package]] +name = "predicates-core" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cad38746f3166b4031b1a0d39ad9f954dd291e7854fcc0eed52ee41a0b50d144" + +[[package]] +name = "predicates-tree" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0de1b847b39c8131db0467e9df1ff60e6d0562ab8e9a16e568ad0fdb372e2f2" +dependencies = [ + "predicates-core", + "termtree", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -2533,6 +2876,19 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.13.0", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + [[package]] name = "rustix" version = "1.1.4" @@ -2542,7 +2898,7 @@ dependencies = [ "bitflags 2.13.0", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.12.1", "windows-sys 0.61.2", ] @@ -2805,6 +3161,17 @@ dependencies = [ "serde", ] +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + [[package]] name = "sha2" version = "0.9.9" @@ -2860,6 +3227,12 @@ dependencies = [ "libc", ] +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + [[package]] name = "simd_cesu8" version = "1.1.1" @@ -2953,6 +3326,17 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -2962,7 +3346,7 @@ dependencies = [ "fastrand", "getrandom 0.4.3", "once_cell", - "rustix", + "rustix 1.1.4", "windows-sys 0.61.2", ] @@ -2975,6 +3359,12 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "termtree" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" + [[package]] name = "thiserror" version = "1.0.69" @@ -3024,6 +3414,25 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "time" +version = "0.3.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + [[package]] name = "tinystr" version = "0.8.3" @@ -3349,6 +3758,15 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + [[package]] name = "walkdir" version = "2.5.0" @@ -3482,6 +3900,18 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "which" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" +dependencies = [ + "either", + "home", + "once_cell", + "rustix 0.38.44", +] + [[package]] name = "winapi" version = "0.3.9" @@ -3528,6 +3958,15 @@ dependencies = [ "windows-targets", ] +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -3619,6 +4058,16 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix 1.1.4", +] + [[package]] name = "yoke" version = "0.8.3" @@ -3736,8 +4185,57 @@ dependencies = [ "syn", ] +[[package]] +name = "zip" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "760394e246e4c28189f19d488c058bf16f564016aefac5d32bb1f3b51d5e9261" +dependencies = [ + "aes 0.8.4", + "byteorder", + "bzip2", + "constant_time_eq", + "crc32fast", + "crossbeam-utils", + "flate2", + "hmac 0.12.1", + "pbkdf2", + "sha1", + "time", + "zstd", +] + [[package]] name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zstd" +version = "0.11.2+zstd.1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20cc960326ece64f010d2d2107537f26dc589a6573a316bd5b1dba685fa5fde4" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "5.0.2+zstd.1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d2a5585e04f9eea4b2a3d1eca508c4dee9592a89ef6f450c11719da0726f4db" +dependencies = [ + "libc", + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/Cargo.toml b/Cargo.toml index 07254880..1f91dd4f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -75,3 +75,7 @@ silent-payments = ["dep:bdk_sp"] [dev-dependencies] claims = "0.8.0" +predicates = "3.0" +tempfile = "3.8" +assert_cmd = "2.2.2" +bdk_testenv = "0.13.1" diff --git a/Justfile b/Justfile index 163efcaa..df04921e 100644 --- a/Justfile +++ b/Justfile @@ -28,7 +28,7 @@ test: pre-push: cargo test --features default cargo test --no-default-features - cargo test --all-features + cargo test --all-features -- --test-threads=2 cargo clippy --no-default-features --all-targets -- -D warnings cargo clippy --all-features --all-targets -- -D warnings cargo fmt --all -- --check diff --git a/src/commands.rs b/src/commands.rs index 529ef26b..1d920ad8 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -281,6 +281,9 @@ pub struct WalletOpts { #[cfg(feature = "cbf")] #[clap(flatten)] pub compactfilter_opts: CompactFilterOpts, + #[cfg(any(feature = "electrum", feature = "esplora"))] + #[command(flatten)] + pub proxy_opts: ProxyOpts, } /// Options to configure a SOCKS5 proxy for a blockchain client connection. @@ -288,11 +291,11 @@ pub struct WalletOpts { #[derive(Debug, Args, Clone, PartialEq, Eq)] pub struct ProxyOpts { /// Sets the SOCKS5 proxy for a blockchain client. - #[arg(env = "PROXY_ADDRS:PORT", long = "proxy", short = 'p')] + #[arg(env = "PROXY_ADDRS:PORT", long = "proxy")] pub proxy: Option, /// Sets the SOCKS5 proxy credential. - #[arg(env = "PROXY_USER:PASSWD", long="proxy_auth", short='a', value_parser = parse_proxy_auth)] + #[arg(env = "PROXY_USER:PASSWD", long="proxy_auth", value_parser = parse_proxy_auth)] pub proxy_auth: Option<(String, String)>, /// Sets the SOCKS5 proxy retries for the blockchain client. diff --git a/src/config.rs b/src/config.rs index 6865ee09..60580037 100644 --- a/src/config.rs +++ b/src/config.rs @@ -50,6 +50,21 @@ pub struct WalletConfigInner { pub parallel_requests: Option, #[cfg(feature = "rpc")] pub cookie: Option, + #[cfg(any(feature = "electrum", feature = "esplora"))] + #[serde(default)] + pub proxy: Option, + #[cfg(any(feature = "electrum", feature = "esplora"))] + #[serde(default)] + pub proxy_auth: Option, + #[cfg(any(feature = "electrum", feature = "esplora"))] + #[serde(default)] + pub proxy_retries: Option, + #[cfg(any(feature = "electrum", feature = "esplora"))] + #[serde(default)] + pub proxy_timeout: Option, + #[cfg(feature = "cbf")] + #[serde(default)] + pub conn_count: Option, } impl WalletConfig { @@ -93,7 +108,7 @@ impl TryFrom<&WalletConfigInner> for WalletOpts { type Error = Error; fn try_from(config: &WalletConfigInner) -> Result { - let _network = Network::from_str(&config.network) + Network::from_str(&config.network) .map_err(|_| Error::Generic("Invalid network".to_string()))?; #[cfg(any(feature = "sqlite", feature = "redb"))] @@ -155,8 +170,21 @@ impl TryFrom<&WalletConfigInner> for WalletOpts { #[cfg(feature = "rpc")] cookie: config.cookie.clone(), + #[cfg(any(feature = "electrum", feature = "esplora"))] + proxy_opts: crate::commands::ProxyOpts { + proxy: config.proxy.clone(), + proxy_auth: match &config.proxy_auth { + Some(s) => Some(crate::utils::parse_proxy_auth(s)?), + None => None, + }, + retries: config.proxy_retries.unwrap_or(5), + timeout: config.proxy_timeout, + }, + #[cfg(feature = "cbf")] - compactfilter_opts: crate::commands::CompactFilterOpts { conn_count: 2 }, + compactfilter_opts: crate::commands::CompactFilterOpts { + conn_count: config.conn_count.unwrap_or(2), + }, }) } } @@ -218,6 +246,16 @@ mod tests { rpc_password: None, #[cfg(feature = "rpc")] cookie: None, + #[cfg(any(feature = "electrum", feature = "esplora"))] + proxy: None, + #[cfg(any(feature = "electrum", feature = "esplora"))] + proxy_auth: None, + #[cfg(any(feature = "electrum", feature = "esplora"))] + proxy_retries: None, + #[cfg(any(feature = "electrum", feature = "esplora"))] + proxy_timeout: None, + #[cfg(feature = "cbf")] + conn_count: None, }; let opts: WalletOpts = (&wallet_config) @@ -293,6 +331,16 @@ mod tests { rpc_password: None, #[cfg(feature = "rpc")] cookie: None, + #[cfg(any(feature = "electrum", feature = "esplora"))] + proxy: None, + #[cfg(any(feature = "electrum", feature = "esplora"))] + proxy_auth: None, + #[cfg(any(feature = "electrum", feature = "esplora"))] + proxy_retries: None, + #[cfg(any(feature = "electrum", feature = "esplora"))] + proxy_timeout: None, + #[cfg(feature = "cbf")] + conn_count: None, }; let result: Result = (&inner).try_into(); diff --git a/src/handlers/config.rs b/src/handlers/config.rs index f3b1772c..3409bfa3 100644 --- a/src/handlers/config.rs +++ b/src/handlers/config.rs @@ -12,7 +12,7 @@ use crate::config::{WalletConfig, WalletConfigInner}; use crate::error::BDKCliError as Error; use crate::handlers::Init; use crate::handlers::{AppCommand, AppContext}; -#[cfg(feature = "sqlite")] +#[cfg(any(feature = "sqlite", feature = "redb"))] use crate::persister::DatabaseType; use crate::utils::types::{StatusResult, WalletsListResult}; use bdk_wallet::bitcoin::Network; @@ -95,7 +95,6 @@ impl AppCommand> for SaveConfigCommand { 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")] @@ -125,6 +124,22 @@ impl AppCommand> for SaveConfigCommand { parallel_requests: Some(self.wallet_opts.parallel_requests), #[cfg(feature = "rpc")] cookie: self.wallet_opts.cookie.clone(), + + #[cfg(any(feature = "electrum", feature = "esplora"))] + proxy: self.wallet_opts.proxy_opts.proxy.clone(), + #[cfg(any(feature = "electrum", feature = "esplora"))] + proxy_auth: self + .wallet_opts + .proxy_opts + .proxy_auth + .as_ref() + .map(|(u, p)| format!("{u}:{p}")), + #[cfg(any(feature = "electrum", feature = "esplora"))] + proxy_retries: Some(self.wallet_opts.proxy_opts.retries), + #[cfg(any(feature = "electrum", feature = "esplora"))] + proxy_timeout: self.wallet_opts.proxy_opts.timeout, + #[cfg(feature = "cbf")] + conn_count: Some(self.wallet_opts.compactfilter_opts.conn_count), }; config.wallets.insert(wallet_name.clone(), wallet_config); diff --git a/src/handlers/offline.rs b/src/handlers/offline.rs index 96e23cf4..6853194e 100644 --- a/src/handlers/offline.rs +++ b/src/handlers/offline.rs @@ -16,7 +16,6 @@ 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, @@ -311,6 +310,8 @@ impl AppCommand>> for CreateTxCommand { let psbt = tx_builder.finish()?; + // let psbt_base64 = BASE64_STANDARD.encode(psbt.serialize()); + Ok(PsbtResult::new(&psbt, Some(false))) } } @@ -540,7 +541,7 @@ impl AppCommand>> for CreateSpTxCommand { pub struct BumpFeeCommand { /// TXID of the transaction to update. #[arg(env = "TXID", long = "txid")] - pub txid: String, + pub txid: Txid, /// 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)] @@ -574,9 +575,7 @@ impl AppCommand>> for BumpFeeCommand { 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 mut tx_builder = wallet.build_fee_bump(self.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); @@ -761,7 +760,7 @@ impl AppCommand>> for CombinePsbtCommand { Ok(acc) })?; - Ok(PsbtResult::new(&final_psbt, None)) + Ok(PsbtResult::new(&final_psbt, Some(false))) } } diff --git a/src/handlers/online.rs b/src/handlers/online.rs index 2aa2ffca..f2900cbc 100644 --- a/src/handlers/online.rs +++ b/src/handlers/online.rs @@ -83,7 +83,7 @@ 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")] + #[clap(long, default_value = "5")] pub parallel_request: usize, } diff --git a/src/handlers/repl.rs b/src/handlers/repl.rs index c2a5ae85..b15b653f 100644 --- a/src/handlers/repl.rs +++ b/src/handlers/repl.rs @@ -43,10 +43,7 @@ pub(crate) async fn respond( ) -> 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) { + let repl_subcommand = match ReplSubCommand::try_parse_from(&args) { Ok(cmd) => cmd, Err(e) => { writeln!(std::io::stdout(), "{}", e).map_err(|e| e.to_string())?; @@ -58,10 +55,7 @@ pub(crate) async fn respond( 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())?; + cmd.execute(&mut ctx).map_err(|e| e.to_string())?; Some(()) } #[cfg(any( @@ -80,20 +74,16 @@ pub(crate) async fn respond( 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())?; + cmd.execute(&mut ctx).await.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())?; + WalletSubCommand::Config(_) => { + writeln!( + std::io::stdout(), + "`config` is not available in REPL mode — the wallet for this session \ + is already loaded. Exit and run `bdk-cli wallet --wallet config ...`." + ) + .map_err(|e| e.to_string())?; Some(()) } }, diff --git a/src/main.rs b/src/main.rs index a79492e7..06e3ea24 100644 --- a/src/main.rs +++ b/src/main.rs @@ -29,7 +29,7 @@ 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; +use clap::{CommandFactory, Parser}; #[tokio::main] async fn main() { @@ -199,8 +199,14 @@ async fn run(cli_opts: CliOpts) -> Result<(), Error> { cmd.execute(&mut ctx)?.write_out(std::io::stdout())?; } - CliSubCommand::Completions { shell: _ } => unimplemented!(), - + CliSubCommand::Completions { shell } => { + clap_complete::generate( + shell, + &mut CliOpts::command(), + "bdk-cli", + &mut std::io::stdout(), + ); + } #[cfg(feature = "silent-payments")] CliSubCommand::SilentPaymentCode(cmd) => { let mut ctx = AppContext::new(cli_opts.network, home_dir); diff --git a/src/persister.rs b/src/persister.rs index 564000fe..e49b4aea 100644 --- a/src/persister.rs +++ b/src/persister.rs @@ -4,8 +4,10 @@ use bdk_wallet::Wallet; use bdk_wallet::bitcoin::Network; #[cfg(any(feature = "sqlite", feature = "redb"))] use bdk_wallet::{KeychainKind, PersistedWallet, WalletPersister}; +#[cfg(any(feature = "sqlite", feature = "redb"))] use clap::ValueEnum; +#[cfg(any(feature = "sqlite", feature = "redb"))] #[derive(Clone, ValueEnum, Debug, Eq, PartialEq)] pub enum DatabaseType { /// Sqlite database diff --git a/src/utils/common.rs b/src/utils/common.rs index d93ff023..e2866617 100644 --- a/src/utils/common.rs +++ b/src/utils/common.rs @@ -95,19 +95,18 @@ pub(crate) fn parse_address(address_str: &str) -> Result { /// 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 - }); + let dir = match home_path { + Some(dir) => dir, + None => { + let mut dir = + dirs::home_dir().ok_or_else(|| Error::Generic("Home dir not found".to_string()))?; + dir.push(".bdk-bitcoin"); + dir + } + }; if !dir.exists() { - std::fs::create_dir(&dir).map_err(|e| Error::Generic(e.to_string()))?; + std::fs::create_dir_all(&dir).map_err(|e| Error::Generic(e.to_string()))?; } Ok(dir) @@ -171,14 +170,8 @@ pub fn load_wallet_config( "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())), - }?; + let network = Network::from_str(&wallet_config.network) + .map_err(|_| Error::Generic("Invalid network in config".to_string()))?; Ok((wallet_opts, network)) } diff --git a/src/utils/runtime.rs b/src/utils/runtime.rs index 29dac4d9..8beb5527 100644 --- a/src/utils/runtime.rs +++ b/src/utils/runtime.rs @@ -89,31 +89,28 @@ impl WalletRuntime { }) } - 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, - )?))); - } - + pub fn build_wallet( + &self, + #[cfg_attr( + not(any(feature = "sqlite", feature = "redb")), + allow(unused_variables) + )] + require_db: bool, + ) -> Result { #[cfg(any(feature = "sqlite", feature = "redb"))] - { + if require_db { let mut persister = self.create_persister()?; let wallet = new_persisted_wallet(self.network, &mut persister, &self.wallet_opts)?; - Ok(RuntimeWallet::Persisted( + return 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, - )?))) - } + Ok(RuntimeWallet::Standard(Box::new(new_wallet( + self.network, + &self.wallet_opts, + )?))) } #[cfg(any( diff --git a/tests/cli.rs b/tests/cli.rs new file mode 100644 index 00000000..9285327c --- /dev/null +++ b/tests/cli.rs @@ -0,0 +1,20 @@ +// Copyright (c) 2020-2026 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. + +//! bdk-cli Integration Test Framework +//! +//! This modules performs the necessary integration test for bdk-cli +//! The tests can be run using `cargo test` + +mod common; + +mod integration { + mod init; + mod offline; + mod online; +} diff --git a/tests/cli_flags.rs b/tests/cli_flags.rs deleted file mode 100644 index 23abc81a..00000000 --- a/tests/cli_flags.rs +++ /dev/null @@ -1,26 +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. - -//! CLI Flags Tests -//! -//! Tests for global CLI flags and their behavior - -use std::process::Command; - -#[test] -fn test_without_pretty_flag() { - let output = Command::new("cargo") - .args("run -- key generate".split_whitespace()) - .output() - .unwrap(); - - assert!(output.status.success()); - - let stdout = String::from_utf8_lossy(&output.stdout); - assert!(serde_json::from_str::(&stdout).is_ok()); -} diff --git a/tests/common/mod.rs b/tests/common/mod.rs new file mode 100644 index 00000000..43220166 --- /dev/null +++ b/tests/common/mod.rs @@ -0,0 +1,86 @@ +// Copyright (c) 2020-2026 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. + +//! bdk-cli Integration Test Framework +//! +//! This module provides the necessary helper methods for tests +//! +use assert_cmd::Command; +use std::path::PathBuf; + +/// The bdk-cli command struct +#[allow(dead_code)] +#[derive(Debug)] +pub struct BdkCli { + pub network: String, + pub datadir: Option, + pub recv_desc: Option, + pub change_desc: Option, + pub server_url: Option, +} + +impl BdkCli { + /// Construct a new test environment configuration + pub fn new(network: &str, datadir: Option) -> Self { + Self { + network: network.to_string(), + datadir, + recv_desc: None, + change_desc: None, + server_url: None, + } + } + + /// Creates the base assert_cmd::Command with the global flags pre-loaded + pub fn build_base_cmd(&self) -> Command { + let mut cmd = Command::cargo_bin("bdk-cli").expect("bdk-cli binary must compile"); + + cmd.arg("--network").arg(&self.network); + + if let Some(dir) = &self.datadir { + cmd.arg("--datadir").arg(dir); + } + + if let Some(url) = &self.server_url { + cmd.arg("--server").arg(url); + } + + cmd + } + + /// Returns a pre-configured Command builder for any top-level subcommand + pub fn cmd(&self, subcommand: &str, args: &[&str]) -> Command { + let mut cmd = self.build_base_cmd(); + cmd.arg(subcommand); + cmd.args(args); + cmd + } + + /// Returns a pre-configured Command builder for `key` operations + pub fn key_cmd(&self, args: &[&str]) -> Command { + let mut cmd = self.build_base_cmd(); + cmd.arg("key"); + cmd.args(args); + cmd + } + + /// Returns a pre-configured Command builder for `wallet` operations + #[allow(unused)] + pub fn wallet_cmd(&self, args: &[&str]) -> Command { + let mut cmd = self.build_base_cmd(); + cmd.arg("wallet"); + + // Automatically inject descriptors if they are set in the helper state + if let Some(recv) = &self.recv_desc { + cmd.arg("--descriptor").arg(recv); + } + + cmd.args(args); + cmd + } +} diff --git a/tests/integration.rs b/tests/integration.rs deleted file mode 100644 index 79c887f3..00000000 --- a/tests/integration.rs +++ /dev/null @@ -1,247 +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. - -//! bdk-cli Integration Test Framework -//! -//! This modules performs the necessary integration test for bdk-cli -//! The tests can be run using `cargo test` - -#[cfg(feature = "rpc")] -mod test { - use serde_json::{Value, json}; - use std::convert::From; - use std::env::temp_dir; - use std::path::PathBuf; - use std::process::Command; - - /// Testing errors for integration tests - #[allow(dead_code)] - #[derive(Debug)] - enum IntTestError { - // IO error - IO(std::io::Error), - // Command execution error - CmdExec(String), - // Json Data error - JsonData(String), - } - - impl From for IntTestError { - fn from(e: std::io::Error) -> Self { - IntTestError::IO(e) - } - } - - // Helper function - // Runs a system command with given args - #[allow(dead_code)] - fn run_cmd_with_args(cmd: &str, args: &[&str]) -> Result { - let output = Command::new(cmd).args(args).output().unwrap(); - let mut value = output.stdout; - let error = output.stderr; - if value.is_empty() { - return Err(IntTestError::CmdExec(String::from_utf8(error).unwrap())); - } - value.pop(); // remove `\n` at end - let output_string = std::str::from_utf8(&value).unwrap(); - let json_value: serde_json::Value = match serde_json::from_str(output_string) { - Ok(value) => value, - Err(_) => json!(output_string), // bitcoin-cli will sometime return raw string - }; - Ok(json_value) - } - - // Helper Function - // Transforms a json value to string - #[allow(dead_code)] - fn value_to_string(value: &Value) -> Result { - match value { - Value::Bool(bool) => match bool { - true => Ok("true".to_string()), - false => Ok("false".to_string()), - }, - Value::Number(n) => Ok(n.to_string()), - Value::String(s) => Ok(s.to_string()), - _ => Err(IntTestError::JsonData( - "Value parsing not implemented for this type".to_string(), - )), - } - } - - // Helper Function - // Extracts value from a given json object and key - #[allow(dead_code)] - fn get_value(json: &Value, key: &str) -> Result { - let map = json - .as_object() - .ok_or(IntTestError::JsonData("Json is not an object".to_string()))?; - let value = map - .get(key) - .ok_or(IntTestError::JsonData("Invalid key".to_string()))? - .to_owned(); - let string_value = value_to_string(&value)?; - Ok(string_value) - } - - /// The bdk-cli command struct - /// Use it to perform all bdk-cli operations - #[allow(dead_code)] - #[derive(Debug)] - struct BdkCli { - target: String, - network: String, - verbosity: bool, - recv_desc: Option, - chang_desc: Option, - node_datadir: Option, - } - - impl BdkCli { - /// Construct a new [`BdkCli`] struct - fn new( - network: &str, - node_datadir: Option, - verbosity: bool, - features: &[&str], - ) -> Result { - // Build bdk-cli with given features - let mut feat = "--features=".to_string(); - for item in features { - feat.push_str(item); - feat.push(','); - } - feat.pop(); // remove the last comma - let _build = Command::new("cargo").args(["build", &feat]).output()?; - - let mut bdk_cli = Self { - target: "./target/debug/bdk-cli".to_string(), - network: network.to_string(), - verbosity, - recv_desc: None, - chang_desc: None, - node_datadir, - }; - - println!("BDK-CLI Config : {bdk_cli:#?}"); - let bdk_master_key = bdk_cli.key_exec(&["generate"])?; - let bdk_xprv = get_value(&bdk_master_key, "xprv")?; - - let bdk_recv_desc = - bdk_cli.key_exec(&["derive", "--path", "m/84h/1h/0h/0", "--xprv", &bdk_xprv])?; - let bdk_recv_desc = get_value(&bdk_recv_desc, "xprv")?; - let bdk_recv_desc = format!("wpkh({bdk_recv_desc})"); - - let bdk_chng_desc = - bdk_cli.key_exec(&["derive", "--path", "m/84h/1h/0h/1", "--xprv", &bdk_xprv])?; - let bdk_chng_desc = get_value(&bdk_chng_desc, "xprv")?; - let bdk_chng_desc = format!("wpkh({bdk_chng_desc})"); - - bdk_cli.recv_desc = Some(bdk_recv_desc); - bdk_cli.chang_desc = Some(bdk_chng_desc); - - Ok(bdk_cli) - } - - /// Execute bdk-cli wallet commands with given args - fn wallet_exec(&self, args: &[&str]) -> Result { - // Check if data directory is specified - let mut wallet_args = if let Some(datadir) = &self.node_datadir { - let datadir = datadir.as_os_str().to_str().unwrap(); - ["--network", &self.network, "--datadir", datadir, "wallet"].to_vec() - } else { - ["--network", &self.network, "wallet"].to_vec() - }; - - if self.verbosity { - wallet_args.push("-v"); - } - - wallet_args.push("-d"); - wallet_args.push(self.recv_desc.as_ref().unwrap()); - wallet_args.push("-c"); - wallet_args.push(self.chang_desc.as_ref().unwrap()); - - for arg in args { - wallet_args.push(arg); - } - run_cmd_with_args(&self.target, &wallet_args) - } - - /// Execute bdk-cli key commands with given args - fn key_exec(&self, args: &[&str]) -> Result { - let mut key_args = ["key"].to_vec(); - for arg in args { - key_args.push(arg); - } - run_cmd_with_args(&self.target, &key_args) - } - - /// Execute bdk-cli node command - fn node_exec(&self, args: &[&str]) -> Result { - // Check if data directory is specified - let mut node_args = if let Some(datadir) = &self.node_datadir { - let datadir = datadir.as_os_str().to_str().unwrap(); - ["--network", &self.network, "--datadir", datadir, "node"].to_vec() - } else { - ["--network", &self.network, "node"].to_vec() - }; - - for arg in args { - node_args.push(arg); - } - run_cmd_with_args(&self.target, &node_args) - } - } - - // Run A Basic wallet operation test, with given feature - #[cfg(test)] - #[allow(dead_code)] - fn basic_wallet_ops(feature: &str) { - // Create a temporary directory for testing env - let mut test_dir = std::env::current_dir().unwrap(); - test_dir.push("bdk-testing"); - - let test_dir = temp_dir(); - // let test_dir = test_temp_dir.into_path().to_path_buf(); - - // Create bdk-cli instance - let bdk_cli = BdkCli::new("regtest", Some(test_dir), false, &[feature]).unwrap(); - - // Generate 101 blocks - bdk_cli.node_exec(&["generate", "101"]).unwrap(); - - // Get a bdk address - let bdk_addr_json = bdk_cli.wallet_exec(&["get_new_address"]).unwrap(); - let bdk_addr = get_value(&bdk_addr_json, "address").unwrap(); - - // Send coins from core to bdk - bdk_cli - .node_exec(&["sendtoaddress", &bdk_addr, "1000000000"]) - .unwrap(); - - bdk_cli.node_exec(&["generate", "1"]).unwrap(); - - // Sync the bdk wallet - bdk_cli.wallet_exec(&["sync"]).unwrap(); - - // Get the balance - let balance_json = bdk_cli.wallet_exec(&["get_balance"]).unwrap(); - let confirmed_balance = balance_json - .as_object() - .unwrap() - .get("satoshi") - .unwrap() - .as_object() - .unwrap() - .get("confirmed") - .unwrap() - .as_u64() - .unwrap(); - assert_eq!(confirmed_balance, 1000000000u64); - } -} diff --git a/tests/integration/init.rs b/tests/integration/init.rs new file mode 100644 index 00000000..17fdfce2 --- /dev/null +++ b/tests/integration/init.rs @@ -0,0 +1,336 @@ +use crate::common::BdkCli; +use predicates::prelude::*; +use tempfile::TempDir; +// --- KEY COMMAND TESTS --- +mod test_key { + use super::*; + use serde_json::Value; + + #[test] + fn test_cli_key_generate() { + let temp_dir = TempDir::new().unwrap(); + let cli = BdkCli::new("testnet", Some(temp_dir.path().to_path_buf())); + + cli.key_cmd(&["generate"]) + .assert() + .success() + .stdout(predicate::str::contains("\"xprv\":")) + .stdout(predicate::str::contains("\"mnemonic\":")) + .stdout(predicate::str::contains("\"fingerprint\":")); + } + + #[test] + fn test_cli_key_derive() { + let temp_dir = TempDir::new().unwrap(); + let cli = BdkCli::new("testnet", Some(temp_dir.path().to_path_buf())); + + let generate_output = cli + .key_cmd(&["generate"]) + .output() + .expect("Failed to execute generate command"); + assert!(generate_output.status.success(), "Generate command failed"); + + let generate_json: Value = + serde_json::from_slice(&generate_output.stdout).expect("Invalid JSON"); + let xprv = generate_json["xprv"].as_str().expect("Missing XPRV"); + + let mut cmd = cli.key_cmd(&[ + "derive", + "--xprv", + xprv, + "--derivation_path", + "m/84'/1'/0'/0", + ]); + + cmd.assert() + .success() + .stdout(predicate::str::contains("\"xprv\":")) + .stdout(predicate::str::contains("\"xpub\":")); + } + + #[test] + fn test_cli_key_restore() { + let temp_dir = TempDir::new().unwrap(); + let cli = BdkCli::new("testnet", Some(temp_dir.path().to_path_buf())); + + // Execute the command and capture the output + let generate_cmd = cli + .key_cmd(&["generate"]) + .output() + .expect("Failed to execute generate command"); + assert!(generate_cmd.status.success(), "Generate command failed"); + + // Parse the JSON to extract the mnemonic + let generate_json: Value = + serde_json::from_slice(&generate_cmd.stdout).expect("Failed to parse JSON"); + + let mnemonic = generate_json["mnemonic"] + .as_str() + .expect("Mnemonic missing"); + let xprv = generate_json["xprv"].as_str().expect("XPRV missing"); + let finger_print = generate_json["fingerprint"] + .as_str() + .expect("Fingerprint missing"); + + // Restore using the mnemonic + let output_restore = cli + .key_cmd(&["restore", "--mnemonic", mnemonic]) + .output() + .expect("Failed to execute restore command"); + assert!(output_restore.status.success(), "Restore command failed"); + + // Parse the JSON from the restore command + let restore_json: Value = + serde_json::from_slice(&output_restore.stdout).expect("Failed to parse JSON"); + + let restored_xprv = restore_json["xprv"] + .as_str() + .expect("Restored XPRV missing"); + let restored_fingerprint = restore_json["fingerprint"] + .as_str() + .expect("Restored fingerprint missing"); + + // Assert that the restored data exactly matches the generated data + assert_eq!( + xprv, restored_xprv, + "The restored XPRV does not match the generated XPRV!" + ); + + assert_eq!( + finger_print, restored_fingerprint, + "The restored fingerprint does not match the generated fingerprint!" + ); + } +} + +// --- WALLETS COMMAND TESTS --- +mod test_wallets { + use super::*; + + #[test] + fn test_list_wallets_empty() { + let temp_dir = TempDir::new().unwrap(); + let cli = BdkCli::new("testnet", Some(temp_dir.path().to_path_buf())); + + let mut cmd = cli.build_base_cmd(); + cmd.arg("wallets"); + + cmd.assert() + .failure() + .stderr(predicate::str::contains("No wallets configured yet.")); + } + + #[cfg(feature = "rpc")] + #[test] + fn test_list_wallets_with_entries() { + let temp_dir = TempDir::new().unwrap(); + let cli = BdkCli::new("testnet", Some(temp_dir.path().to_path_buf())); + + for wallet_name in ["wallet_one", "wallet_two"] { + let desc = cli + .cmd("descriptor", &["--type", "tr"]) + .output() + .expect("Command to generate descriptors failed"); + let desc_values: serde_json::Value = serde_json::from_slice(&desc.stdout).unwrap(); + let pub_desc = &desc_values["public_descriptors"]; + let ext_desc = pub_desc["external"].as_str().unwrap(); + let int_desc = pub_desc["internal"].as_str().unwrap(); + + cli.build_base_cmd() + .arg("wallet") + .arg("--wallet") + .arg(wallet_name) + .arg("config") + .arg("--ext-descriptor") + .arg(ext_desc) + .arg("--int-descriptor") + .arg(int_desc) + .arg("--client-type") + .arg("rpc") + .arg("--database-type") + .arg("sqlite") + .arg("--url") + .arg("http://localhost:18443") + .assert() + .success(); + } + + cli.build_base_cmd() + .arg("wallets") + .assert() + .success() + .stdout(predicate::str::contains("wallet_one")) + .stdout(predicate::str::contains("wallet_two")); + } +} + +// --- DESCRIPTOR COMMAND TESTS --- +mod test_descriptor { + use super::*; + + #[test] + fn test_generate_new_descriptor() { + let temp_dir = TempDir::new().unwrap(); + let cli = BdkCli::new("testnet", Some(temp_dir.path().to_path_buf())); + + // Run `bdk-cli descriptor --type tr` + cli.cmd("descriptor", &["--type", "tr"]) + .assert() + .success() + .stdout(predicate::str::contains("\"public_descriptors\":")) + .stdout(predicate::str::contains("\"private_descriptors\":")) + .stdout(predicate::str::contains("\"mnemonic\":")) + .stdout(predicate::str::contains("\"fingerprint\":")); + } +} + +// --- COMPILE COMMAND TESTS --- +#[cfg(feature = "compiler")] +mod test_compile { + use super::*; + + #[test] + fn test_compile_valid_policy() { + let temp_dir = TempDir::new().unwrap(); + let cli = BdkCli::new("testnet", Some(temp_dir.path().to_path_buf())); + + let policy = "pk(02e5b88fdb71c696e1a473f309a47535b7190e21a22bd25e7fc8bd055db3bba12f)"; + + cli.cmd("compile", &[policy, "--type", "wsh"]) + .assert() + .success() + .stdout(predicate::str::contains("\"descriptor\":")) + .stdout(predicate::str::contains("wsh(")); + } + + #[test] + fn test_compile_invalid_policy() { + let temp_dir = TempDir::new().unwrap(); + let cli = BdkCli::new("testnet", Some(temp_dir.path().to_path_buf())); + + cli.cmd("compile", &["invalid_policy", "--type", "wsh"]) + .assert() + .failure() + .stderr(predicate::str::contains("Invalid policy")); + } +} + +// --- CONFIG COMMAND TESTS --- +#[cfg(feature = "rpc")] +mod test_config { + use super::*; + use serde_json::Value; + + #[test] + fn test_save_and_read_wallet_config() { + let temp_dir = TempDir::new().unwrap(); + let cli = BdkCli::new("regtest", Some(temp_dir.path().to_path_buf())); + + let desc = cli + .cmd("descriptor", &["--type", "tr"]) + .output() + .expect("Command to generate descriptors failed"); + + let desc_values: Value = + serde_json::from_slice(&desc.stdout).expect("Invalid JSON from output descriptor"); + + let pub_desc = &desc_values["public_descriptors"]; + + let ext_desc = pub_desc["external"].as_str().unwrap(); + let int_desc = pub_desc["internal"].as_str().unwrap(); + let wallet_name = "test_config_wallet"; + let client_type = "rpc"; + let db = "sqlite"; + let url = "http://localhost:18443"; + + let mut cmd_init = cli.build_base_cmd(); + cmd_init + .arg("wallet") + .arg("--wallet") + .arg(wallet_name) + .arg("config") + .arg("--ext-descriptor") + .arg(ext_desc) + .arg("--int-descriptor") + .arg(int_desc) + .arg("--client-type") + .arg(client_type) + .arg("--database-type") + .arg(db) + .arg("--url") + .arg(url); + + cmd_init.assert().success(); + + // verify saved config + let mut cmd = cli.build_base_cmd(); + cmd.arg("wallets"); + + let output = cmd.output().expect("Failed to execute wallets command"); + + assert!( + output.status.success(), + "The wallets command failed to execute" + ); + + let json_output: Value = + serde_json::from_slice(&output.stdout).expect("CLI did not output valid JSON"); + + let config = &json_output[wallet_name]; + + assert!( + !config.is_null(), + "The wallet {wallet_name} was not found in the root JSON object" + ); + + assert_eq!(config["wallet"].as_str().unwrap(), wallet_name); + assert_eq!(config["network"].as_str().unwrap(), "regtest"); + assert_eq!(config["database_type"].as_str().unwrap(), db); + assert_eq!(config["client_type"].as_str().unwrap(), client_type); + assert_eq!(config["server_url"].as_str().unwrap(), url); + assert_eq!(config["ext_descriptor"].as_str().unwrap(), ext_desc); + assert_eq!(config["int_descriptor"].as_str().unwrap(), int_desc); + } +} + +// SILENT PAYMENTS +#[cfg(feature = "silent-payments")] +mod test_silent_payments { + use super::*; + + const SCAN: &str = "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; + const SPEND: &str = "02c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5"; + + #[test] + fn test_silent_payment_code_network_hrp() { + BdkCli::new("regtest", None) + .cmd( + "silent_payment_code", + &["--scan_key", SCAN, "--spend_key", SPEND], + ) + .assert() + .success() + .stdout(predicate::str::contains("sprt1")); + + BdkCli::new("testnet", None) + .cmd( + "silent_payment_code", + &["--scan_key", SCAN, "--spend_key", SPEND], + ) + .assert() + .success() + .stdout(predicate::str::contains("tsp1")); + } + + #[test] + fn test_silent_payment_code_rejects_invalid_pubkey() { + BdkCli::new("regtest", None) + .cmd( + "silent_payment_code", + &["--scan_key", "deadbeef", "--spend_key", SPEND], + ) + .assert() + .failure() + .stderr(predicate::str::contains("malformed public key")); + } +} diff --git a/tests/integration/offline.rs b/tests/integration/offline.rs new file mode 100644 index 00000000..f19f623c --- /dev/null +++ b/tests/integration/offline.rs @@ -0,0 +1,351 @@ +#[cfg(feature = "rpc")] +mod test_offline { + use crate::common::BdkCli; + use assert_cmd::Command; + use predicates::prelude::*; + use serde_json::Value; + use tempfile::TempDir; + + static WALLET_NAME: &str = "test_config_wallet"; + + /// Helper to spin up a sandboxed CLI with the generated descriptors + fn setup_wallet_config() -> (BdkCli, Command) { + let temp_dir = TempDir::new().unwrap(); + let cli = BdkCli::new("regtest", Some(temp_dir.path().to_path_buf())); + + // generate descriptors + let desc = cli + .cmd("descriptor", &["--type", "tr"]) + .output() + .expect("Command to generate descriptors failed"); + + let desc_values: Value = + serde_json::from_slice(&desc.stdout).expect("Invalid JSON from output descriptor"); + + let priv_desc = &desc_values["private_descriptors"]; + + let ext_desc = priv_desc["external"].as_str().unwrap(); + let int_desc = priv_desc["internal"].as_str().unwrap(); + let client_type = "rpc"; + let db: &str = "sqlite"; + let url = "http://localhost:18443"; + + let mut cmd_init = cli.build_base_cmd(); + cmd_init + .arg("wallet") + .arg("--wallet") + .arg(WALLET_NAME) + .arg("config") + .arg("--ext-descriptor") + .arg(ext_desc) + .arg("--int-descriptor") + .arg(int_desc) + .arg("--client-type") + .arg(client_type) + .arg("--database-type") + .arg(db) + .arg("--url") + .arg(url); + (cli, cmd_init) + } + + #[test] + fn test_new_and_unused_address() { + let (cli, mut cmd_init) = setup_wallet_config(); + cmd_init.assert().success(); + + // new address + cli.wallet_cmd(&["--wallet", WALLET_NAME, "new_address"]) + .assert() + .success() + .stdout(predicate::str::contains("\"address\":")); + + // `unused-address` + cli.wallet_cmd(&["--wallet", WALLET_NAME, "unused_address"]) + .assert() + .success() + .stdout(predicate::str::contains("\"address\":")); + } + + #[test] + fn test_empty_wallet_balances_and_lists() { + let (cli, mut cmd_init) = setup_wallet_config(); + cmd_init.assert().success(); + + // balance + cli.wallet_cmd(&["--wallet", WALLET_NAME, "balance"]) + .assert() + .success() + .stdout(predicate::str::contains("\"total\":")) + .stdout(predicate::str::contains("\"trusted_pending\":")) + .stdout(predicate::str::contains("\"untrusted_pending\":")) + .stdout(predicate::str::contains("\"immature\":")) + .stdout(predicate::str::contains("\"confirmed\":")); + + // Unspent UTXOs + cli.wallet_cmd(&["--wallet", WALLET_NAME, "unspent"]) + .assert() + .success() + .stdout(predicate::str::contains("[]")); + + // Transactions + cli.wallet_cmd(&["--wallet", WALLET_NAME, "transactions"]) + .assert() + .success() + .stdout(predicate::str::contains("[]")); + } + + #[test] + fn test_policies_and_public_descriptor() { + let (cli, mut cmd_init) = setup_wallet_config(); + cmd_init.assert().success(); + + // Policies + cli.wallet_cmd(&["--wallet", WALLET_NAME, "policies"]) + .assert() + .success() + .stdout(predicate::str::contains("\"external\":")) + .stdout(predicate::str::contains("\"internal\":")); + + // Public Descriptor + cli.wallet_cmd(&["--wallet", WALLET_NAME, "public_descriptor"]) + .assert() + .success() + .stdout(predicate::str::contains("external")) + .stdout(predicate::str::contains("internal")); + } + + #[test] + fn test_create_tx_insufficient_funds() { + let (cli, mut cmd_init) = setup_wallet_config(); + cmd_init.assert().success(); + + // create transaction + cli.wallet_cmd(&[ + "--wallet", + WALLET_NAME, + "create_tx", + "--to", + "tb1p4tp4l6glyr2gs94neqcpr5gha7344nfyznfkc8szkreflscsdkgqsdent4:1000", + ]) + .assert() + .failure() + .stderr(predicate::str::contains("Insufficient")); + } + + #[test] + fn test_combine_psbt_invalid_input() { + let (cli, mut cmd_init) = setup_wallet_config(); + cmd_init.assert().success(); + + // Invalid create-tx. + cli.wallet_cmd(&[ + "--wallet", + WALLET_NAME, + "combine_psbt", + "invalid_psbt", + "another_invalid_psbt", + ]) + .assert() + .failure() + .stderr(predicate::str::contains("Invalid")); + } + + #[cfg(feature = "bip322")] + #[test] + fn test_sign_message_and_verify_message() { + let (cli, mut cmd_init) = setup_wallet_config(); + cmd_init.assert().success(); + + let message = "bdk-cli integration test"; + + // Reveal exactly one address and reuse it for both sign and verify. + let addr_output = cli + .wallet_cmd(&["--wallet", WALLET_NAME, "new_address"]) + .output() + .expect("Failed to generate address"); + assert!( + addr_output.status.success(), + "new_address failed: {}", + String::from_utf8_lossy(&addr_output.stderr) + ); + let addr_json: Value = serde_json::from_slice(&addr_output.stdout).unwrap(); + let address = addr_json["address"] + .as_str() + .expect("missing address") + .to_string(); + println!("DEBUG signing address: {address}"); + + // Sign (default signature_type = "simple"). + let sign_output = cli + .wallet_cmd(&[ + "--wallet", + WALLET_NAME, + "sign_message", + "--message", + message, + "--address", + &address, + ]) + .output() + .expect("Failed to sign message"); + assert!( + sign_output.status.success(), + "sign_message failed: {}", + String::from_utf8_lossy(&sign_output.stderr) + ); + let sign_json: Value = serde_json::from_slice(&sign_output.stdout).unwrap(); + let proof = sign_json["proof"].as_str().expect("missing proof"); + println!("DEBUG proof: {proof}"); + println!("DEBUG verifying same address: {address}"); + + // Verify with the identical address + message. + cli.wallet_cmd(&[ + "--wallet", + WALLET_NAME, + "verify_message", + "--address", + &address, + "--message", + message, + "--proof", + proof, + ]) + .assert() + .success() + .stdout(predicate::str::contains("\"valid\": true")); + } + + #[cfg(feature = "bip322")] + #[test] + fn test_verify_message_rejects_tampered_message() { + let (cli, mut cmd_init) = setup_wallet_config(); + cmd_init.assert().success(); + + let addr_output = cli + .wallet_cmd(&["--wallet", WALLET_NAME, "new_address"]) + .output() + .expect("Failed to generate address"); + let addr_json: Value = serde_json::from_slice(&addr_output.stdout).unwrap(); + let address = addr_json["address"].as_str().unwrap(); + + let sign_output = cli + .wallet_cmd(&[ + "--wallet", + WALLET_NAME, + "sign_message", + "--message", + "original message", + "--address", + address, + ]) + .output() + .expect("Failed to sign message"); + let sign_json: Value = serde_json::from_slice(&sign_output.stdout).unwrap(); + let proof = sign_json["proof"].as_str().unwrap(); + + // A tampered message should fail + cli.wallet_cmd(&[ + "--wallet", + WALLET_NAME, + "verify_message", + "--proof", + proof, + "--message", + "tampered message", + "--address", + address, + ]) + .assert() + .success() + .stdout(predicate::str::contains("\"valid\": false")); + } + + #[test] + fn test_create_tx_send_all_rejects_multiple_recipients() { + let (cli, mut cmd_init) = setup_wallet_config(); + cmd_init.assert().success(); + + // Same address twice => two recipients => must be rejected under --send_all. + let recipient = "tb1p4tp4l6glyr2gs94neqcpr5gha7344nfyznfkc8szkreflscsdkgqsdent4:0"; + cli.wallet_cmd(&[ + "--wallet", + WALLET_NAME, + "create_tx", + "--send_all", + "--to", + recipient, + "--to", + recipient, + ]) + .assert() + .failure() + .stderr(predicate::str::contains( + "Wallet can only be drained to a single output", + )); + } +} + +#[cfg(all(feature = "repl", feature = "electrum"))] +mod repl_tests { + use crate::common::BdkCli; + use serde_json::Value; + use tempfile::TempDir; + + const WALLET_NAME: &str = "repl_test_wallet"; + + fn setup_repl_wallet() -> (BdkCli, TempDir) { + let temp = TempDir::new().unwrap(); + let cli = BdkCli::new("regtest", Some(temp.path().to_path_buf())); + + let desc = cli.cmd("descriptor", &["--type", "tr"]).output().unwrap(); + let v: Value = serde_json::from_slice(&desc.stdout).unwrap(); + let ext = v["private_descriptors"]["external"].as_str().unwrap(); + let int = v["private_descriptors"]["internal"].as_str().unwrap(); + + cli.build_base_cmd() + .args(["wallet", "--wallet", WALLET_NAME, "config"]) + .args(["--ext-descriptor", ext, "--int-descriptor", int]) + .args(["--client-type", "electrum", "--database-type", "sqlite"]) + .args(["--url", "127.0.0.1:1"]) + .assert() + .success(); + + (cli, temp) + } + + #[test] + fn test_repl_executes_commands_and_exits() { + let (cli, _temp) = setup_repl_wallet(); + + let output = cli + .build_base_cmd() + .args(["repl", "--wallet", WALLET_NAME]) + .write_stdin("wallet new_address\nwallet balance\nexit\n") + .output() + .expect("failed to run repl"); + + assert!( + output.status.success(), + "repl exited non-zero: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("Entering REPL mode"), + "missing banner:\n{stdout}" + ); + assert!( + stdout.contains("\"address\":"), + "new_address output missing:\n{stdout}" + ); + assert!( + stdout.contains("\"confirmed\":"), + "balance output missing:\n{stdout}" + ); + assert!( + stdout.contains("Exiting..."), + "no exit acknowledgement:\n{stdout}" + ); + } +} diff --git a/tests/integration/online.rs b/tests/integration/online.rs new file mode 100644 index 00000000..0ba166d5 --- /dev/null +++ b/tests/integration/online.rs @@ -0,0 +1,1062 @@ +#[cfg(feature = "electrum")] +mod test_online { + use crate::common::BdkCli; + use assert_cmd::Command; + use bdk_testenv::{TestEnv, bitcoincore_rpc::RpcApi}; + use bdk_wallet::bitcoin::{Address, Amount, Network, Txid}; + use serde_json::Value; + use std::str::FromStr; + use std::time::Duration; + use tempfile::TempDir; + + static WALLET_NAME: &str = "test_online_wallet"; + static RECIPIENT: &str = "tb1p4tp4l6glyr2gs94neqcpr5gha7344nfyznfkc8szkreflscsdkgqsdent4"; + + /// Spins up the envt, a Regtest node + electrs, and saves the wallet config. + fn setup_online_wallet() -> (BdkCli, Command, TestEnv) { + let env = TestEnv::new().expect("Failed to start bdk_testenv"); + let server_url = env.electrsd.electrum_url.as_str(); + + let temp_dir = TempDir::new().unwrap(); + let cli = BdkCli::new("regtest", Some(temp_dir.path().to_path_buf())); + + let desc = cli + .cmd("descriptor", &["--type", "tr"]) + .output() + .expect("Command to generate descriptors failed"); + let desc_values: Value = + serde_json::from_slice(&desc.stdout).expect("Invalid JSON from output descriptor"); + let priv_desc = &desc_values["private_descriptors"]; + let ext_desc = priv_desc["external"].as_str().unwrap(); + let int_desc = priv_desc["internal"].as_str().unwrap(); + + let mut cmd_init = cli.build_base_cmd(); + cmd_init + .arg("wallet") + .arg("--wallet") + .arg(WALLET_NAME) + .arg("config") + .arg("--ext-descriptor") + .arg(ext_desc) + .arg("--int-descriptor") + .arg(int_desc) + .arg("--client-type") + .arg("electrum") + .arg("--database-type") + .arg("sqlite") + .arg("--url") + .arg(server_url); + + (cli, cmd_init, env) + } + + /// Mines blocks, funds wallet with 0.5 BTC, confirms it, and + /// runs `full_scan` so the wallet's persisted state reflects the funding. + /// Asserts the resulting confirmed balance is exactly 50,000,000 sats. + fn fund_and_sync_wallet(cli: &BdkCli, env: &TestEnv) { + let address = cli_new_address(cli); + + let node_address = env + .rpc_client() + .get_new_address(None, None) + .unwrap() + .assume_checked(); + + let mined_blocks = env + .mine_blocks(101, Some(node_address)) + .expect("Failed to mine initial blocks"); + assert_eq!(mined_blocks.len(), 101, "expected exactly 101 blocks mined"); + env.wait_until_electrum_sees_block(Duration::from_secs(10)) + .expect("Electrum did not catch up to initial blocks"); + + let txid = env + .send(&address, Amount::from_btc(0.5).unwrap()) + .expect("Failed to fund wallet address"); + env.wait_until_electrum_sees_txid(txid, Duration::from_secs(10)) + .expect("Electrum did not see funding tx"); + + env.mine_blocks(3, None) + .expect("Failed to confirm funding tx"); + env.wait_until_electrum_sees_block(Duration::from_secs(10)) + .expect("Electrum did not catch up to confirmation blocks"); + + cli_full_scan(cli); + assert_eq!( + cli_balance(cli), + 50_000_000, + "wallet should show funded balance after full_scan" + ); + } + + /// Runs `wallet --wallet `, asserts the process exited + /// successfully, and parses stdout as JSON. + fn run_wallet_json(cli: &BdkCli, args: &[&str]) -> Value { + let mut full_args = vec!["--wallet", WALLET_NAME]; + full_args.extend_from_slice(args); + + let output = cli + .wallet_cmd(&full_args) + .output() + .unwrap_or_else(|e| panic!("failed to spawn `{}`: {e}", args.join(" "))); + + assert!( + output.status.success(), + "`{}` failed: {}", + args.join(" "), + String::from_utf8_lossy(&output.stderr) + ); + + serde_json::from_slice(&output.stdout).unwrap_or_else(|e| { + panic!( + "`{}` returned non-JSON stdout ({e}): {}", + args.join(" "), + String::from_utf8_lossy(&output.stdout) + ) + }) + } + + fn cli_new_address(cli: &BdkCli) -> Address { + let json = run_wallet_json(cli, &["new_address"]); + let addr = json["address"] + .as_str() + .expect("new_address: missing 'address' field"); + Address::from_str(addr) + .expect("new_address: invalid address string") + .require_network(Network::Regtest) + .expect("new_address: wrong network") + } + + fn cli_full_scan(cli: &BdkCli) { + run_wallet_json(cli, &["full_scan"]); + } + + fn cli_sync(cli: &BdkCli) { + run_wallet_json(cli, &["sync"]); + } + + fn cli_balance(cli: &BdkCli) -> u64 { + let json = run_wallet_json(cli, &["balance"]); + json["confirmed"] + .as_u64() + .expect("balance: missing 'confirmed' field") + } + + fn cli_create_tx(cli: &BdkCli, to: &str) -> String { + let json = run_wallet_json(cli, &["create_tx", "--to", to]); + json["psbt"] + .as_str() + .expect("create_tx: missing 'psbt' field") + .to_string() + } + + /// Returns (signed_psbt_base64, is_finalized). + fn cli_sign(cli: &BdkCli, psbt: &str) -> (String, bool) { + let json = run_wallet_json(cli, &["sign", psbt]); + let signed = json["psbt"] + .as_str() + .expect("sign: missing 'psbt' field") + .to_string(); + let finalized = json["is_finalized"] + .as_bool() + .expect("sign: missing 'is_finalized' field"); + (signed, finalized) + } + + fn cli_extract_psbt(cli: &BdkCli, psbt: &str) -> String { + let json = run_wallet_json(cli, &["extract_psbt", psbt]); + json["raw_tx"] + .as_str() + .expect("extract_psbt: missing 'raw_tx' field") + .to_string() + } + + fn cli_broadcast(cli: &BdkCli, raw_tx: &str) -> Txid { + let json = run_wallet_json(cli, &["broadcast", "--tx", raw_tx]); + let txid = json["txid"] + .as_str() + .expect("broadcast: missing 'txid' field"); + Txid::from_str(txid).expect("broadcast: invalid txid") + } + + #[test] + fn test_funded_wallet_unspent_and_transactions() { + let (cli, mut cmd_init, env) = setup_online_wallet(); + cmd_init.assert().success(); + fund_and_sync_wallet(&cli, &env); + + // unspent: exactly one confirmed, unspent 50,000,000 sat UTXO + let unspent = run_wallet_json(&cli, &["unspent"]); + assert_eq!( + unspent["count"].as_u64(), + Some(1), + "funded wallet should report exactly one UTXO, got: {unspent}" + ); + + let utxo = &unspent["items"][0]; + assert_eq!( + utxo["txout"]["value"].as_u64(), + Some(50_000_000), + "the single UTXO should equal the 0.5 BTC funding amount" + ); + assert_eq!( + utxo["is_spent"].as_bool(), + Some(false), + "the funding UTXO must be unspent" + ); + assert!( + utxo["outpoint"].as_str().is_some_and(|o| o.contains(':')), + "UTXO outpoint should be a `txid:vout` string, got: {}", + utxo["outpoint"] + ); + assert!( + utxo["derivation_index"].is_number(), + "UTXO should expose a numeric derivation_index" + ); + + // transactions: exactly one relevant tx, funding our address + let txs = run_wallet_json(&cli, &["transactions"]); + assert_eq!( + txs["count"].as_u64(), + Some(1), + "funded wallet should report exactly one transaction, got: {txs}" + ); + + let tx = &txs["items"][0]; + assert_eq!( + tx["is_coinbase"].as_bool(), + Some(false), + "the funding transaction is a normal send, not a coinbase" + ); + assert!( + tx["txid"].as_str().is_some_and(|t| t.len() == 64), + "transaction should expose a 64-char hex txid, got: {}", + tx["txid"] + ); + // The funding tx must contain the output that paid us 0.5 BTC. + let outputs = tx["outputs"] + .as_array() + .expect("transaction outputs should be a JSON array"); + assert!( + outputs + .iter() + .any(|o| o["value"].as_u64() == Some(50_000_000)), + "funding tx should contain a 50,000,000 sat output to the wallet" + ); + } + + #[test] + fn test_create_tx_send_all_drains_wallet() { + let (cli, mut cmd_init, env) = setup_online_wallet(); + cmd_init.assert().success(); + fund_and_sync_wallet(&cli, &env); + + let unsigned_psbt = run_wallet_json( + &cli, + &["create_tx", "--send_all", "--to", &format!("{RECIPIENT}:0")], + )["psbt"] + .as_str() + .expect("create_tx: missing 'psbt' field") + .to_string(); + + let (signed_psbt, finalized) = cli_sign(&cli, &unsigned_psbt); + assert!(finalized, "drain PSBT should be finalized after signing"); + + let raw_tx = cli_extract_psbt(&cli, &signed_psbt); + let drain_txid = cli_broadcast(&cli, &raw_tx); + env.rpc_client() + .get_mempool_entry(&drain_txid) + .expect("drain tx not found in node mempool"); + + env.mine_blocks(1, None) + .expect("Failed to mine drain confirmation block"); + env.wait_until_electrum_sees_block(Duration::from_secs(10)) + .expect("Electrum did not catch up to drain confirmation"); + + cli_sync(&cli); + assert_eq!( + cli_balance(&cli), + 0, + "send_all should leave a zero confirmed balance" + ); + } + + #[test] + fn test_full_online_transaction_lifecycle() { + let (cli, mut cmd_init, env) = setup_online_wallet(); + cmd_init.assert().success(); + + fund_and_sync_wallet(&cli, &env); + + // Build, sign, extract, and broadcast a spend + let unsigned_psbt = cli_create_tx(&cli, &format!("{RECIPIENT}:20000")); + + let (signed_psbt, finalized) = cli_sign(&cli, &unsigned_psbt); + assert!(finalized, "PSBT should be finalized after signing"); + + let raw_tx = cli_extract_psbt(&cli, &signed_psbt); + + let spend_txid = cli_broadcast(&cli, &raw_tx); + env.rpc_client() + .get_mempool_entry(&spend_txid) + .expect("broadcast tx not found in node mempool"); + + // Confirm the spend and verify the final balance + env.mine_blocks(1, None) + .expect("Failed to mine spend confirmation block"); + env.wait_until_electrum_sees_block(Duration::from_secs(10)) + .expect("Electrum did not catch up to spend confirmation"); + + cli_sync(&cli); + let final_balance = cli_balance(&cli); + assert!( + (49_900_000..50_000_000).contains(&final_balance), + "unexpected post-spend confirmed balance: {final_balance}" + ); + } + + #[test] + fn test_finalize_psbt_on_signed_tx() { + let (cli, mut cmd_init, env) = setup_online_wallet(); + cmd_init.assert().success(); + fund_and_sync_wallet(&cli, &env); + + let unsigned_psbt = cli_create_tx(&cli, &format!("{RECIPIENT}:15000")); + let (signed_psbt, finalized) = cli_sign(&cli, &unsigned_psbt); + assert!(finalized, "PSBT should be finalized after signing"); + + // finalize_psbt should be idempotent: finalizing an already-finalized + // PSBT should succeed and still report is_finalized = true. + let finalize_json = run_wallet_json(&cli, &["finalize_psbt", &signed_psbt]); + assert_eq!( + finalize_json["is_finalized"].as_bool(), + Some(true), + "finalize_psbt should report the PSBT as finalized" + ); + } + + #[test] + fn test_combine_psbt_merges_signatures() { + let (cli, mut cmd_init, env) = setup_online_wallet(); + cmd_init.assert().success(); + fund_and_sync_wallet(&cli, &env); + + let unsigned_psbt = cli_create_tx(&cli, &format!("{RECIPIENT}:15000")); + let (signed_psbt, finalized) = cli_sign(&cli, &unsigned_psbt); + assert!(finalized, "PSBT should be finalized after signing"); + + // Combine the unsigned and signed versions of the same transaction + let combined_json = run_wallet_json(&cli, &["combine_psbt", &unsigned_psbt, &signed_psbt]); + let combined_psbt = combined_json["psbt"] + .as_str() + .expect("combine_psbt: missing 'psbt' field"); + + // If signature data survived the combine, this should extract cleanly. + let raw_tx = cli_extract_psbt(&cli, combined_psbt); + assert!( + !raw_tx.is_empty(), + "combined PSBT did not extract to a valid raw transaction" + ); + } + + #[test] + fn test_bump_fee_replaces_unconfirmed_tx() { + let (cli, mut cmd_init, env) = setup_online_wallet(); + cmd_init.assert().success(); + fund_and_sync_wallet(&cli, &env); + + // Create and broadcast a low-fee-rate, RBF-enabled transaction. + let unsigned_psbt = run_wallet_json( + &cli, + &[ + "create_tx", + "--to", + &format!("{RECIPIENT}:15000"), + "--fee_rate", + "1.0", + ], + )["psbt"] + .as_str() + .expect("create_tx: missing 'psbt' field") + .to_string(); + + let (signed_psbt, finalized) = cli_sign(&cli, &unsigned_psbt); + assert!(finalized); + let raw_tx = cli_extract_psbt(&cli, &signed_psbt); + let original_txid = cli_broadcast(&cli, &raw_tx); + + env.rpc_client() + .get_mempool_entry(&original_txid) + .expect("original tx should be in the mempool before bumping"); + + env.wait_until_electrum_sees_txid(original_txid, Duration::from_secs(10)) + .expect("electrs did not see the broadcast tx"); + cli_sync(&cli); + + // Bump to a much higher fee rate; this should replace the original tx. + let bumped_json = run_wallet_json( + &cli, + &[ + "bump_fee", + "--txid", + &original_txid.to_string(), + "--fee_rate", + "10.0", + ], + ); + let bumped_psbt = bumped_json["psbt"] + .as_str() + .expect("bump_fee: missing 'psbt' field"); + + let (signed_bumped, bumped_finalized) = cli_sign(&cli, bumped_psbt); + assert!( + bumped_finalized, + "bumped PSBT should be finalized after signing" + ); + + let bumped_raw_tx = cli_extract_psbt(&cli, &signed_bumped); + let bumped_txid = cli_broadcast(&cli, &bumped_raw_tx); + + assert_ne!( + original_txid, bumped_txid, + "fee-bumped transaction should have a different txid than the original" + ); + + env.mine_blocks(1, None) + .expect("Failed to confirm bumped tx"); + env.wait_until_electrum_sees_block(Duration::from_secs(10)) + .expect("Electrum did not catch up after bump confirmation"); + cli_sync(&cli); + + let final_balance = cli_balance(&cli); + assert!( + final_balance < 50_000_000, + "balance should reflect exactly one confirmed spend after RBF, got {final_balance}" + ); + } + + #[test] + fn test_create_tx_multiple_recipients() { + use bdk_wallet::bitcoin::Psbt; + + let (cli, mut cmd_init, env) = setup_online_wallet(); + cmd_init.assert().success(); + fund_and_sync_wallet(&cli, &env); + + let second = cli_new_address(&cli); + + let psbt_b64 = run_wallet_json( + &cli, + &[ + "create_tx", + "--to", + &format!("{RECIPIENT}:20000"), + "--to", + &format!("{second}:30000"), + ], + )["psbt"] + .as_str() + .expect("create_tx: missing 'psbt' field") + .to_string(); + + let psbt: Psbt = psbt_b64 + .parse() + .expect("create_tx returned an invalid PSBT"); + let values: Vec = psbt + .unsigned_tx + .output + .iter() + .map(|o| o.value.to_sat()) + .collect(); + + assert!( + values.contains(&20_000), + "first recipient (20_000) output missing: {values:?}" + ); + assert!( + values.contains(&30_000), + "second recipient (30_000) output missing: {values:?}" + ); + assert!( + psbt.unsigned_tx.output.len() >= 2, + "expected at least the two recipient outputs, got {}", + psbt.unsigned_tx.output.len() + ); + } + + // `create_tx --add_string` embeds an OP_RETURN (nulldata) output carrying + // the given text, at zero value. + #[test] + fn test_create_tx_op_return() { + use bdk_wallet::bitcoin::Psbt; + let (cli, mut cmd_init, env) = setup_online_wallet(); + cmd_init.assert().success(); + fund_and_sync_wallet(&cli, &env); + + let msg = "hello bdk cli"; + let psbt_b64 = run_wallet_json( + &cli, + &[ + "create_tx", + "--to", + &format!("{RECIPIENT}:20000"), + "--add_string", + msg, + ], + )["psbt"] + .as_str() + .unwrap() + .to_string(); + + let psbt: Psbt = psbt_b64.parse().expect("invalid PSBT"); + let op_return = psbt + .unsigned_tx + .output + .iter() + .find(|o| o.script_pubkey.is_op_return()) + .expect("expected an OP_RETURN output"); + assert_eq!( + op_return.value.to_sat(), + 0, + "OP_RETURN output must carry 0 value" + ); + assert!( + op_return + .script_pubkey + .as_bytes() + .windows(msg.len()) + .any(|w| w == msg.as_bytes()), + "OP_RETURN should embed the message bytes" + ); + } + + // `create_tx --utxos` forces a specific UTXO to be spent; + // `--unspendable`excludes one. Funds two UTXOs so the selection is actually observable. + #[test] + fn test_create_tx_utxo_selection() { + use bdk_wallet::bitcoin::Psbt; + let (cli, mut cmd_init, env) = setup_online_wallet(); + cmd_init.assert().success(); + + let node_addr = env + .rpc_client() + .get_new_address(None, None) + .unwrap() + .assume_checked(); + env.mine_blocks(101, Some(node_addr)).unwrap(); + env.wait_until_electrum_sees_block(Duration::from_secs(10)) + .unwrap(); + let a1 = cli_new_address(&cli); + let t1 = env.send(&a1, Amount::from_btc(0.3).unwrap()).unwrap(); + env.wait_until_electrum_sees_txid(t1, Duration::from_secs(10)) + .unwrap(); + let a2 = cli_new_address(&cli); + let t2 = env.send(&a2, Amount::from_btc(0.2).unwrap()).unwrap(); + env.wait_until_electrum_sees_txid(t2, Duration::from_secs(10)) + .unwrap(); + env.mine_blocks(3, None).unwrap(); + env.wait_until_electrum_sees_block(Duration::from_secs(10)) + .unwrap(); + cli_full_scan(&cli); + + let unspent = run_wallet_json(&cli, &["unspent"]); + let items = unspent["items"].as_array().unwrap(); + assert_eq!(items.len(), 2, "expected two UTXOs, got {}", items.len()); + let op0 = items[0]["outpoint"].as_str().unwrap().to_string(); + + // --utxos forces op0 to be an input. + let psbt_a: Psbt = run_wallet_json( + &cli, + &[ + "create_tx", + "--to", + &format!("{RECIPIENT}:10000"), + "--utxos", + &op0, + ], + )["psbt"] + .as_str() + .unwrap() + .parse() + .unwrap(); + let inputs_a: Vec = psbt_a + .unsigned_tx + .input + .iter() + .map(|i| i.previous_output.to_string()) + .collect(); + assert!( + inputs_a.contains(&op0), + "--utxos should force {op0}: {inputs_a:?}" + ); + + // --unspendable excludes op0 (the other UTXO covers the amount). + let psbt_b: Psbt = run_wallet_json( + &cli, + &[ + "create_tx", + "--to", + &format!("{RECIPIENT}:10000"), + "--unspendable", + &op0, + ], + )["psbt"] + .as_str() + .unwrap() + .parse() + .unwrap(); + let inputs_b: Vec = psbt_b + .unsigned_tx + .input + .iter() + .map(|i| i.previous_output.to_string()) + .collect(); + assert!( + !inputs_b.contains(&op0), + "--unspendable should exclude {op0}: {inputs_b:?}" + ); + } + + #[cfg(feature = "rpc")] + mod test_rpc { + use super::*; + + static WALLET_NAME: &str = "test_rpc_wallet"; + + #[test] + fn test_rpc_full_scan_reflects_funding() { + let env = TestEnv::new().expect("start testenv"); + let rpc_url = env.bitcoind.rpc_url(); + let cookie = env + .bitcoind + .params + .cookie_file + .to_str() + .unwrap() + .to_string(); + + let temp_dir = TempDir::new().unwrap(); + let cli = BdkCli::new("regtest", Some(temp_dir.path().to_path_buf())); + + // descriptors + let desc = cli.cmd("descriptor", &["--type", "tr"]).output().unwrap(); + let dv: Value = serde_json::from_slice(&desc.stdout).unwrap(); + let ext = dv["private_descriptors"]["external"].as_str().unwrap(); + let int = dv["private_descriptors"]["internal"].as_str().unwrap(); + + // config: rpc client-type, --url flag, cookie as a POSITIONAL arg + cli.build_base_cmd() + .args(["wallet", "--wallet", WALLET_NAME, "config"]) + .args(["--ext-descriptor", ext, "--int-descriptor", int]) + .args(["--client-type", "rpc", "--database-type", "sqlite"]) + .args(["--url", &rpc_url]) + .arg(&cookie) + .assert() + .success(); + + // wallet address to fund + let out = cli + .wallet_cmd(&["--wallet", WALLET_NAME, "new_address"]) + .output() + .unwrap(); + let addr_json: Value = serde_json::from_slice(&out.stdout).unwrap(); + let address = Address::from_str(addr_json["address"].as_str().unwrap()) + .unwrap() + .require_network(Network::Regtest) + .unwrap(); + + // fund + confirm + let node_addr = env + .rpc_client() + .get_new_address(None, None) + .unwrap() + .assume_checked(); + env.mine_blocks(101, Some(node_addr)).unwrap(); + env.send(&address, Amount::from_btc(0.5).unwrap()).unwrap(); + env.mine_blocks(3, None).unwrap(); + + // full_scan + balance via rpc + let scan = cli + .wallet_cmd(&["--wallet", WALLET_NAME, "full_scan"]) + .output() + .unwrap(); + assert!( + scan.status.success(), + "rpc full_scan failed: {}", + String::from_utf8_lossy(&scan.stderr) + ); + + let bal = cli + .wallet_cmd(&["--wallet", WALLET_NAME, "balance"]) + .output() + .unwrap(); + let bj: Value = serde_json::from_slice(&bal.stdout).unwrap(); + assert_eq!( + bj["confirmed"].as_u64(), + Some(50_000_000), + "rpc-synced confirmed balance mismatch: {bj}" + ); + } + } + + #[cfg(feature = "silent-payments")] + mod test_sp { + use super::*; + use bdk_wallet::bitcoin::{Transaction, consensus::encode::deserialize_hex}; + + const SCAN: &str = "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; + const SPEND: &str = "02c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5"; + static WALLET: &str = "sp_wallet"; + + #[test] + fn test_create_sp_tx_produces_taproot_output() { + let env = TestEnv::new().unwrap(); + let url = env.electrsd.electrum_url.as_str(); + let tmp = TempDir::new().unwrap(); + let cli = BdkCli::new("regtest", Some(tmp.path().to_path_buf())); + + let desc = cli.cmd("descriptor", &["--type", "tr"]).output().unwrap(); + let dv: Value = serde_json::from_slice(&desc.stdout).unwrap(); + let ext_desc = dv["private_descriptors"]["external"].as_str().unwrap(); + let int_desc = dv["private_descriptors"]["internal"].as_str().unwrap(); + cli.build_base_cmd() + .args([ + "wallet", + "--wallet", + WALLET, + "config", + "--ext-descriptor", + ext_desc, + "--int-descriptor", + int_desc, + "--client-type", + "electrum", + "--database-type", + "sqlite", + "--url", + url, + ]) + .assert() + .success(); + + let new_address = cli + .wallet_cmd(&["--wallet", WALLET, "new_address"]) + .output() + .unwrap(); + let addr = Address::from_str( + serde_json::from_slice::(&new_address.stdout).unwrap()["address"] + .as_str() + .unwrap(), + ) + .unwrap() + .require_network(Network::Regtest) + .unwrap(); + let node_address = env + .rpc_client() + .get_new_address(None, None) + .unwrap() + .assume_checked(); + env.mine_blocks(101, Some(node_address)).unwrap(); + env.wait_until_electrum_sees_block(Duration::from_secs(10)) + .unwrap(); + let txid = env.send(&addr, Amount::from_btc(0.5).unwrap()).unwrap(); + env.wait_until_electrum_sees_txid(txid, Duration::from_secs(10)) + .unwrap(); + env.mine_blocks(3, None).unwrap(); + env.wait_until_electrum_sees_block(Duration::from_secs(10)) + .unwrap(); + cli.wallet_cmd(&["--wallet", WALLET, "full_scan"]) + .assert() + .success(); + + let silent_payment_output = cli + .cmd( + "silent_payment_code", + &["--scan_key", SCAN, "--spend_key", SPEND], + ) + .output() + .unwrap(); + let sp_code = + serde_json::from_slice::(&silent_payment_output.stdout).unwrap()["message"] + .as_str() + .unwrap() + .to_string(); + + let out = cli + .wallet_cmd(&[ + "--wallet", + WALLET, + "create_sp_tx", + "--to-sp", + &format!("{sp_code}:20000"), + ]) + .output() + .unwrap(); + assert!( + out.status.success(), + "create_sp_tx failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + let rawtx = serde_json::from_slice::(&out.stdout).unwrap()["raw_tx"] + .as_str() + .unwrap() + .to_string(); + + let tx: Transaction = deserialize_hex(&rawtx).expect("invalid raw tx"); + let sp_out = tx + .output + .iter() + .find(|o| o.value.to_sat() == 20_000) + .expect("no 20_000 output"); + assert!( + sp_out.script_pubkey.is_p2tr(), + "SP recipient output should be P2TR" + ); + } + } + + #[cfg(feature = "bip322")] + #[test] + fn test_verify_message_proof_of_funds_uses_persisted_utxos() { + let env = TestEnv::new().expect("Failed to start bdk_testenv"); + let server_url = env.electrsd.electrum_url.as_str(); + let temp_dir = TempDir::new().unwrap(); + let cli = BdkCli::new("regtest", Some(temp_dir.path().to_path_buf())); + + // A wpkh wallet + let desc = cli.cmd("descriptor", &["--type", "wpkh"]).output().unwrap(); + let desc_val: Value = serde_json::from_slice(&desc.stdout).unwrap(); + let ext_desc = desc_val["private_descriptors"]["external"] + .as_str() + .unwrap(); + let int_desc = desc_val["private_descriptors"]["internal"] + .as_str() + .unwrap(); + cli.build_base_cmd() + .arg("wallet") + .arg("--wallet") + .arg(WALLET_NAME) + .arg("config") + .arg("--ext-descriptor") + .arg(ext_desc) + .arg("--int-descriptor") + .arg(int_desc) + .arg("--client-type") + .arg("electrum") + .arg("--database-type") + .arg("sqlite") + .arg("--url") + .arg(server_url) + .assert() + .success(); + + // Fund a single small UTXO + let address = cli_new_address(&cli); + let node_addr = env + .rpc_client() + .get_new_address(None, None) + .unwrap() + .assume_checked(); + env.mine_blocks(101, Some(node_addr)).unwrap(); + env.wait_until_electrum_sees_block(Duration::from_secs(10)) + .unwrap(); + let txid = env.send(&address, Amount::from_sat(5_000)).unwrap(); + env.wait_until_electrum_sees_txid(txid, Duration::from_secs(10)) + .unwrap(); + env.mine_blocks(3, None).unwrap(); + env.wait_until_electrum_sees_block(Duration::from_secs(10)) + .unwrap(); + cli_full_scan(&cli); + + // The funded outpoint to prove control of. + let unspent = run_wallet_json(&cli, &["unspent"]); + let outpoint = unspent["items"][0]["outpoint"] + .as_str() + .expect("funded wallet should have one UTXO") + .to_string(); + let addr = address.to_string(); + + // Produce a proof-of-funds over that UTXO. + let proof = run_wallet_json( + &cli, + &[ + "sign_message", + "--message", + "proof-of-funds", + "--address", + &addr, + "--signature-type", + "fullproofoffunds", + "--utxos", + &outpoint, + ], + )["proof"] + .as_str() + .expect("sign_message should return a proof") + .to_string(); + + let result = run_wallet_json( + &cli, + &[ + "verify_message", + "--proof", + &proof, + "--message", + "proof-of-funds", + "--address", + &addr, + ], + ); + assert_eq!( + result["valid"].as_bool(), + Some(true), + "proof-of-funds should verify against the persisted wallet: {result}" + ); + assert_eq!( + result["proven_amount"].as_u64(), + Some(5_000), + "proven_amount should equal the funded UTXO value: {result}" + ); + } + + // `create_dns_tx` with a plain `--to` recipient + #[cfg(feature = "dns_payment")] + #[test] + fn test_create_dns_tx_plain_recipient() { + use bdk_wallet::bitcoin::Psbt; + let (cli, mut cmd_init, env) = setup_online_wallet(); + cmd_init.assert().success(); + fund_and_sync_wallet(&cli, &env); + + let psbt_b64 = run_wallet_json( + &cli, + &["create_dns_tx", "--to", &format!("{RECIPIENT}:20000")], + )["psbt"] + .as_str() + .expect("create_dns_tx: missing 'psbt'") + .to_string(); + + let psbt: Psbt = psbt_b64 + .parse() + .expect("create_dns_tx returned an invalid PSBT"); + assert!( + psbt.unsigned_tx + .output + .iter() + .any(|o| o.value.to_sat() == 20_000), + "expected the 20_000 sat recipient output" + ); + } + + // With neither `--to` nor `--to_dns`, the command errors. + #[cfg(feature = "dns_payment")] + #[test] + fn test_create_dns_tx_requires_recipient() { + let (cli, mut cmd_init, _env) = setup_online_wallet(); + cmd_init.assert().success(); + + let out = cli + .wallet_cmd(&["--wallet", WALLET_NAME, "create_dns_tx"]) + .output() + .unwrap(); + assert!(!out.status.success(), "should fail with no recipients"); + assert!( + String::from_utf8_lossy(&out.stderr).contains("Either --to or --to_dns"), + "expected recipient-required error, got: {}", + String::from_utf8_lossy(&out.stderr) + ); + } +} + +#[cfg(feature = "esplora")] +mod test_esplora { + use crate::common::BdkCli; + use bdk_testenv::{TestEnv, bitcoincore_rpc::RpcApi}; + use bdk_wallet::bitcoin::{Address, Amount, Network}; + use serde_json::Value; + use std::str::FromStr; + use std::time::Duration; + use tempfile::TempDir; + + static WALLET_NAME: &str = "test_esplora_wallet"; + + // config an esplora wallet, fund it, `full_scan`, and confirm the synced balance. + #[test] + fn test_esplora_full_scan_reflects_funding() { + let env = TestEnv::new().expect("start testenv"); + let esplora = env + .electrsd + .esplora_url + .clone() + .expect("esplora http endpoint (TestEnv sets http_enabled)"); + // esplora_url is a bind addr ("0.0.0.0:PORT"); connect via loopback. + let url = format!("http://{}", esplora.replace("0.0.0.0", "127.0.0.1")); + + let temp_dir = TempDir::new().unwrap(); + let cli = BdkCli::new("regtest", Some(temp_dir.path().to_path_buf())); + + let desc = cli.cmd("descriptor", &["--type", "tr"]).output().unwrap(); + let desc_value: Value = serde_json::from_slice(&desc.stdout).unwrap(); + let ext = desc_value["private_descriptors"]["external"] + .as_str() + .unwrap(); + let int = desc_value["private_descriptors"]["internal"] + .as_str() + .unwrap(); + cli.build_base_cmd() + .args(["wallet", "--wallet", WALLET_NAME, "config"]) + .args(["--ext-descriptor", ext, "--int-descriptor", int]) + .args(["--client-type", "esplora", "--database-type", "sqlite"]) + .args(["--url", &url]) + .assert() + .success(); + + let out = cli + .wallet_cmd(&["--wallet", WALLET_NAME, "new_address"]) + .output() + .unwrap(); + let address = Address::from_str( + serde_json::from_slice::(&out.stdout).unwrap()["address"] + .as_str() + .unwrap(), + ) + .unwrap() + .require_network(Network::Regtest) + .unwrap(); + + let node_addr = env + .rpc_client() + .get_new_address(None, None) + .unwrap() + .assume_checked(); + env.mine_blocks(101, Some(node_addr)).unwrap(); + env.wait_until_electrum_sees_block(Duration::from_secs(10)) + .unwrap(); + let txid = env.send(&address, Amount::from_btc(0.5).unwrap()).unwrap(); + env.wait_until_electrum_sees_txid(txid, Duration::from_secs(10)) + .unwrap(); + env.mine_blocks(3, None).unwrap(); + env.wait_until_electrum_sees_block(Duration::from_secs(10)) + .unwrap(); + + let scan = cli + .wallet_cmd(&["--wallet", WALLET_NAME, "full_scan"]) + .output() + .unwrap(); + assert!( + scan.status.success(), + "esplora full_scan failed: {}", + String::from_utf8_lossy(&scan.stderr) + ); + + let bal = cli + .wallet_cmd(&["--wallet", WALLET_NAME, "balance"]) + .output() + .unwrap(); + let bj: Value = serde_json::from_slice(&bal.stdout).unwrap(); + assert_eq!( + bj["confirmed"].as_u64(), + Some(50_000_000), + "esplora-synced confirmed balance mismatch: {bj}" + ); + } +}