diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 2405c2e7a..e4533f143 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -34,12 +34,15 @@ jobs: - name: test payjoin v1 integration run: cargo test --package payjoin --verbose --features=send,receive --test integration - name: test payjoin v2 integration - run: cargo test --package payjoin --verbose --features=send,receive,v2 --test integration + run: cargo test --package payjoin-defaults --verbose --features=danger-local-https,v2 --test integration - name: test payjoin-cli bin v1 run: cargo test --package payjoin-cli --verbose --features=danger-local-https - name: build payjoin-cli bin v2 if: matrix.rust != '1.63.0' run: cargo build --package payjoin-cli --verbose --features=v2 + - name: build payjoin-cli bin v2 with danger-local-https + if: matrix.rust != '1.63.0' + run: cargo build --package payjoin-cli --verbose --features=danger-local-https,v2 fmt: runs-on: ubuntu-latest diff --git a/Cargo.lock b/Cargo.lock index fb3648f2f..efe67b8ce 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -101,9 +101,9 @@ dependencies = [ [[package]] name = "aho-corasick" -version = "1.1.2" +version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2969dcb958b36655471fc61f7e416fa76033bdd4bfed0678d8fee1e2d07a1f0" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" dependencies = [ "memchr", ] @@ -116,13 +116,13 @@ checksum = "0952808a6c2afd1aa8947271f3a60f1a6763c7b912d210184c5149b5cf147247" [[package]] name = "async-trait" -version = "0.1.77" +version = "0.1.79" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c980ee35e870bd1a4d2c8294d4c04d0499e67bca1e4b5cefcc693c2fa00caea9" +checksum = "a507401cad91ec6a857ed5513a2073c82a9b9048762b885bb98655b306964681" dependencies = [ "proc-macro2", "quote", - "syn 2.0.52", + "syn 2.0.58", ] [[package]] @@ -138,15 +138,15 @@ dependencies = [ [[package]] name = "autocfg" -version = "1.1.0" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" +checksum = "f1fdabc7756949593fe60f30ec81974b613357de856987752631dea1e3394c80" [[package]] name = "backtrace" -version = "0.3.69" +version = "0.3.71" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2089b7e3f35b9dd2d0ed921ead4f6d318c27680d4a5bd167b3ee120edb105837" +checksum = "26b05800d2e817c8b3b4b54abd461726265fa9789ae34330622f2db9ee696f9d" dependencies = [ "addr2line", "cc", @@ -169,6 +169,12 @@ version = "0.21.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" +[[package]] +name = "base64" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9475866fec1451be56a3c2400fd081ff546538961565ccb5b7142cbd22bc7a51" + [[package]] name = "bech32" version = "0.9.1" @@ -287,9 +293,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.4.2" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed570934406eb16438a4e976b1b4500774099c13b8cb96eec99f620f05090ddf" +checksum = "cf4b9d6a944f767f8e5e0db018570623c85f3d925ac718db4e06d0187adb21c1" [[package]] name = "block-buffer" @@ -333,9 +339,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.5.0" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2bd12c1caf447e69cd4528f47f94d203fd2582878ecb9e9465484c4148a8223" +checksum = "514de17de45fdb8dc022b1a7975556c53c86f9f0aa5f534b98977b171857c2c9" [[package]] name = "bzip2" @@ -360,9 +366,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.0.90" +version = "1.0.91" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8cd6604a82acf3039f1144f54b8eb34e91ffba622051189e71b781822d5ee1f5" +checksum = "1fd97381a8cc6493395a5afc4c691c1084b3768db713b73aa215217aa245d153" [[package]] name = "cfg-if" @@ -743,9 +749,9 @@ dependencies = [ [[package]] name = "fastrand" -version = "2.0.1" +version = "2.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25cbce373ec4653f1a01a31e8a5e5ec0c622dc27ff9c4e6606eefef5cbbed4a5" +checksum = "658bd65b1cf4c852a3cc96f18a8ce7b5640f6b703f905c7d74532294c2a63984" [[package]] name = "filetime" @@ -840,7 +846,7 @@ checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac" dependencies = [ "proc-macro2", "quote", - "syn 2.0.52", + "syn 2.0.58", ] [[package]] @@ -885,9 +891,9 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.2.12" +version = "0.2.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "190092ea657667030ac6a35e305e62fc4dd69fd98ac98631e5d3a2b1575a12b5" +checksum = "a06fddc2749e0528d2813f95e050e87e52c8cbbae56223b9babf73b3e53b0cc6" dependencies = [ "cfg-if", "libc", @@ -922,9 +928,9 @@ checksum = "4271d37baee1b8c7e4b708028c57d816cf9d2434acb33a549475f78c181f6253" [[package]] name = "h2" -version = "0.3.24" +version = "0.3.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb2c4422095b67ee78da96fbb51a4cc413b3b25883c7717ff7ca1ab31022c9c9" +checksum = "81fe527a889e1532da5c525686d96d4c2e74cdd345badf8dfef9f6b39dd5f5e8" dependencies = [ "bytes", "fnv", @@ -1199,7 +1205,7 @@ dependencies = [ "http 0.2.12", "hyper 0.14.28", "log", - "rustls 0.22.2", + "rustls 0.22.3", "rustls-native-certs 0.7.0", "rustls-pki-types", "tokio", @@ -1217,7 +1223,7 @@ dependencies = [ "hyper 1.2.0", "hyper-util", "log", - "rustls 0.22.2", + "rustls 0.22.3", "rustls-native-certs 0.7.0", "rustls-pki-types", "tokio", @@ -1279,9 +1285,9 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.2.5" +version = "2.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b0b929d511467233429c45a44ac1dcaa21ba0f5ba11e4879e6ed28ddb4f9df4" +checksum = "168fb715dda47215e360912c096649d23d58bf392ac62f73919e831745e40f26" dependencies = [ "equivalent", "hashbrown 0.14.3", @@ -1309,9 +1315,9 @@ dependencies = [ [[package]] name = "itoa" -version = "1.0.10" +version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1a46d1a171d865aa5f83f92695765caa047a9b4cbae2cbf37dbd613a793fd4c" +checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" [[package]] name = "js-sys" @@ -1406,9 +1412,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.7.1" +version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "523dc4f511e55ab87b694dc30d0f820d60906ef06413f93d4d7a1385599cc149" +checksum = "6c8640c5d730cb13ebd907d8d04b52f55ac9a2eec55b440c8892f40d56c76c1d" [[package]] name = "minimal-lexical" @@ -1427,9 +1433,9 @@ dependencies = [ [[package]] name = "minreq" -version = "2.11.0" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb3371dfc7b772c540da1380123674a8e20583aca99907087d990ca58cf44203" +checksum = "00a000cf8bbbfb123a9bdc66b61c2885a4bb038df4f2629884caafabeb76b0f9" dependencies = [ "log", "once_cell", @@ -1554,7 +1560,7 @@ dependencies = [ "hyper-tungstenite", "hyper-util", "once_cell", - "rustls 0.22.2", + "rustls 0.22.3", "tokio", "tokio-tungstenite", "tokio-util", @@ -1574,7 +1580,7 @@ dependencies = [ "hyper-tungstenite", "hyper-util", "once_cell", - "rustls 0.22.2", + "rustls 0.22.3", "tokio", "tokio-tungstenite", "tokio-util", @@ -1668,7 +1674,7 @@ dependencies = [ "payjoin-directory", "rand", "rcgen", - "rustls 0.22.2", + "rustls 0.22.3", "serde", "serde_json", "testcontainers", @@ -1698,14 +1704,38 @@ dependencies = [ "log", "ohttp-relay 0.0.4", "payjoin", + "payjoin-defaults", "rcgen", - "rustls 0.22.2", + "rustls 0.22.3", "serde", "tokio", "ureq", "url", ] +[[package]] +name = "payjoin-defaults" +version = "0.0.1" +dependencies = [ + "bitcoin", + "bitcoind", + "http 1.1.0", + "log", + "ohttp-relay 0.0.7", + "once_cell", + "payjoin", + "payjoin-directory", + "rcgen", + "rustls 0.22.3", + "testcontainers", + "testcontainers-modules", + "tokio", + "tracing", + "tracing-subscriber", + "ureq", + "url", +] + [[package]] name = "payjoin-directory" version = "0.0.1" @@ -1748,9 +1778,9 @@ checksum = "3637c05577168127568a64e9dc5a6887da720efef07b3d9472d45f63ab191166" [[package]] name = "pest" -version = "2.7.8" +version = "2.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56f8023d0fb78c8e03784ea1c7f3fa36e68a723138990b8d5a47d916b651e7a8" +checksum = "311fb059dee1a7b802f036316d790138c613a4e8b180c822e3925a662e9f0c95" dependencies = [ "memchr", "thiserror", @@ -1759,9 +1789,9 @@ dependencies = [ [[package]] name = "pest_derive" -version = "2.7.8" +version = "2.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0d24f72393fd16ab6ac5738bc33cdb6a9aa73f8b902e8fe29cf4e67d7dd1026" +checksum = "f73541b156d32197eecda1a4014d7f868fd2bcb3c550d5386087cfba442bf69c" dependencies = [ "pest", "pest_generator", @@ -1769,22 +1799,22 @@ dependencies = [ [[package]] name = "pest_generator" -version = "2.7.8" +version = "2.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdc17e2a6c7d0a492f0158d7a4bd66cc17280308bbaff78d5bef566dca35ab80" +checksum = "c35eeed0a3fab112f75165fdc026b3913f4183133f19b49be773ac9ea966e8bd" dependencies = [ "pest", "pest_meta", "proc-macro2", "quote", - "syn 2.0.52", + "syn 2.0.58", ] [[package]] name = "pest_meta" -version = "2.7.8" +version = "2.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "934cd7631c050f4674352a6e835d5f6711ffbfb9345c2fc0107155ac495ae293" +checksum = "2adbf29bb9776f28caece835398781ab24435585fe0d4dc1374a61db5accedca" dependencies = [ "once_cell", "pest", @@ -1808,14 +1838,14 @@ checksum = "2f38a4412a78282e09a2cf38d195ea5420d15ba0602cb375210efbc877243965" dependencies = [ "proc-macro2", "quote", - "syn 2.0.52", + "syn 2.0.58", ] [[package]] name = "pin-project-lite" -version = "0.2.13" +version = "0.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8afb450f006bf6385ca15ef45d71d2288452bc3683ce2e2cacc0d18e4be60b58" +checksum = "bda66fc9667c18cb2758a2ac84d1167245054bcf85d5d1aaa6923f45801bdd02" [[package]] name = "pin-utils" @@ -2009,14 +2039,14 @@ dependencies = [ [[package]] name = "regex" -version = "1.10.3" +version = "1.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b62dbe01f0b06f9d8dc7d49e05a0785f153b00b2c227856282f671e0318c9b15" +checksum = "c117dbdfde9c8308975b6a18d71f3f385c89461f7b3fb054288ecf2a2058ba4c" dependencies = [ "aho-corasick", "memchr", "regex-automata 0.4.6", - "regex-syntax 0.8.2", + "regex-syntax 0.8.3", ] [[package]] @@ -2036,7 +2066,7 @@ checksum = "86b83b8b9847f9bf95ef68afb0b8e6cdb80f498442f5179a29fad448fcc1eaea" dependencies = [ "aho-corasick", "memchr", - "regex-syntax 0.8.2", + "regex-syntax 0.8.3", ] [[package]] @@ -2047,9 +2077,9 @@ checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" [[package]] name = "regex-syntax" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08c74e62047bb2de4ff487b251e4a92e24f48745648451635cec7d591162d9f" +checksum = "adad44e29e4c806119491a7f06f03de4d1af22c3a680dd47f1e6e179439d1f56" [[package]] name = "ring" @@ -2110,11 +2140,11 @@ checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76" [[package]] name = "rustix" -version = "0.38.31" +version = "0.38.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ea3e1a662af26cd7a3ba09c0297a31af215563ecf42817c98df621387f4e949" +checksum = "65e04861e65f21776e67888bfbea442b3642beaa0138fdb1dd7a84a52dffdb89" dependencies = [ - "bitflags 2.4.2", + "bitflags 2.5.0", "errno", "libc", "linux-raw-sys", @@ -2135,9 +2165,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.22.2" +version = "0.22.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e87c9956bd9807afa1f77e0f7594af32566e830e088a5576d27c5b6f30f49d41" +checksum = "99008d7ad0bbbea527ec27bddbc0e432c5b87d8175178cee68d2eec9c4a1813c" dependencies = [ "log", "ring 0.17.8", @@ -2166,7 +2196,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f1fb85efa936c42c6d5fc28d2629bb51e4b2f4b8a5211e297d599cc5a093792" dependencies = [ "openssl-probe", - "rustls-pemfile 2.1.1", + "rustls-pemfile 2.1.2", "rustls-pki-types", "schannel", "security-framework", @@ -2183,19 +2213,19 @@ dependencies = [ [[package]] name = "rustls-pemfile" -version = "2.1.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f48172685e6ff52a556baa527774f61fcaa884f59daf3375c62a3f1cd2549dab" +checksum = "29993a25686778eb88d4189742cd713c9bce943bc54251a33509dc63cbacf73d" dependencies = [ - "base64 0.21.7", + "base64 0.22.0", "rustls-pki-types", ] [[package]] name = "rustls-pki-types" -version = "1.3.1" +version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ede67b28608b4c60685c7d54122d4400d90f62b40caee7700e700380a390fa8" +checksum = "ecd36cc4259e3e4514335c4a138c6b43171a8d61d8f5c9348f9fc7529416f247" [[package]] name = "rustls-webpki" @@ -2272,9 +2302,9 @@ dependencies = [ [[package]] name = "security-framework" -version = "2.9.2" +version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05b64fb303737d99b81884b2c63433e9ae28abebe5eb5045dcdd175dc2ecf4de" +checksum = "770452e37cad93e0a50d5abc3990d2bc351c36d0328f86cefec2f2fb206eaef6" dependencies = [ "bitflags 1.3.2", "core-foundation", @@ -2285,9 +2315,9 @@ dependencies = [ [[package]] name = "security-framework-sys" -version = "2.9.1" +version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e932934257d3b408ed8f30db49d85ea163bfe74961f017f405b025af298f0c7a" +checksum = "41f3cc463c0ef97e11c3461a9d3787412d30e8e7eb907c79180c4a57bf7c04ef" dependencies = [ "core-foundation-sys", "libc", @@ -2310,14 +2340,14 @@ checksum = "7eb0b34b42edc17f6b7cac84a52a1c5f0e1bb2227e997ca9011ea3dd34e8610b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.52", + "syn 2.0.58", ] [[package]] name = "serde_json" -version = "1.0.114" +version = "1.0.115" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5f09b1bd632ef549eaa9f60a1f8de742bdbc698e6cee2095fc84dde5f549ae0" +checksum = "12dc5c46daa8e9fdf4f5e71b6cf9a53f2487da0e86e55808e2d35539666497dd" dependencies = [ "itoa", "ryu", @@ -2416,9 +2446,9 @@ dependencies = [ [[package]] name = "smallvec" -version = "1.13.1" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6ecd384b10a64542d77071bd64bd7b231f4ed5940fba55e98c3de13824cf3d7" +checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" [[package]] name = "socket2" @@ -2477,9 +2507,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.52" +version = "2.0.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b699d15b36d1f02c3e7c69f8ffef53de37aefae075d8488d4ba1a7788d574a07" +checksum = "44cfb93f38070beee36b3fef7d4f5a16f27751d94b187b666a5cc5e9b0d30687" dependencies = [ "proc-macro2", "quote", @@ -2561,7 +2591,7 @@ checksum = "c61f3ba182994efc43764a46c018c347bc492c79f024e705f46567b418f6d4f7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.52", + "syn 2.0.58", ] [[package]] @@ -2621,9 +2651,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.36.0" +version = "1.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61285f6515fa018fb2d1e46eb21223fff441ee8db5d0f1435e8ab4f5cdb80931" +checksum = "1adbebffeca75fcfd058afa480fb6c0b81e165a0323f9c9d39c9697e37c46787" dependencies = [ "backtrace", "bytes", @@ -2646,7 +2676,7 @@ checksum = "5b8a1e28f2deaa14e508979454cb3a223b10b938b45af148bc0986de36f1923b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.52", + "syn 2.0.58", ] [[package]] @@ -2665,7 +2695,7 @@ version = "0.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "775e0c0f0adb3a2f22a00c4745d728b479985fc15ee7ca6a2608388c5569860f" dependencies = [ - "rustls 0.22.2", + "rustls 0.22.3", "rustls-pki-types", "tokio", ] @@ -2753,7 +2783,7 @@ checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.52", + "syn 2.0.58", ] [[package]] @@ -2895,7 +2925,7 @@ dependencies = [ "flate2", "log", "once_cell", - "rustls 0.22.2", + "rustls 0.22.3", "rustls-native-certs 0.7.0", "rustls-pki-types", "rustls-webpki 0.102.2", @@ -2975,7 +3005,7 @@ dependencies = [ "once_cell", "proc-macro2", "quote", - "syn 2.0.52", + "syn 2.0.58", "wasm-bindgen-shared", ] @@ -2997,7 +3027,7 @@ checksum = "e94f17b526d0a461a191c78ea52bbce64071ed5c04c9ffe424dcb38f74171bb7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.52", + "syn 2.0.58", "wasm-bindgen-backend", "wasm-bindgen-shared", ] @@ -3265,7 +3295,7 @@ checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" dependencies = [ "proc-macro2", "quote", - "syn 2.0.52", + "syn 2.0.58", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index b383631d5..2adf10413 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] -members = ["payjoin", "payjoin-cli", "payjoin-directory"] +members = ["payjoin", "payjoin-cli", "payjoin-directory", "payjoin-defaults"] resolver = "2" [patch.crates-io.payjoin] -path = "payjoin" \ No newline at end of file +path = "payjoin" diff --git a/payjoin-cli/Cargo.toml b/payjoin-cli/Cargo.toml index cb17987a3..6c1fdd28e 100644 --- a/payjoin-cli/Cargo.toml +++ b/payjoin-cli/Cargo.toml @@ -19,8 +19,8 @@ path = "src/main.rs" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [features] native-certs = ["ureq/native-certs"] -danger-local-https = ["rcgen", "rustls", "hyper-rustls"] -v2 = ["payjoin/v2"] +danger-local-https = ["rcgen", "rustls", "hyper-rustls", "payjoin-defaults/danger-local-https"] +v2 = ["payjoin/v2", "payjoin-defaults/v2"] [dependencies] anyhow = "1.0.70" @@ -34,6 +34,7 @@ hyper = { version = "0.14", features = ["full"] } hyper-rustls = { version = "0.25", optional = true } log = "0.4.7" payjoin = { version = "0.15.0", features = ["send", "receive", "base64"] } +payjoin-defaults = { path = "../payjoin-defaults" } rcgen = { version = "0.11.1", optional = true } rustls = { version = "0.22.2", optional = true } serde = { version = "1.0.160", features = ["derive"] } diff --git a/payjoin-cli/src/app/v2.rs b/payjoin-cli/src/app/v2.rs index 3d7b5b313..63175f98a 100644 --- a/payjoin-cli/src/app/v2.rs +++ b/payjoin-cli/src/app/v2.rs @@ -10,7 +10,6 @@ use payjoin::bitcoin::Amount; use payjoin::{base64, bitcoin, Error, PjUriBuilder}; use tokio::sync::Mutex as AsyncMutex; use tokio::task::spawn_blocking; -use url::Url; use super::config::AppConfig; use super::{App as AppTrait, SeenInputs}; @@ -310,53 +309,27 @@ async fn unwrap_ohttp_keys_or_else_fetch(config: &AppConfig) -> Result Result { - use anyhow::ensure; - - let proxy = proxy.clone(); - let ohttp_keys_url = pj_endpoint.join("/ohttp-keys")?; - let res = spawn_blocking(move || { - http_proxy(&proxy)?.get(ohttp_keys_url.as_str()).call().map_err(map_ureq_err) - }) - .await??; - - ensure!(res.status() == 200, "Failed to connect to target {}", res.status()); - let mut body = Vec::new(); - let _ = res.into_reader().read_to_end(&mut body)?; - Ok(payjoin::OhttpKeys::decode(&body)?) -} - -/// Normalize the Url to include the port for ureq. ureq has a bug -/// which makes Proxy::new(...) use port 8080 for all input with scheme -/// http regardless of the port included in the Url. This prevents that. -/// https://github.com/algesten/ureq/pull/717 -fn normalize_proxy_url(proxy: &Url) -> Result { - let scheme = proxy.scheme(); - let host = proxy.host_str().ok_or(anyhow!("Failed to parse host"))?; - - if scheme == "http" || scheme == "https" { - Ok(format!("{}:{}", host, proxy.port().unwrap_or(80))) - } else { - Ok(proxy.as_str().to_string()) - } -} - -#[cfg(feature = "danger-local-https")] -fn http_proxy(proxy: &Url) -> Result { - let proxy = ureq::Proxy::new(normalize_proxy_url(proxy)?)?; - Ok(super::http_agent_builder()?.proxy(proxy).build()) -} - -#[cfg(not(feature = "danger-local-https"))] -fn http_proxy(proxy: &Url) -> Result { - let proxy = ureq::Proxy::new(normalize_proxy_url(proxy)?)?; - Ok(ureq::AgentBuilder::new().proxy(proxy).build()) -} - fn map_ureq_err(e: ureq::Error) -> anyhow::Error { let e_string = e.to_string(); match e.into_response() { @@ -446,34 +419,3 @@ impl ReceiveStore { Ok(()) } } - -#[cfg(test)] -#[cfg(not(feature = "danger-local-https"))] -mod test { - use http::uri::Uri; - - use super::*; - - /// This test depends on the production payjo.in server being live. - /// It is an integration test that should be moved once a payjoin-io - /// crate exists - #[tokio::test] - async fn test_fetch_ohttp_keys() { - let relay_port = find_free_port(); - let relay_url = Url::parse(&format!("http://0.0.0.0:{}", relay_port)).unwrap(); - let pj_endpoint = Url::parse("https://payjo.in:443").unwrap(); - tokio::select! { - _ = ohttp_relay::listen_tcp(relay_port, Uri::from_static("payjo.in:443")) => { - assert!(false, "Relay is long running"); - } - res = fetch_ohttp_keys(&relay_url, &pj_endpoint) => { - assert!(res.is_ok()); - } - } - } - - fn find_free_port() -> u16 { - let listener = std::net::TcpListener::bind("0.0.0.0:0").unwrap(); - listener.local_addr().unwrap().port() - } -} diff --git a/payjoin-defaults/Cargo.toml b/payjoin-defaults/Cargo.toml new file mode 100644 index 000000000..18e4bb835 --- /dev/null +++ b/payjoin-defaults/Cargo.toml @@ -0,0 +1,37 @@ +[package] +authors = ["jbesraa "] +description = "IO utilities for Payjoin" +edition = "2021" +keywords = ["bip77", "payjoin", "bitcoin", "networking"] +license = "MITNFA" +name = "payjoin-defaults" +readme = "README.md" +repository = "https://github.com/payjoin/rust-payjoin" +resolver = "2" +version = "0.0.1" + +[features] +danger-local-https = ["rustls"] +v2 = ["payjoin/v2"] + +[dependencies] +payjoin = { version = "0.15.0", features = ["send", "receive"] } +rustls = { version = "0.22.2", optional = true } +ureq = "2.9.4" + +[dev-dependencies] +bitcoin = { version = "0.30.0", features = ["base64"] } +bitcoind = { version = "0.31.1", features = ["0_21_2"] } +http = "1" +log = { version = "0.4.14"} +ohttp-relay = "0.0.7" +once_cell = "1" +payjoin-directory = { path = "../payjoin-directory", features = ["danger-local-https"] } +rcgen = { version = "0.11" } +rustls = "0.22.2" +testcontainers = "0.15.0" +testcontainers-modules = { version = "0.1.3", features = ["redis"] } +tokio = { version = "1.12.0", features = ["full"] } +tracing = "0.1.40" +tracing-subscriber = { version = "0.3.17", features = ["env-filter"] } +url = "2.2.2" diff --git a/payjoin-defaults/README.md b/payjoin-defaults/README.md new file mode 100644 index 000000000..c4b83d2eb --- /dev/null +++ b/payjoin-defaults/README.md @@ -0,0 +1,9 @@ +# payjoin-defaults + +This provides a collection of I/O utilities for working with the +payjoin crate. The payjoin crate only deals with the low-level +protocol. payjoin-defaults provides sane defaults to make implementing +payjoin easy. + +To learn more about payjoin, refer to [Payjoin Website](https://payjoin.org/). + diff --git a/payjoin-defaults/src/lib.rs b/payjoin-defaults/src/lib.rs new file mode 100644 index 000000000..b632be1c9 --- /dev/null +++ b/payjoin-defaults/src/lib.rs @@ -0,0 +1,149 @@ +use payjoin::Url; + +/// Fetch the ohttp keys from the specified payjoin directory via proxy. +/// +/// * `ohttp_relay`: The http CONNNECT method proxy to request the ohttp keys from a payjoin +/// directory. Proxying requests for ohttp keys ensures a client IP address is never revealed to +/// the payjoin directory. +/// +/// * `payjoin_directory`: The payjoin directory from which to fetch the ohttp keys. This +/// directory stores and forwards payjoin client payloads. +/// +/// * `cert_der` (optional): The DER-encoded certificate to use for local HTTPS connections. This +/// parameter is only available when the "danger-local-https" feature is enabled. +#[cfg(feature = "v2")] +pub fn fetch_ohttp_keys( + ohttp_relay: Url, + payjoin_directory: Url, + #[cfg(feature = "danger-local-https")] cert_der: Vec, +) -> Result { + let ohttp_keys_url = payjoin_directory.join("/ohttp-keys")?; + let proxy = PayjoinProxy::new( + &ohttp_relay, + #[cfg(feature = "danger-local-https")] + cert_der, + )?; + let res = proxy.get(ohttp_keys_url.as_str()).call()?; + let mut body = Vec::new(); + let _ = res.into_reader().read_to_end(&mut body)?; + payjoin::OhttpKeys::decode(&body) + .map_err(|e| Error(InternalError::InvalidOhttpKeys(e.to_string()))) +} + +#[derive(Debug)] +pub struct Error(InternalError); + +#[derive(Debug)] +enum InternalError { + ParseUrl(payjoin::ParseError), + Ureq(ureq::Error), + Io(std::io::Error), + #[cfg(feature = "danger-local-https")] + Rustls(rustls::Error), + #[cfg(feature = "v2")] + InvalidOhttpKeys(String), +} + +macro_rules! impl_from_error { + ($from:ty, $to:ident) => { + impl From<$from> for Error { + fn from(value: $from) -> Self { Self(InternalError::$to(value)) } + } + }; +} + +impl_from_error!(payjoin::ParseError, ParseUrl); +impl_from_error!(ureq::Error, Ureq); +impl_from_error!(std::io::Error, Io); +#[cfg(feature = "danger-local-https")] +impl_from_error!(rustls::Error, Rustls); + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + use InternalError::*; + + match &self.0 { + ParseUrl(e) => e.fmt(f), + Ureq(e) => e.fmt(f), + Io(e) => e.fmt(f), + #[cfg(feature = "v2")] + InvalidOhttpKeys(e) => { + write!(f, "Invalid ohttp keys returned from payjoin directory: {}", e) + } + #[cfg(feature = "danger-local-https")] + Rustls(e) => e.fmt(f), + } + } +} + +impl std::error::Error for Error { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + use InternalError::*; + + match &self.0 { + ParseUrl(e) => Some(e), + Ureq(e) => Some(e), + Io(e) => Some(e), + #[cfg(feature = "v2")] + InvalidOhttpKeys(_) => None, + #[cfg(feature = "danger-local-https")] + Rustls(e) => Some(e), + } + } +} + +impl From for Error { + fn from(value: InternalError) -> Self { Self(value) } +} + +struct PayjoinProxy { + client: ureq::Agent, +} + +impl PayjoinProxy { + fn new( + proxy: &Url, + #[cfg(feature = "danger-local-https")] cert_der: Vec, + ) -> Result { + let proxy = ureq::Proxy::new(Self::normalize_proxy_url(proxy)?)?; + #[cfg(feature = "danger-local-https")] + let client = Self::http_agent_builder(cert_der)?.proxy(proxy).build(); + #[cfg(not(feature = "danger-local-https"))] + let client = ureq::AgentBuilder::new().proxy(proxy).build(); + + Ok(Self { client }) + } + + fn get(&self, url: &str) -> ureq::Request { self.client.get(url) } + + // Normalize the Url to include the port for ureq. ureq has a bug + // which makes Proxy::new(...) use port 8080 for all input with scheme + // http regardless of the port included in the Url. This prevents that. + // https://github.com/algesten/ureq/pull/717 + fn normalize_proxy_url(proxy: &Url) -> Result { + let host = match proxy.host_str() { + Some(host) => host, + None => return Err(Error(InternalError::ParseUrl(payjoin::ParseError::EmptyHost))), + }; + match proxy.scheme() { + "http" | "https" => Ok(format!("{}:{}", host, proxy.port().unwrap_or(80))), + _ => Ok(proxy.as_str().to_string()), + } + } + + #[cfg(feature = "danger-local-https")] + fn http_agent_builder(cert_der: Vec) -> Result { + use std::sync::Arc; + + use rustls::client::ClientConfig; + use rustls::pki_types::CertificateDer; + use rustls::RootCertStore; + use ureq::AgentBuilder; + + let mut root_cert_store = RootCertStore::empty(); + root_cert_store.add(CertificateDer::from(cert_der.as_slice()))?; + let client_config = + ClientConfig::builder().with_root_certificates(root_cert_store).with_no_client_auth(); + Ok(AgentBuilder::new().tls_config(Arc::new(client_config))) + } +} diff --git a/payjoin-defaults/tests/integration.rs b/payjoin-defaults/tests/integration.rs new file mode 100644 index 000000000..e56358415 --- /dev/null +++ b/payjoin-defaults/tests/integration.rs @@ -0,0 +1,605 @@ +#[cfg(feature = "danger-local-https")] +#[cfg(feature = "v2")] +mod v2 { + use std::collections::HashMap; + use std::env; + use std::str::FromStr; + use std::sync::Arc; + use std::time::Duration; + + use bitcoin::address::NetworkChecked; + use bitcoin::psbt::Psbt; + use bitcoin::{base64, Amount, FeeRate, OutPoint}; + use bitcoind::bitcoincore_rpc::core_rpc_json::{AddressType, WalletProcessPsbtResult}; + use bitcoind::bitcoincore_rpc::{self, RpcApi}; + use log::{log_enabled, Level}; + use once_cell::sync::{Lazy, OnceCell}; + use payjoin::receive::v2::{Enrolled, Enroller, PayjoinProposal, UncheckedProposal}; + use payjoin::send::RequestBuilder; + use payjoin::{OhttpKeys, PjUriBuilder, Request, Uri}; + use testcontainers_modules::redis::Redis; + use testcontainers_modules::testcontainers::clients::Cli; + use tokio::task::spawn_blocking; + use tracing_subscriber::{EnvFilter, FmtSubscriber}; + use ureq::{Agent, AgentBuilder, Error, Response}; + use url::Url; + + static TESTS_TIMEOUT: Lazy = Lazy::new(|| Duration::from_secs(20)); + static WAIT_SERVICE_INTERVAL: Lazy = Lazy::new(|| Duration::from_secs(3)); + static INIT_TRACING: OnceCell<()> = OnceCell::new(); + + type BoxError = Box; + + #[tokio::test] + async fn test_bad_ohttp_keys() { + let bad_ohttp_keys = OhttpKeys::decode( + &base64::decode_config( + "AQAg3WpRjS0aqAxQUoLvpas2VYjT2oIg6-3XSiB-QiYI1BAABAABAAM", + base64::URL_SAFE, + ) + .expect("invalid base64"), + ) + .expect("Invalid OhttpKeys"); + + std::env::set_var("RUST_LOG", "debug"); + let (cert, key) = local_cert_key(); + let port = find_free_port(); + let directory = Url::parse(&format!("https://localhost:{}", port)).unwrap(); + tokio::select!( + _ = init_directory(port, (cert.clone(), key)) => assert!(false, "Directory server is long running"), + res = enroll_with_bad_keys(directory, bad_ohttp_keys, cert) => { + assert_eq!( + res.unwrap_err().into_response().unwrap().content_type(), + "application/problem+json" + ); + } + ); + + async fn enroll_with_bad_keys( + directory: Url, + bad_ohttp_keys: OhttpKeys, + cert_der: Vec, + ) -> Result { + let agent = Arc::new(http_agent(cert_der.clone()).unwrap()); + wait_for_service_ready(directory.clone(), agent.clone()).await.unwrap(); + let mock_ohttp_relay = directory.clone(); // pass through to directory + let mut bad_enroller = + Enroller::from_directory_config(directory, bad_ohttp_keys, mock_ohttp_relay); + let (req, _ctx) = bad_enroller.extract_req().expect("Failed to extract request"); + spawn_blocking(move || agent.post(req.url.as_str()).send_bytes(&req.body)) + .await + .expect("Failed to send request") + } + } + + #[tokio::test] + async fn v2_to_v2() { + std::env::set_var("RUST_LOG", "debug"); + init_tracing(); + let (cert, key) = local_cert_key(); + let ohttp_relay_port = find_free_port(); + let ohttp_relay = Url::parse(&format!("http://localhost:{}", ohttp_relay_port)).unwrap(); + let directory_port = find_free_port(); + let directory = Url::parse(&format!("https://localhost:{}", directory_port)).unwrap(); + let gateway_origin = http::Uri::from_str(directory.as_str()).unwrap(); + tokio::select!( + _ = ohttp_relay::listen_tcp(ohttp_relay_port, gateway_origin) => assert!(false, "Ohttp relay is long running"), + _ = init_directory(directory_port, (cert.clone(), key)) => assert!(false, "Directory server is long running"), + res = do_v2_send_receive(ohttp_relay, directory, cert) => assert!(res.is_ok()) + ); + + async fn do_v2_send_receive( + ohttp_relay: Url, + directory: Url, + cert_der: Vec, + ) -> Result<(), BoxError> { + let (_bitcoind, sender, receiver) = init_bitcoind_sender_receiver()?; + let agent = Arc::new(http_agent(cert_der.clone())?); + wait_for_service_ready(ohttp_relay.clone(), agent.clone()).await.unwrap(); + wait_for_service_ready(directory.clone(), agent.clone()).await.unwrap(); + let ohttp_keys = + fetch_ohttp_keys(ohttp_relay, directory.clone(), cert_der.clone()).await?; + + // ********************** + // Inside the Receiver: + let mut enrolled = + enroll_with_directory(directory.clone(), ohttp_keys.clone(), cert_der).await?; + println!("enrolled: {:#?}", &enrolled); + let pj_uri_string = + create_receiver_pj_uri_string(&receiver, ohttp_keys, &enrolled.fallback_target())?; + + // ********************** + // Inside the Sender: + // Create a funded PSBT (not broadcasted) to address with amount given in the pj_uri + let pj_uri = Uri::from_str(&pj_uri_string).unwrap().assume_checked(); + let psbt = build_original_psbt(&sender, &pj_uri)?; + // debug!("Original psbt: {:#?}", psbt); + let (send_req, send_ctx) = RequestBuilder::from_psbt_and_uri(psbt, pj_uri)? + .build_with_additional_fee(Amount::from_sat(10000), None, FeeRate::ZERO, false)? + .extract_v2(directory.to_owned())?; // Mock since we're not + // log::info!("send fallback v2"); + // log::debug!("Request: {:#?}", &send_req.body); + let response = { + let Request { url, body, .. } = send_req.clone(); + let agent_clone = agent.clone(); + spawn_blocking(move || { + agent_clone + .post(url.as_str()) + .set("Content-Type", payjoin::V1_REQ_CONTENT_TYPE) + .send_bytes(&body) + }) + .await?? + }; + log::info!("Response: {:#?}", &response); + assert!(is_success(response.status())); + // no response body yet since we are async and pushed fallback_psbt to the buffer + + // ********************** + // Inside the Receiver: + + // GET fallback psbt + let (req, ctx) = enrolled.extract_req()?; + let agent_clone = agent.clone(); + let response = + spawn_blocking(move || agent_clone.post(req.url.as_str()).send_bytes(&req.body)) + .await??; + + // POST payjoin + let proposal = enrolled.process_res(response.into_reader(), ctx)?.unwrap(); + let mut payjoin_proposal = handle_directory_proposal(receiver, proposal); + let (req, ctx) = payjoin_proposal.extract_v2_req()?; + let agent_clone = agent.clone(); + let response = + spawn_blocking(move || agent_clone.post(req.url.as_str()).send_bytes(&req.body)) + .await??; + let mut res = Vec::new(); + response.into_reader().read_to_end(&mut res)?; + let _response = payjoin_proposal.deserialize_res(res, ctx)?; + // response should be 204 http + + // ********************** + // Inside the Sender: + // Sender checks, signs, finalizes, extracts, and broadcasts + + // Replay post fallback to get the response + let agent_clone = agent.clone(); + let response = spawn_blocking(move || { + agent_clone.post(send_req.url.as_str()).send_bytes(&send_req.body) + }) + .await??; + let checked_payjoin_proposal_psbt = + send_ctx.process_response(&mut response.into_reader())?.unwrap(); + let payjoin_tx = extract_pj_tx(&sender, checked_payjoin_proposal_psbt)?; + sender.send_raw_transaction(&payjoin_tx)?; + log::info!("sent"); + Ok(()) + } + } + + #[tokio::test] + #[cfg(feature = "v2")] + async fn v1_to_v2() { + std::env::set_var("RUST_LOG", "debug"); + init_tracing(); + let (cert, key) = local_cert_key(); + let ohttp_relay_port = find_free_port(); + let ohttp_relay = Url::parse(&format!("http://localhost:{}", ohttp_relay_port)).unwrap(); + let directory_port = find_free_port(); + let directory = Url::parse(&format!("https://localhost:{}", directory_port)).unwrap(); + let gateway_origin = http::Uri::from_str(directory.as_str()).unwrap(); + tokio::select!( + _ = ohttp_relay::listen_tcp(ohttp_relay_port, gateway_origin) => assert!(false, "Ohttp relay is long running"), + _ = init_directory(directory_port, (cert.clone(), key)) => assert!(false, "Directory server is long running"), + res = do_v1_to_v2(ohttp_relay, directory, cert) => assert!(res.is_ok()), + ); + + async fn do_v1_to_v2( + ohttp_relay: Url, + directory: Url, + cert_der: Vec, + ) -> Result<(), BoxError> { + let (_bitcoind, sender, receiver) = init_bitcoind_sender_receiver()?; + let agent: Arc = Arc::new(http_agent(cert_der.clone())?); + wait_for_service_ready(ohttp_relay.clone(), agent.clone()).await.unwrap(); + wait_for_service_ready(directory.clone(), agent.clone()).await.unwrap(); + let ohttp_keys = + fetch_ohttp_keys(ohttp_relay, directory.clone(), cert_der.clone()).await?; + + let mut enrolled = + enroll_with_directory(directory, ohttp_keys.clone(), cert_der.clone()).await?; + + let pj_uri_string = + create_receiver_pj_uri_string(&receiver, ohttp_keys, &enrolled.fallback_target())?; + + // ********************** + // Inside the V1 Sender: + // Create a funded PSBT (not broadcasted) to address with amount given in the pj_uri + let pj_uri = Uri::from_str(&pj_uri_string).unwrap().assume_checked(); + let psbt = build_original_psbt(&sender, &pj_uri)?; + // debug!("Original psbt: {:#?}", psbt); + let (send_req, send_ctx) = RequestBuilder::from_psbt_and_uri(psbt, pj_uri)? + .build_with_additional_fee(Amount::from_sat(10000), None, FeeRate::ZERO, false)? + .extract_v1()?; + log::info!("send fallback v1 to offline receiver fail"); + let res = { + let Request { url, body, .. } = send_req.clone(); + let agent_clone = agent.clone(); + spawn_blocking(move || { + agent_clone + .post(url.as_str()) + .set("Content-Type", payjoin::V1_REQ_CONTENT_TYPE) + .send_bytes(&body) + }) + .await? + }; + match res { + Err(ureq::Error::Status(code, _)) => assert_eq!(code, 503), + _ => panic!("Expected response status code 503, found {:?}", res), + } + + // ********************** + // Inside the Receiver: + let agent_clone = agent.clone(); + let receiver_loop = tokio::task::spawn(async move { + let (response, ctx) = loop { + let (req, ctx) = enrolled.extract_req().unwrap(); + let agent_clone = agent_clone.clone(); + let response = spawn_blocking(move || { + agent_clone.post(req.url.as_str()).send_bytes(&req.body) + }) + .await??; + + if response.status() == 200 { + // debug!("GET'd fallback_psbt"); + break (response.into_reader(), ctx); + } else if response.status() == 202 { + log::info!( + "No response yet for POST payjoin request, retrying some seconds" + ); + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + } else { + log::error!("Unexpected response status: {}", response.status()); + panic!("Unexpected response status: {}", response.status()) + } + }; + // debug!("handle directory response"); + let proposal = enrolled.process_res(response, ctx).unwrap().unwrap(); + let mut payjoin_proposal = handle_directory_proposal(receiver, proposal); + // Respond with payjoin psbt within the time window the sender is willing to wait + // this response would be returned as http response to the sender + let (req, ctx) = payjoin_proposal.extract_v2_req().unwrap(); + let response = spawn_blocking(move || { + agent_clone.post(req.url.as_str()).send_bytes(&req.body) + }) + .await??; + let mut res = Vec::new(); + response.into_reader().read_to_end(&mut res)?; + let _response = payjoin_proposal.deserialize_res(res, ctx).unwrap(); + // debug!("Post payjoin_psbt to directory"); + // assert!(_response.status() == 204); + Ok::<_, Box>(()) + }); + + // ********************** + // send fallback v1 to online receiver + log::info!("send fallback v1 to online receiver should succeed"); + let response = { + let Request { url, body, .. } = send_req.clone(); + let agent_clone = agent.clone(); + spawn_blocking(move || { + agent_clone + .post(url.as_str()) + .set("Content-Type", payjoin::V1_REQ_CONTENT_TYPE) + .send_bytes(&body) + .expect("Failed to send request") + }) + .await? + }; + log::info!("Response: {:#?}", &response); + assert!(is_success(response.status())); + + let checked_payjoin_proposal_psbt = + send_ctx.process_response(&mut response.into_reader())?; + let payjoin_tx = extract_pj_tx(&sender, checked_payjoin_proposal_psbt)?; + sender.send_raw_transaction(&payjoin_tx)?; + log::info!("sent"); + assert!(receiver_loop.await.is_ok(), "The spawned task panicked or returned an error"); + Ok(()) + } + } + + async fn init_directory(port: u16, local_cert_key: (Vec, Vec)) -> Result<(), BoxError> { + let docker: Cli = Cli::default(); + let timeout = Duration::from_secs(2); + let db = docker.run(Redis::default()); + let db_host = format!("127.0.0.1:{}", db.get_host_port_ipv4(6379)); + println!("Database running on {}", db.get_host_port_ipv4(6379)); + payjoin_directory::listen_tcp_with_tls(port, db_host, timeout, local_cert_key).await + } + + // generates or gets a DER encoded localhost cert and key. + fn local_cert_key() -> (Vec, Vec) { + let cert = rcgen::generate_simple_self_signed(vec![ + "0.0.0.0".to_string(), + "localhost".to_string(), + ]) + .expect("Failed to generate cert"); + let cert_der = cert.serialize_der().expect("Failed to serialize cert"); + let key_der = cert.serialize_private_key_der(); + (cert_der, key_der) + } + + async fn fetch_ohttp_keys( + ohttp_relay: Url, + directory: Url, + cert_der: Vec, + ) -> Result { + let res = tokio::task::spawn_blocking(move || { + payjoin_defaults::fetch_ohttp_keys(ohttp_relay.clone(), directory.clone(), cert_der) + }) + .await + .map_err(|e| e.to_string())? + .map_err(|e| e.to_string())?; + Ok(res) + } + + async fn enroll_with_directory( + directory: Url, + ohttp_keys: OhttpKeys, + cert_der: Vec, + ) -> Result { + let mock_ohttp_relay = directory.clone(); // pass through to directory + let mut enroller = Enroller::from_directory_config( + directory.clone(), + ohttp_keys, + mock_ohttp_relay.clone(), + ); + let (req, ctx) = enroller.extract_req()?; + println!("enroll req: {:#?}", &req); + let res = spawn_blocking(move || { + http_agent(cert_der).unwrap().post(req.url.as_str()).send_bytes(&req.body) + }) + .await??; + assert!(is_success(res.status())); + Ok(enroller.process_res(res.into_reader(), ctx)?) + } + + /// The receiver outputs a string to be passed to the sender as a string or QR code + fn create_receiver_pj_uri_string( + receiver: &bitcoincore_rpc::Client, + ohttp_keys: OhttpKeys, + fallback_target: &str, + ) -> Result { + let pj_receiver_address = receiver.get_new_address(None, None)?.assume_checked(); + Ok(PjUriBuilder::new( + pj_receiver_address, + Url::parse(&fallback_target).unwrap(), + Some(ohttp_keys), + ) + .amount(Amount::ONE_BTC) + .build() + .to_string()) + } + + fn handle_directory_proposal( + receiver: bitcoincore_rpc::Client, + proposal: UncheckedProposal, + ) -> PayjoinProposal { + // in a payment processor where the sender could go offline, this is where you schedule to broadcast the original_tx + let _to_broadcast_in_failure_case = proposal.extract_tx_to_schedule_broadcast(); + + // Receive Check 1: Can Broadcast + let proposal = proposal + .check_broadcast_suitability(None, |tx| { + Ok(receiver + .test_mempool_accept(&[bitcoin::consensus::encode::serialize_hex(&tx)]) + .unwrap() + .first() + .unwrap() + .allowed) + }) + .expect("Payjoin proposal should be broadcastable"); + + // Receive Check 2: receiver can't sign for proposal inputs + let proposal = proposal + .check_inputs_not_owned(|input| { + let address = + bitcoin::Address::from_script(&input, bitcoin::Network::Regtest).unwrap(); + Ok(receiver.get_address_info(&address).unwrap().is_mine.unwrap()) + }) + .expect("Receiver should not own any of the inputs"); + + // Receive Check 3: receiver can't sign for proposal inputs + let proposal = proposal.check_no_mixed_input_scripts().unwrap(); + + // Receive Check 4: have we seen this input before? More of a check for non-interactive i.e. payment processor receivers. + let mut payjoin = proposal + .check_no_inputs_seen_before(|_| Ok(false)) + .unwrap() + .identify_receiver_outputs(|output_script| { + let address = + bitcoin::Address::from_script(&output_script, bitcoin::Network::Regtest) + .unwrap(); + Ok(receiver.get_address_info(&address).unwrap().is_mine.unwrap()) + }) + .expect("Receiver should have at least one output"); + + // Select receiver payjoin inputs. TODO Lock them. + let available_inputs = receiver.list_unspent(None, None, None, None, None).unwrap(); + let candidate_inputs: HashMap = available_inputs + .iter() + .map(|i| (i.amount, OutPoint { txid: i.txid, vout: i.vout })) + .collect(); + + let selected_outpoint = payjoin.try_preserving_privacy(candidate_inputs).expect("gg"); + let selected_utxo = available_inputs + .iter() + .find(|i| i.txid == selected_outpoint.txid && i.vout == selected_outpoint.vout) + .unwrap(); + + // calculate receiver payjoin outputs given receiver payjoin inputs and original_psbt, + let txo_to_contribute = bitcoin::TxOut { + value: selected_utxo.amount.to_sat(), + script_pubkey: selected_utxo.script_pub_key.clone(), + }; + let outpoint_to_contribute = + bitcoin::OutPoint { txid: selected_utxo.txid, vout: selected_utxo.vout }; + payjoin.contribute_witness_input(txo_to_contribute, outpoint_to_contribute); + + let receiver_substitute_address = + receiver.get_new_address(None, None).unwrap().assume_checked(); + payjoin.substitute_output_address(receiver_substitute_address); + let payjoin_proposal = payjoin + .finalize_proposal( + |psbt: &Psbt| { + Ok(receiver + .wallet_process_psbt( + &bitcoin::base64::encode(psbt.serialize()), + None, + None, + Some(false), + ) + .map(|res: WalletProcessPsbtResult| { + let psbt = Psbt::from_str(&res.psbt).unwrap(); + return psbt; + }) + .unwrap()) + }, + Some(bitcoin::FeeRate::MIN), + ) + .unwrap(); + // debug!("Receiver's Payjoin proposal PSBT: {:#?}", &payjoin_proposal.psbt()); + payjoin_proposal + } + + fn http_agent(cert_der: Vec) -> Result { + Ok(http_agent_builder(cert_der)?.build()) + } + + fn http_agent_builder(cert_der: Vec) -> Result { + use rustls::client::ClientConfig; + use rustls::pki_types::CertificateDer; + use rustls::RootCertStore; + + let mut root_cert_store = RootCertStore::empty(); + root_cert_store.add(CertificateDer::from(cert_der.as_slice()))?; + let client_config = + ClientConfig::builder().with_root_certificates(root_cert_store).with_no_client_auth(); + + Ok(AgentBuilder::new().tls_config(Arc::new(client_config))) + } + + fn is_success(status: u16) -> bool { status >= 200 && status < 300 } + + fn find_free_port() -> u16 { + let listener = std::net::TcpListener::bind("0.0.0.0:0").unwrap(); + listener.local_addr().unwrap().port() + } + + async fn wait_for_service_ready( + service_url: Url, + agent: Arc, + ) -> Result<(), &'static str> { + let health_url = service_url.join("/health").map_err(|_| "Invalid URL")?; + let res = spawn_blocking(move || { + let start = std::time::Instant::now(); + + while start.elapsed() < *TESTS_TIMEOUT { + let request_result = agent.get(health_url.as_str()).call(); + + match request_result { + Ok(response) if response.status() == 200 => return Ok(()), + Err(Error::Status(404, _)) => return Err("Endpoint not found"), + _ => std::thread::sleep(*WAIT_SERVICE_INTERVAL), + } + } + + Err("Timeout waiting for service to be ready") + }) + .await + .map_err(|_| "JoinError")?; + res + } + + fn init_tracing() { + INIT_TRACING.get_or_init(|| { + let subscriber = FmtSubscriber::builder() + .with_env_filter(EnvFilter::from_default_env()) + .with_test_writer() + .finish(); + + tracing::subscriber::set_global_default(subscriber) + .expect("failed to set global default subscriber"); + }); + } + + fn init_bitcoind_sender_receiver( + ) -> Result<(bitcoind::BitcoinD, bitcoincore_rpc::Client, bitcoincore_rpc::Client), BoxError> + { + let bitcoind_exe = + env::var("BITCOIND_EXE").ok().or_else(|| bitcoind::downloaded_exe_path().ok()).unwrap(); + let mut conf = bitcoind::Conf::default(); + conf.view_stdout = log_enabled!(Level::Debug); + let bitcoind = bitcoind::BitcoinD::with_conf(bitcoind_exe, &conf)?; + let receiver = bitcoind.create_wallet("receiver")?; + let receiver_address = + receiver.get_new_address(None, Some(AddressType::Bech32))?.assume_checked(); + let sender = bitcoind.create_wallet("sender")?; + let sender_address = + sender.get_new_address(None, Some(AddressType::Bech32))?.assume_checked(); + bitcoind.client.generate_to_address(1, &receiver_address)?; + bitcoind.client.generate_to_address(101, &sender_address)?; + + assert_eq!( + Amount::from_btc(50.0)?, + receiver.get_balances()?.mine.trusted, + "receiver doesn't own bitcoin" + ); + + assert_eq!( + Amount::from_btc(50.0)?, + sender.get_balances()?.mine.trusted, + "sender doesn't own bitcoin" + ); + Ok((bitcoind, sender, receiver)) + } + + fn build_original_psbt( + sender: &bitcoincore_rpc::Client, + pj_uri: &Uri<'_, NetworkChecked>, + ) -> Result { + let mut outputs = HashMap::with_capacity(1); + outputs.insert(pj_uri.address.to_string(), pj_uri.amount.unwrap()); + // debug!("outputs: {:?}", outputs); + let options = bitcoincore_rpc::json::WalletCreateFundedPsbtOptions { + lock_unspent: Some(true), + fee_rate: Some(Amount::from_sat(2000)), + ..Default::default() + }; + let psbt = sender + .wallet_create_funded_psbt( + &[], // inputs + &outputs, + None, // locktime + Some(options), + None, + )? + .psbt; + let psbt = sender.wallet_process_psbt(&psbt, None, None, None)?.psbt; + Ok(Psbt::from_str(&psbt)?) + } + + fn extract_pj_tx( + sender: &bitcoincore_rpc::Client, + psbt: Psbt, + ) -> Result> { + let payjoin_base64_string = bitcoin::base64::encode(&psbt.serialize()); + let payjoin_psbt = + sender.wallet_process_psbt(&payjoin_base64_string, None, None, None)?.psbt; + let payjoin_psbt = sender.finalize_psbt(&payjoin_psbt, Some(false))?.psbt.unwrap(); + let payjoin_psbt = Psbt::from_str(&payjoin_psbt)?; + // debug!("Sender's Payjoin PSBT: {:#?}", payjoin_psbt); + + Ok(payjoin_psbt.extract_tx()) + } +} diff --git a/payjoin/src/lib.rs b/payjoin/src/lib.rs index ba36efe0b..854c9b386 100644 --- a/payjoin/src/lib.rs +++ b/payjoin/src/lib.rs @@ -48,4 +48,4 @@ pub(crate) mod weight; #[cfg(feature = "base64")] pub use bitcoin::base64; pub use uri::{PjParseError, PjUri, PjUriBuilder, Uri}; -pub use url::Url; +pub use url::{ParseError, Url}; diff --git a/payjoin/tests/integration.rs b/payjoin/tests/integration.rs index a397ac6b7..c231af01f 100644 --- a/payjoin/tests/integration.rs +++ b/payjoin/tests/integration.rs @@ -1,4 +1,5 @@ #[cfg(all(feature = "send", feature = "receive"))] +#[cfg(not(feature = "v2"))] mod integration { use std::collections::HashMap; use std::env; @@ -22,7 +23,6 @@ mod integration { static INIT_TRACING: OnceCell<()> = OnceCell::new(); - #[cfg(not(feature = "v2"))] mod v1 { use payjoin::receive::{Headers, PayjoinProposal, UncheckedProposal}; @@ -191,640 +191,89 @@ mod integration { .unwrap(); payjoin_proposal } - } - - #[cfg(feature = "v2")] - mod v2 { - use std::sync::Arc; - use std::time::Duration; - - use payjoin::receive::v2::{Enrolled, Enroller, PayjoinProposal, UncheckedProposal}; - use payjoin::OhttpKeys; - use testcontainers_modules::redis::Redis; - use testcontainers_modules::testcontainers::clients::Cli; - use tokio::task::spawn_blocking; - use ureq::{Agent, AgentBuilder, Error, Response}; - - use super::*; - - #[tokio::test] - async fn test_bad_ohttp_keys() { - let bad_ohttp_keys = OhttpKeys::decode( - &base64::decode_config( - "AQAg3WpRjS0aqAxQUoLvpas2VYjT2oIg6-3XSiB-QiYI1BAABAABAAM", - base64::URL_SAFE, - ) - .expect("invalid base64"), - ) - .expect("Invalid OhttpKeys"); - - std::env::set_var("RUST_LOG", "debug"); - let (cert, key) = local_cert_key(); - let port = find_free_port(); - let directory = Url::parse(&format!("https://localhost:{}", port)).unwrap(); - tokio::select!( - _ = init_directory(port, (cert.clone(), key)) => assert!(false, "Directory server is long running"), - res = enroll_with_bad_keys(directory, bad_ohttp_keys, cert) => { - assert_eq!( - res.unwrap_err().into_response().unwrap().content_type(), - "application/problem+json" - ); - } - ); - async fn enroll_with_bad_keys( - directory: Url, - bad_ohttp_keys: OhttpKeys, - cert_der: Vec, - ) -> Result { - let agent = Arc::new(http_agent(cert_der.clone()).unwrap()); - wait_for_service_ready(directory.clone(), agent.clone()).await.unwrap(); - let mock_ohttp_relay = directory.clone(); // pass through to directory - let mut bad_enroller = - Enroller::from_directory_config(directory, bad_ohttp_keys, mock_ohttp_relay); - let (req, _ctx) = bad_enroller.extract_req().expect("Failed to extract request"); - spawn_blocking(move || agent.post(req.url.as_str()).send_bytes(&req.body)) - .await - .expect("Failed to send request") - } - } - - #[tokio::test] - async fn v2_to_v2() { - std::env::set_var("RUST_LOG", "debug"); - init_tracing(); - let (cert, key) = local_cert_key(); - let ohttp_relay_port = find_free_port(); - let ohttp_relay = - Url::parse(&format!("http://localhost:{}", ohttp_relay_port)).unwrap(); - let directory_port = find_free_port(); - let directory = Url::parse(&format!("https://localhost:{}", directory_port)).unwrap(); - let gateway_origin = http::Uri::from_str(directory.as_str()).unwrap(); - tokio::select!( - _ = ohttp_relay::listen_tcp(ohttp_relay_port, gateway_origin) => assert!(false, "Ohttp relay is long running"), - _ = init_directory(directory_port, (cert.clone(), key)) => assert!(false, "Directory server is long running"), - res = do_v2_send_receive(ohttp_relay, directory, cert) => assert!(res.is_ok()) - ); + fn init_tracing() { + INIT_TRACING.get_or_init(|| { + let subscriber = FmtSubscriber::builder() + .with_env_filter(EnvFilter::from_default_env()) + .with_test_writer() + .finish(); - async fn do_v2_send_receive( - ohttp_relay: Url, - directory: Url, - cert_der: Vec, - ) -> Result<(), BoxError> { - let (_bitcoind, sender, receiver) = init_bitcoind_sender_receiver()?; - let agent = Arc::new(http_agent(cert_der.clone())?); - wait_for_service_ready(ohttp_relay.clone(), agent.clone()).await.unwrap(); - wait_for_service_ready(directory.clone(), agent.clone()).await.unwrap(); - let ohttp_keys = - fetch_ohttp_keys(&ohttp_relay, &directory, cert_der.clone()).await?; - - // ********************** - // Inside the Receiver: - let mut enrolled = - enroll_with_directory(directory.clone(), ohttp_keys.clone(), cert_der).await?; - println!("enrolled: {:#?}", &enrolled); - let pj_uri_string = create_receiver_pj_uri_string( - &receiver, - ohttp_keys, - &enrolled.fallback_target(), - ) - .await?; - - // ********************** - // Inside the Sender: - // Create a funded PSBT (not broadcasted) to address with amount given in the pj_uri - let pj_uri = Uri::from_str(&pj_uri_string).unwrap().assume_checked(); - let psbt = build_original_psbt(&sender, &pj_uri)?; - debug!("Original psbt: {:#?}", psbt); - let (send_req, send_ctx) = RequestBuilder::from_psbt_and_uri(psbt, pj_uri)? - .build_with_additional_fee(Amount::from_sat(10000), None, FeeRate::ZERO, false)? - .extract_v2(directory.to_owned())?; // Mock since we're not - log::info!("send fallback v2"); - log::debug!("Request: {:#?}", &send_req.body); - let response = { - let Request { url, body, .. } = send_req.clone(); - let agent_clone = agent.clone(); - spawn_blocking(move || { - agent_clone - .post(url.as_str()) - .set("Content-Type", payjoin::V1_REQ_CONTENT_TYPE) - .send_bytes(&body) - }) - .await?? - }; - log::info!("Response: {:#?}", &response); - assert!(is_success(response.status())); - // no response body yet since we are async and pushed fallback_psbt to the buffer - - // ********************** - // Inside the Receiver: - - // GET fallback psbt - let (req, ctx) = enrolled.extract_req()?; - let agent_clone = agent.clone(); - let response = spawn_blocking(move || { - agent_clone.post(req.url.as_str()).send_bytes(&req.body) - }) - .await??; - - // POST payjoin - let proposal = enrolled.process_res(response.into_reader(), ctx)?.unwrap(); - let mut payjoin_proposal = handle_directory_proposal(receiver, proposal); - let (req, ctx) = payjoin_proposal.extract_v2_req()?; - let agent_clone = agent.clone(); - let response = spawn_blocking(move || { - agent_clone.post(req.url.as_str()).send_bytes(&req.body) - }) - .await??; - let mut res = Vec::new(); - response.into_reader().read_to_end(&mut res)?; - let _response = payjoin_proposal.deserialize_res(res, ctx)?; - // response should be 204 http - - // ********************** - // Inside the Sender: - // Sender checks, signs, finalizes, extracts, and broadcasts - - // Replay post fallback to get the response - let agent_clone = agent.clone(); - let response = spawn_blocking(move || { - agent_clone.post(send_req.url.as_str()).send_bytes(&send_req.body) - }) - .await??; - let checked_payjoin_proposal_psbt = - send_ctx.process_response(&mut response.into_reader())?.unwrap(); - let payjoin_tx = extract_pj_tx(&sender, checked_payjoin_proposal_psbt)?; - sender.send_raw_transaction(&payjoin_tx)?; - log::info!("sent"); - Ok(()) - } + tracing::subscriber::set_global_default(subscriber) + .expect("failed to set global default subscriber"); + }); } - #[tokio::test] - #[cfg(feature = "v2")] - async fn v1_to_v2() { - std::env::set_var("RUST_LOG", "debug"); - init_tracing(); - let (cert, key) = local_cert_key(); - let ohttp_relay_port = find_free_port(); - let ohttp_relay = - Url::parse(&format!("http://localhost:{}", ohttp_relay_port)).unwrap(); - let directory_port = find_free_port(); - let directory = Url::parse(&format!("https://localhost:{}", directory_port)).unwrap(); - let gateway_origin = http::Uri::from_str(directory.as_str()).unwrap(); - tokio::select!( - _ = ohttp_relay::listen_tcp(ohttp_relay_port, gateway_origin) => assert!(false, "Ohttp relay is long running"), - _ = init_directory(directory_port, (cert.clone(), key)) => assert!(false, "Directory server is long running"), - res = do_v1_to_v2(ohttp_relay, directory, cert) => assert!(res.is_ok()), + fn init_bitcoind_sender_receiver( + ) -> Result<(bitcoind::BitcoinD, bitcoincore_rpc::Client, bitcoincore_rpc::Client), BoxError> + { + let bitcoind_exe = env::var("BITCOIND_EXE") + .ok() + .or_else(|| bitcoind::downloaded_exe_path().ok()) + .unwrap(); + let mut conf = bitcoind::Conf::default(); + conf.view_stdout = log_enabled!(Level::Debug); + let bitcoind = bitcoind::BitcoinD::with_conf(bitcoind_exe, &conf)?; + let receiver = bitcoind.create_wallet("receiver")?; + let receiver_address = + receiver.get_new_address(None, Some(AddressType::Bech32))?.assume_checked(); + let sender = bitcoind.create_wallet("sender")?; + let sender_address = + sender.get_new_address(None, Some(AddressType::Bech32))?.assume_checked(); + bitcoind.client.generate_to_address(1, &receiver_address)?; + bitcoind.client.generate_to_address(101, &sender_address)?; + + assert_eq!( + Amount::from_btc(50.0)?, + receiver.get_balances()?.mine.trusted, + "receiver doesn't own bitcoin" ); - async fn do_v1_to_v2( - ohttp_relay: Url, - directory: Url, - cert_der: Vec, - ) -> Result<(), BoxError> { - let (_bitcoind, sender, receiver) = init_bitcoind_sender_receiver()?; - let agent: Arc = Arc::new(http_agent(cert_der.clone())?); - wait_for_service_ready(ohttp_relay.clone(), agent.clone()).await.unwrap(); - wait_for_service_ready(directory.clone(), agent.clone()).await.unwrap(); - let ohttp_keys = - fetch_ohttp_keys(&ohttp_relay, &directory, cert_der.clone()).await?; - - let mut enrolled = - enroll_with_directory(directory, ohttp_keys.clone(), cert_der.clone()).await?; - - let pj_uri_string = create_receiver_pj_uri_string( - &receiver, - ohttp_keys, - &enrolled.fallback_target(), - ) - .await?; - - // ********************** - // Inside the V1 Sender: - // Create a funded PSBT (not broadcasted) to address with amount given in the pj_uri - let pj_uri = Uri::from_str(&pj_uri_string).unwrap().assume_checked(); - let psbt = build_original_psbt(&sender, &pj_uri)?; - debug!("Original psbt: {:#?}", psbt); - let (send_req, send_ctx) = RequestBuilder::from_psbt_and_uri(psbt, pj_uri)? - .build_with_additional_fee(Amount::from_sat(10000), None, FeeRate::ZERO, false)? - .extract_v1()?; - log::info!("send fallback v1 to offline receiver fail"); - let res = { - let Request { url, body, .. } = send_req.clone(); - let agent_clone = agent.clone(); - spawn_blocking(move || { - agent_clone - .post(url.as_str()) - .set("Content-Type", payjoin::V1_REQ_CONTENT_TYPE) - .send_bytes(&body) - }) - .await? - }; - match res { - Err(ureq::Error::Status(code, _)) => assert_eq!(code, 503), - _ => panic!("Expected response status code 503, found {:?}", res), - } - - // ********************** - // Inside the Receiver: - let agent_clone = agent.clone(); - let receiver_loop = tokio::task::spawn(async move { - let (response, ctx) = loop { - let (req, ctx) = enrolled.extract_req().unwrap(); - let agent_clone = agent_clone.clone(); - let response = spawn_blocking(move || { - agent_clone.post(req.url.as_str()).send_bytes(&req.body) - }) - .await??; - - if response.status() == 200 { - debug!("GET'd fallback_psbt"); - break (response.into_reader(), ctx); - } else if response.status() == 202 { - log::info!( - "No response yet for POST payjoin request, retrying some seconds" - ); - tokio::time::sleep(std::time::Duration::from_secs(1)).await; - } else { - log::error!("Unexpected response status: {}", response.status()); - panic!("Unexpected response status: {}", response.status()) - } - }; - debug!("handle directory response"); - let proposal = enrolled.process_res(response, ctx).unwrap().unwrap(); - let mut payjoin_proposal = handle_directory_proposal(receiver, proposal); - // Respond with payjoin psbt within the time window the sender is willing to wait - // this response would be returned as http response to the sender - let (req, ctx) = payjoin_proposal.extract_v2_req().unwrap(); - let response = spawn_blocking(move || { - agent_clone.post(req.url.as_str()).send_bytes(&req.body) - }) - .await??; - let mut res = Vec::new(); - response.into_reader().read_to_end(&mut res)?; - let _response = payjoin_proposal.deserialize_res(res, ctx).unwrap(); - debug!("Post payjoin_psbt to directory"); - // assert!(_response.status() == 204); - Ok::<_, Box>(()) - }); - - // ********************** - // send fallback v1 to online receiver - log::info!("send fallback v1 to online receiver should succeed"); - let response = { - let Request { url, body, .. } = send_req.clone(); - let agent_clone = agent.clone(); - spawn_blocking(move || { - agent_clone - .post(url.as_str()) - .set("Content-Type", payjoin::V1_REQ_CONTENT_TYPE) - .send_bytes(&body) - .expect("Failed to send request") - }) - .await? - }; - log::info!("Response: {:#?}", &response); - assert!(is_success(response.status())); - - let checked_payjoin_proposal_psbt = - send_ctx.process_response(&mut response.into_reader())?; - let payjoin_tx = extract_pj_tx(&sender, checked_payjoin_proposal_psbt)?; - sender.send_raw_transaction(&payjoin_tx)?; - log::info!("sent"); - assert!( - receiver_loop.await.is_ok(), - "The spawned task panicked or returned an error" - ); - Ok(()) - } - } - - async fn init_directory( - port: u16, - local_cert_key: (Vec, Vec), - ) -> Result<(), BoxError> { - let docker: Cli = Cli::default(); - let timeout = Duration::from_secs(2); - let db = docker.run(Redis::default()); - let db_host = format!("127.0.0.1:{}", db.get_host_port_ipv4(6379)); - println!("Database running on {}", db.get_host_port_ipv4(6379)); - payjoin_directory::listen_tcp_with_tls(port, db_host, timeout, local_cert_key).await - } - - // generates or gets a DER encoded localhost cert and key. - fn local_cert_key() -> (Vec, Vec) { - let cert = rcgen::generate_simple_self_signed(vec![ - "0.0.0.0".to_string(), - "localhost".to_string(), - ]) - .expect("Failed to generate cert"); - let cert_der = cert.serialize_der().expect("Failed to serialize cert"); - let key_der = cert.serialize_private_key_der(); - (cert_der, key_der) - } - - async fn fetch_ohttp_keys( - ohttp_relay: &Url, - directory: &Url, - cert_der: Vec, - ) -> Result { - let ohttp_relay = ohttp_relay.clone(); - let directory_ohttp_keys = directory.join("/ohttp-keys")?; - let res = spawn_blocking(move || { - http_proxy(cert_der, &ohttp_relay) - .unwrap() - .get(directory_ohttp_keys.as_str()) - .call() - }) - .await??; - assert_eq!(res.status(), 200, "Failed to connect to target {}", res.status()); - let mut body = Vec::new(); - let _ = res.into_reader().read_to_end(&mut body)?; - Ok(payjoin::OhttpKeys::decode(&body)?) - } - - /// Normalize the Url to include the port for ureq. ureq has a bug - /// which makes Proxy::new(...) use port 8080 for all input with scheme - /// http regardless of the port included in the Url. This prevents that. - /// https://github.com/algesten/ureq/pull/717 - fn normalize_proxy_url(proxy: &Url) -> Result { - let scheme = proxy.scheme(); - let host = proxy.host_str().ok_or("No host")?; - - if scheme == "http" || scheme == "https" { - Ok(format!("{}:{}", host, proxy.port().unwrap_or(80))) - } else { - Ok(proxy.as_str().to_string()) - } - } - - async fn enroll_with_directory( - directory: Url, - ohttp_keys: OhttpKeys, - cert_der: Vec, - ) -> Result { - let mock_ohttp_relay = directory.clone(); // pass through to directory - let mut enroller = Enroller::from_directory_config( - directory.clone(), - ohttp_keys, - mock_ohttp_relay.clone(), + assert_eq!( + Amount::from_btc(50.0)?, + sender.get_balances()?.mine.trusted, + "sender doesn't own bitcoin" ); - let (req, ctx) = enroller.extract_req()?; - println!("enroll req: {:#?}", &req); - let res = spawn_blocking(move || { - http_agent(cert_der).unwrap().post(req.url.as_str()).send_bytes(&req.body) - }) - .await??; - assert!(is_success(res.status())); - Ok(enroller.process_res(res.into_reader(), ctx)?) - } - - /// The receiver outputs a string to be passed to the sender as a string or QR code - async fn create_receiver_pj_uri_string( - receiver: &bitcoincore_rpc::Client, - ohttp_keys: OhttpKeys, - fallback_target: &str, - ) -> Result { - let pj_receiver_address = receiver.get_new_address(None, None)?.assume_checked(); - Ok(PjUriBuilder::new( - pj_receiver_address, - Url::parse(&fallback_target).unwrap(), - Some(ohttp_keys), - ) - .amount(Amount::ONE_BTC) - .build() - .to_string()) + Ok((bitcoind, sender, receiver)) } - fn handle_directory_proposal( - receiver: bitcoincore_rpc::Client, - proposal: UncheckedProposal, - ) -> PayjoinProposal { - // in a payment processor where the sender could go offline, this is where you schedule to broadcast the original_tx - let _to_broadcast_in_failure_case = proposal.extract_tx_to_schedule_broadcast(); - - // Receive Check 1: Can Broadcast - let proposal = proposal - .check_broadcast_suitability(None, |tx| { - Ok(receiver - .test_mempool_accept(&[bitcoin::consensus::encode::serialize_hex(&tx)]) - .unwrap() - .first() - .unwrap() - .allowed) - }) - .expect("Payjoin proposal should be broadcastable"); - - // Receive Check 2: receiver can't sign for proposal inputs - let proposal = proposal - .check_inputs_not_owned(|input| { - let address = - bitcoin::Address::from_script(&input, bitcoin::Network::Regtest).unwrap(); - Ok(receiver.get_address_info(&address).unwrap().is_mine.unwrap()) - }) - .expect("Receiver should not own any of the inputs"); - - // Receive Check 3: receiver can't sign for proposal inputs - let proposal = proposal.check_no_mixed_input_scripts().unwrap(); - - // Receive Check 4: have we seen this input before? More of a check for non-interactive i.e. payment processor receivers. - let mut payjoin = proposal - .check_no_inputs_seen_before(|_| Ok(false)) - .unwrap() - .identify_receiver_outputs(|output_script| { - let address = - bitcoin::Address::from_script(&output_script, bitcoin::Network::Regtest) - .unwrap(); - Ok(receiver.get_address_info(&address).unwrap().is_mine.unwrap()) - }) - .expect("Receiver should have at least one output"); - - // Select receiver payjoin inputs. TODO Lock them. - let available_inputs = receiver.list_unspent(None, None, None, None, None).unwrap(); - let candidate_inputs: HashMap = available_inputs - .iter() - .map(|i| (i.amount, OutPoint { txid: i.txid, vout: i.vout })) - .collect(); - - let selected_outpoint = payjoin.try_preserving_privacy(candidate_inputs).expect("gg"); - let selected_utxo = available_inputs - .iter() - .find(|i| i.txid == selected_outpoint.txid && i.vout == selected_outpoint.vout) - .unwrap(); - - // calculate receiver payjoin outputs given receiver payjoin inputs and original_psbt, - let txo_to_contribute = bitcoin::TxOut { - value: selected_utxo.amount.to_sat(), - script_pubkey: selected_utxo.script_pub_key.clone(), + fn build_original_psbt( + sender: &bitcoincore_rpc::Client, + pj_uri: &Uri<'_, NetworkChecked>, + ) -> Result { + let mut outputs = HashMap::with_capacity(1); + outputs.insert(pj_uri.address.to_string(), pj_uri.amount.unwrap()); + debug!("outputs: {:?}", outputs); + let options = bitcoincore_rpc::json::WalletCreateFundedPsbtOptions { + lock_unspent: Some(true), + fee_rate: Some(Amount::from_sat(2000)), + ..Default::default() }; - let outpoint_to_contribute = - bitcoin::OutPoint { txid: selected_utxo.txid, vout: selected_utxo.vout }; - payjoin.contribute_witness_input(txo_to_contribute, outpoint_to_contribute); - - let receiver_substitute_address = - receiver.get_new_address(None, None).unwrap().assume_checked(); - payjoin.substitute_output_address(receiver_substitute_address); - let payjoin_proposal = payjoin - .finalize_proposal( - |psbt: &Psbt| { - Ok(receiver - .wallet_process_psbt( - &bitcoin::base64::encode(psbt.serialize()), - None, - None, - Some(false), - ) - .map(|res: WalletProcessPsbtResult| { - let psbt = Psbt::from_str(&res.psbt).unwrap(); - return psbt; - }) - .unwrap()) - }, - Some(bitcoin::FeeRate::MIN), - ) - .unwrap(); - debug!("Receiver's Payjoin proposal PSBT: {:#?}", &payjoin_proposal.psbt()); - payjoin_proposal - } - - fn http_agent(cert_der: Vec) -> Result { - Ok(http_agent_builder(cert_der)?.build()) + let psbt = sender + .wallet_create_funded_psbt( + &[], // inputs + &outputs, + None, // locktime + Some(options), + None, + )? + .psbt; + let psbt = sender.wallet_process_psbt(&psbt, None, None, None)?.psbt; + Ok(Psbt::from_str(&psbt)?) } - fn http_proxy(cert_der: Vec, proxy: &Url) -> Result { - let proxy = ureq::Proxy::new(normalize_proxy_url(proxy)?)?; - Ok(http_agent_builder(cert_der)?.proxy(proxy).build()) + fn extract_pj_tx( + sender: &bitcoincore_rpc::Client, + psbt: Psbt, + ) -> Result> { + let payjoin_base64_string = base64::encode(&psbt.serialize()); + let payjoin_psbt = + sender.wallet_process_psbt(&payjoin_base64_string, None, None, None)?.psbt; + let payjoin_psbt = sender.finalize_psbt(&payjoin_psbt, Some(false))?.psbt.unwrap(); + let payjoin_psbt = Psbt::from_str(&payjoin_psbt)?; + debug!("Sender's Payjoin PSBT: {:#?}", payjoin_psbt); + + Ok(payjoin_psbt.extract_tx()) } - - fn http_agent_builder(cert_der: Vec) -> Result { - use rustls::client::ClientConfig; - use rustls::pki_types::CertificateDer; - use rustls::RootCertStore; - - let mut root_cert_store = RootCertStore::empty(); - root_cert_store.add(CertificateDer::from(cert_der.as_slice()))?; - let client_config = ClientConfig::builder() - .with_root_certificates(root_cert_store) - .with_no_client_auth(); - - Ok(AgentBuilder::new().tls_config(Arc::new(client_config))) - } - - fn is_success(status: u16) -> bool { status >= 200 && status < 300 } - - fn find_free_port() -> u16 { - let listener = std::net::TcpListener::bind("0.0.0.0:0").unwrap(); - listener.local_addr().unwrap().port() - } - - static TESTS_TIMEOUT: Lazy = Lazy::new(|| Duration::from_secs(20)); - static WAIT_SERVICE_INTERVAL: Lazy = Lazy::new(|| Duration::from_secs(3)); - async fn wait_for_service_ready( - service_url: Url, - agent: Arc, - ) -> Result<(), &'static str> { - let health_url = service_url.join("/health").map_err(|_| "Invalid URL")?; - let res = spawn_blocking(move || { - let start = std::time::Instant::now(); - - while start.elapsed() < *TESTS_TIMEOUT { - let request_result = agent.get(health_url.as_str()).call(); - - match request_result { - Ok(response) if response.status() == 200 => return Ok(()), - Err(Error::Status(404, _)) => return Err("Endpoint not found"), - _ => std::thread::sleep(*WAIT_SERVICE_INTERVAL), - } - } - - Err("Timeout waiting for service to be ready") - }) - .await - .map_err(|_| "JoinError")?; - res - } - } - - fn init_tracing() { - INIT_TRACING.get_or_init(|| { - let subscriber = FmtSubscriber::builder() - .with_env_filter(EnvFilter::from_default_env()) - .with_test_writer() - .finish(); - - tracing::subscriber::set_global_default(subscriber) - .expect("failed to set global default subscriber"); - }); - } - - fn init_bitcoind_sender_receiver( - ) -> Result<(bitcoind::BitcoinD, bitcoincore_rpc::Client, bitcoincore_rpc::Client), BoxError> - { - let bitcoind_exe = - env::var("BITCOIND_EXE").ok().or_else(|| bitcoind::downloaded_exe_path().ok()).unwrap(); - let mut conf = bitcoind::Conf::default(); - conf.view_stdout = log_enabled!(Level::Debug); - let bitcoind = bitcoind::BitcoinD::with_conf(bitcoind_exe, &conf)?; - let receiver = bitcoind.create_wallet("receiver")?; - let receiver_address = - receiver.get_new_address(None, Some(AddressType::Bech32))?.assume_checked(); - let sender = bitcoind.create_wallet("sender")?; - let sender_address = - sender.get_new_address(None, Some(AddressType::Bech32))?.assume_checked(); - bitcoind.client.generate_to_address(1, &receiver_address)?; - bitcoind.client.generate_to_address(101, &sender_address)?; - - assert_eq!( - Amount::from_btc(50.0)?, - receiver.get_balances()?.mine.trusted, - "receiver doesn't own bitcoin" - ); - - assert_eq!( - Amount::from_btc(50.0)?, - sender.get_balances()?.mine.trusted, - "sender doesn't own bitcoin" - ); - Ok((bitcoind, sender, receiver)) - } - - fn build_original_psbt( - sender: &bitcoincore_rpc::Client, - pj_uri: &Uri<'_, NetworkChecked>, - ) -> Result { - let mut outputs = HashMap::with_capacity(1); - outputs.insert(pj_uri.address.to_string(), pj_uri.amount.unwrap()); - debug!("outputs: {:?}", outputs); - let options = bitcoincore_rpc::json::WalletCreateFundedPsbtOptions { - lock_unspent: Some(true), - fee_rate: Some(Amount::from_sat(2000)), - ..Default::default() - }; - let psbt = sender - .wallet_create_funded_psbt( - &[], // inputs - &outputs, - None, // locktime - Some(options), - None, - )? - .psbt; - let psbt = sender.wallet_process_psbt(&psbt, None, None, None)?.psbt; - Ok(Psbt::from_str(&psbt)?) - } - - fn extract_pj_tx( - sender: &bitcoincore_rpc::Client, - psbt: Psbt, - ) -> Result> { - let payjoin_base64_string = base64::encode(&psbt.serialize()); - let payjoin_psbt = - sender.wallet_process_psbt(&payjoin_base64_string, None, None, None)?.psbt; - let payjoin_psbt = sender.finalize_psbt(&payjoin_psbt, Some(false))?.psbt.unwrap(); - let payjoin_psbt = Psbt::from_str(&payjoin_psbt)?; - debug!("Sender's Payjoin PSBT: {:#?}", payjoin_psbt); - - Ok(payjoin_psbt.extract_tx()) } }