From 933fd5bcdbd38d365c125165ed8f3bca56161d8b Mon Sep 17 00:00:00 2001 From: DanGould Date: Fri, 18 Aug 2023 13:54:52 -0400 Subject: [PATCH] Store and forward v2 messages with payjoin-relay Use postgres and hyper to store and notify clients' updates. --- Cargo.lock | 988 ++++++++++++++++++++++++++++++++++- Cargo.toml | 2 +- payjoin-relay/Cargo.toml | 31 ++ payjoin-relay/README.md | 15 + payjoin-relay/src/db.rs | 189 +++++++ payjoin-relay/src/main.rs | 232 ++++++++ payjoin/Cargo.toml | 7 +- payjoin/src/receive/mod.rs | 19 + payjoin/src/send/mod.rs | 1 + payjoin/src/uri.rs | 9 +- payjoin/tests/integration.rs | 420 ++++++++++++--- 11 files changed, 1814 insertions(+), 99 deletions(-) create mode 100644 payjoin-relay/Cargo.toml create mode 100644 payjoin-relay/README.md create mode 100644 payjoin-relay/src/db.rs create mode 100644 payjoin-relay/src/main.rs diff --git a/Cargo.lock b/Cargo.lock index fd83b51c..a0d8cb44 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -28,6 +28,19 @@ dependencies = [ "version_check", ] +[[package]] +name = "ahash" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91429305e9f0a25f6205c5b8e0d2db09e0708a7a6df0f42212bb56c32c8ac97a" +dependencies = [ + "cfg-if", + "getrandom", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.2" @@ -37,6 +50,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0942ffc6dcaadf03badf6e6a2d0228460359d5e34b57ccdc720b7382dfbd5ec5" + [[package]] name = "anyhow" version = "1.0.75" @@ -54,6 +73,25 @@ dependencies = [ "syn 2.0.39", ] +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atomic-write-file" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edcdbedc2236483ab103a53415653d6b4442ea6141baf1ffa85df29635e88436" +dependencies = [ + "nix", + "rand", +] + [[package]] name = "atty" version = "0.2.14" @@ -98,6 +136,12 @@ version = "0.21.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35636a1494ede3b646cc98f74f8e62c773a38a659ebc777a2cf26b9b74171df9" +[[package]] +name = "base64ct" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b" + [[package]] name = "bech32" version = "0.9.1" @@ -200,6 +244,9 @@ name = "bitflags" version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "327762f6e5a765692301e5bb513e0d9fef63be86bbc14528052b1cd3e6f03e07" +dependencies = [ + "serde", +] [[package]] name = "block-buffer" @@ -210,6 +257,16 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bollard-stubs" +version = "1.42.0-rc.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed59b5c00048f48d7af971b71f800fdf23e858844a6f9e4d32ca72e9399e7864" +dependencies = [ + "serde", + "serde_with", +] + [[package]] name = "bumpalo" version = "3.14.0" @@ -322,11 +379,17 @@ dependencies = [ "yaml-rust", ] +[[package]] +name = "const-oid" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28c122c3980598d243d63d9a704629a2d748d101f278052ff068be5a4423ab6f" + [[package]] name = "core-foundation" -version = "0.9.3" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "194a7a9e6de53fa55116934067c844d9d749312f75c6f6d0980e8c252f8c2146" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" dependencies = [ "core-foundation-sys", "libc", @@ -334,9 +397,9 @@ dependencies = [ [[package]] name = "core-foundation-sys" -version = "0.8.4" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e496a50fda8aacccc86d7529e2c1e0892dbd0f898a6b5645b5561b89c3210efa" +checksum = "06ea2b9bc92be3c2baa9334a323ebca2d6f074ff852cd1d7b11064035cd3868f" [[package]] name = "core-rpc" @@ -373,6 +436,21 @@ dependencies = [ "libc", ] +[[package]] +name = "crc" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86ec7a15cbe22e59248fc7eadb1907dab5ba09372595da4d73dd805ed4417dfe" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" + [[package]] name = "crc32fast" version = "1.3.2" @@ -382,6 +460,25 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam-queue" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1cfb3ea8a53f37c40dea2c7bedcbd88bdfae54f5e2175d6ecaff1c988353add" +dependencies = [ + "cfg-if", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a22b2d63d4d1dc0b7f1b6b2747dd0088008a9be28b6ddf0b1e7d335e3037294" +dependencies = [ + "cfg-if", +] + [[package]] name = "crypto-common" version = "0.1.6" @@ -392,6 +489,52 @@ dependencies = [ "typenum", ] +[[package]] +name = "darling" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a01d95850c592940db9b8194bc39f4bc0e89dee5c4265e4b1807c34a9aba453c" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "859d65a907b6852c9361e3185c862aae7fafd2887876799fa55f5f99dc40d610" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 1.0.109", +] + +[[package]] +name = "darling_macro" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c972679f83bdf9c42bd905396b6c3588a843a17f0f16dfcfa3e2c5d57441835" +dependencies = [ + "darling_core", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "der" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fffa369a668c8af7dbf8b5e56c9f744fbd399949ed171606040001947de40b1c" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + [[package]] name = "digest" version = "0.10.7" @@ -399,7 +542,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", + "const-oid", "crypto-common", + "subtle", ] [[package]] @@ -408,11 +553,20 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0688c2a7f92e427f44895cd63841bff7b29f8d7a1648b9e7e07a4a365b2e1257" +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + [[package]] name = "either" version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a26ae43d7bcc3b814de94796a5e736d4029efb0ee900c12e2d54c993ad1a1e07" +dependencies = [ + "serde", +] [[package]] name = "env_logger" @@ -443,6 +597,23 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.48.0", +] + +[[package]] +name = "event-listener" +version = "2.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" + [[package]] name = "fastrand" version = "2.0.1" @@ -461,6 +632,12 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "finl_unicode" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fcfdc7a0362c9f4444381a9e697c79d435fe65b52a37466fc2c1184cee9edc6" + [[package]] name = "flate2" version = "1.0.28" @@ -471,6 +648,17 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "flume" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55ac459de2512911e4b674ce33cf20befaba382d05b62b008afc1c8b57cbf181" +dependencies = [ + "futures-core", + "futures-sink", + "spin 0.9.8", +] + [[package]] name = "fnv" version = "1.0.7" @@ -486,6 +674,21 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "futures" +version = "0.3.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0290714b38af9b4a7b094b8a37086d1b4e61f2df9122c3cad2577669145335" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.29" @@ -493,6 +696,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff4dd66668b557604244583e3e1e1eada8c5c2e96a6d0d6653ede395b78bbacb" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -501,6 +705,45 @@ version = "0.3.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eb1d22c66e66d9d72e1758f0bd7d4fd0bee04cad842ee34587d68c07e45d088c" +[[package]] +name = "futures-executor" +version = "0.3.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f4fb8693db0cf099eadcca0efe2a5a22e4550f98ed16aba6c48700da29597bc" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +version = "0.3.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bf34a163b5c4c52d0478a4d757da8fb65cabef42ba90515efee0f6f9fa45aaa" + +[[package]] +name = "futures-macro" +version = "0.3.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b153fd91e4b0147f4aced87be237c98248656bb01050b96bf3ee89220a8ddb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.39", +] + [[package]] name = "futures-sink" version = "0.3.29" @@ -519,10 +762,16 @@ version = "0.3.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a19526d624e703a3179b3d322efec918b6246ea0fa51d41124525f00f1cc8104" dependencies = [ + "futures-channel", "futures-core", + "futures-io", + "futures-macro", + "futures-sink", "futures-task", + "memchr", "pin-project-lite", "pin-utils", + "slab", ] [[package]] @@ -577,7 +826,7 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" dependencies = [ - "ahash", + "ahash 0.7.7", ] [[package]] @@ -585,12 +834,28 @@ name = "hashbrown" version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "290f1a1d9242c78d09ce40a5e87e7554ee637af1351968159f4952f028f75604" +dependencies = [ + "ahash 0.8.6", + "allocator-api2", +] + +[[package]] +name = "hashlink" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8094feaf31ff591f651a2664fb9cfd92bba7a60ce3197265e9482ebe753c8f7" +dependencies = [ + "hashbrown 0.14.3", +] [[package]] name = "heck" version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" +dependencies = [ + "unicode-segmentation", +] [[package]] name = "hermit-abi" @@ -607,12 +872,36 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d77f7ec81a6d05a3abb01ab6eb7590f6083d08449fe5a1c8b1e620283546ccb7" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "hex_lit" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3011d1213f159867b13cfd6ac92d2cd5f1345762c63be3554e84092d85a50bbd" +[[package]] +name = "hkdf" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791a029f6b9fc27657f6f188ec6e5e43f6911f6f878e0dc5501396e09809d437" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "home" version = "0.5.5" @@ -702,6 +991,12 @@ dependencies = [ "tokio-rustls", ] +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "0.5.0" @@ -732,6 +1027,15 @@ dependencies = [ "hashbrown 0.14.3", ] +[[package]] +name = "itertools" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.9" @@ -785,6 +1089,9 @@ name = "lazy_static" version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" +dependencies = [ + "spin 0.5.2", +] [[package]] name = "libc" @@ -792,6 +1099,23 @@ version = "0.2.150" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89d92a4743f9a61002fae18374ed11e7973f530cb3a3255fb354818118b2203c" +[[package]] +name = "libm" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ec2a862134d2a7d32d7983ddcdd1c4923530833c9f2ea1a44fc5fa473989058" + +[[package]] +name = "libsqlite3-sys" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf4e226dcd58b4be396f7bd3c20da8fdee2911400705297ba7d2d7cc2c30f716" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linked-hash-map" version = "0.5.6" @@ -800,9 +1124,9 @@ checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" [[package]] name = "linux-raw-sys" -version = "0.4.11" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "969488b55f8ac402214f3f5fd243ebb7206cf82de60d3172994707a4bcc2b829" +checksum = "c4cd1a83af159aa67994778be9070f0ae1bd732942279cabb14f86f986a21456" [[package]] name = "lock_api" @@ -820,6 +1144,25 @@ version = "0.4.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5e6163cb8c49088c2c36f57875e58ccd8c87c7427f7fbd50ea6710b2f3f2e8f" +[[package]] +name = "matchers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558" +dependencies = [ + "regex-automata 0.1.10", +] + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + [[package]] name = "memchr" version = "2.6.4" @@ -865,6 +1208,17 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "nix" +version = "0.27.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eb04e9c688eff1c89d72b407f168cf79bb9e867a9d3323ed6c01519eb9cc053" +dependencies = [ + "bitflags 2.4.1", + "cfg-if", + "libc", +] + [[package]] name = "nom" version = "7.1.3" @@ -876,20 +1230,78 @@ dependencies = [ ] [[package]] -name = "num_cpus" -version = "1.16.0" +name = "nu-ansi-term" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43" +checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" dependencies = [ - "hermit-abi 0.3.3", - "libc", + "overload", + "winapi", ] [[package]] -name = "object" -version = "0.32.1" +name = "num-bigint-dig" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cf5f9dd3933bd50a9e1f149ec995f39ae2c496d31fd772c1fd45ebc27e902b0" +checksum = "dc84195820f291c7697304f3cbdadd1cb7199c0efc917ff5eafd71225c136151" +dependencies = [ + "byteorder", + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-integer" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "225d3389fb3509a24c93f5c29eb6bde2586b98d9f016636dff58d7c6f7569cd9" +dependencies = [ + "autocfg", + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d03e6c028c5dc5cac6e2dec0efda81fc887605bb3d884578bb6d6bf7514e252" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39e3200413f237f41ab11ad6d161bc7239c84dcb631773ccd7de3dfe4b5c267c" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "num_cpus" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43" +dependencies = [ + "hermit-abi 0.3.3", + "libc", +] + +[[package]] +name = "object" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cf5f9dd3933bd50a9e1f149ec995f39ae2c496d31fd772c1fd45ebc27e902b0" dependencies = [ "memchr", ] @@ -922,6 +1334,12 @@ version = "6.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2355d85b9a3786f481747ced0e0ff2ba35213a1f9bd406ed906554d7af805a1" +[[package]] +name = "overload" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" + [[package]] name = "parking_lot" version = "0.12.1" @@ -945,6 +1363,12 @@ dependencies = [ "windows-targets 0.48.5", ] +[[package]] +name = "paste" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de3145af08024dea9fa9914f381a17b8fc6034dfb00f3a84013f7ff43f29ed4c" + [[package]] name = "pathdiff" version = "0.2.1" @@ -961,6 +1385,11 @@ dependencies = [ "env_logger", "log", "rand", + "rustls", + "testcontainers", + "testcontainers-modules", + "tokio", + "ureq", "url", ] @@ -987,6 +1416,22 @@ dependencies = [ "url", ] +[[package]] +name = "payjoin-relay" +version = "0.0.1" +dependencies = [ + "anyhow", + "hyper", + "hyper-rustls", + "payjoin", + "rcgen", + "rustls", + "sqlx", + "tokio", + "tracing", + "tracing-subscriber", +] + [[package]] name = "pem" version = "3.0.2" @@ -997,6 +1442,15 @@ dependencies = [ "serde", ] +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.1" @@ -1066,6 +1520,27 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "pkg-config" version = "0.3.27" @@ -1188,8 +1663,17 @@ checksum = "ebee201405406dbf528b8b672104ae6d6d63e6d118cb10e4d51abbc7b58044ff" dependencies = [ "aho-corasick", "memchr", - "regex-automata", - "regex-syntax", + "regex-automata 0.3.9", + "regex-syntax 0.7.5", +] + +[[package]] +name = "regex-automata" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" +dependencies = [ + "regex-syntax 0.6.29", ] [[package]] @@ -1200,9 +1684,15 @@ checksum = "59b23e92ee4318893fa3fe3e6fb365258efbfe6ac6ab30f090cdcbb7aa37efa9" dependencies = [ "aho-corasick", "memchr", - "regex-syntax", + "regex-syntax 0.7.5", ] +[[package]] +name = "regex-syntax" +version = "0.6.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" + [[package]] name = "regex-syntax" version = "0.7.5" @@ -1249,6 +1739,26 @@ dependencies = [ "serde", ] +[[package]] +name = "rsa" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af6c4b23d99685a1408194da11270ef8e9809aff951cc70ec9b17350b087e474" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core", + "signature", + "spki", + "subtle", + "zeroize", +] + [[package]] name = "rust-ini" version = "0.18.0" @@ -1427,6 +1937,39 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_with" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "678b5a069e50bf00ecd22d0cd8ddf7c236f68581b03db652061ed5eb13a312ff" +dependencies = [ + "serde", + "serde_with_macros", +] + +[[package]] +name = "serde_with_macros" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e182d6ec6f05393cc0e5ed1bf81ad6db3a8feedf8ee515ecdd369809bcce8082" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sha2" version = "0.10.8" @@ -1438,6 +1981,15 @@ dependencies = [ "digest", ] +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "signal-hook-registry" version = "1.4.1" @@ -1447,6 +1999,16 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core", +] + [[package]] name = "slab" version = "0.4.9" @@ -1493,6 +2055,238 @@ name = "spin" version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sqlformat" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b7b278788e7be4d0d29c0f39497a0eef3fba6bbc8e70d8bf7fde46edeaa9e85" +dependencies = [ + "itertools", + "nom", + "unicode_categories", +] + +[[package]] +name = "sqlx" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dba03c279da73694ef99763320dea58b51095dfe87d001b1d4b5fe78ba8763cf" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d84b0a3c3739e220d94b3239fd69fb1f74bc36e16643423bd99de3b43c21bfbd" +dependencies = [ + "ahash 0.8.6", + "atoi", + "byteorder", + "bytes", + "crc", + "crossbeam-queue", + "dotenvy", + "either", + "event-listener", + "futures-channel", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashlink", + "hex", + "indexmap 2.1.0", + "log", + "memchr", + "once_cell", + "paste", + "percent-encoding", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlformat", + "thiserror", + "tokio", + "tokio-stream", + "tracing", + "url", +] + +[[package]] +name = "sqlx-macros" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89961c00dc4d7dffb7aee214964b065072bff69e36ddb9e2c107541f75e4f2a5" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn 1.0.109", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0bd4519486723648186a08785143599760f7cc81c52334a55d6a83ea1e20841" +dependencies = [ + "atomic-write-file", + "dotenvy", + "either", + "heck", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn 1.0.109", + "tempfile", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e37195395df71fd068f6e2082247891bc11e3289624bbc776a0cdfa1ca7f1ea4" +dependencies = [ + "atoi", + "base64 0.21.5", + "bitflags 2.4.1", + "byteorder", + "bytes", + "crc", + "digest", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "percent-encoding", + "rand", + "rsa", + "serde", + "sha1", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-postgres" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6ac0ac3b7ccd10cc96c7ab29791a7dd236bd94021f31eec7ba3d46a74aa1c24" +dependencies = [ + "atoi", + "base64 0.21.5", + "bitflags 2.4.1", + "byteorder", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "hex", + "hkdf", + "hmac", + "home", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "rand", + "serde", + "serde_json", + "sha1", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "210976b7d948c7ba9fced8ca835b11cbb2d677c59c79de41ac0d397e14547490" +dependencies = [ + "atoi", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "sqlx-core", + "tracing", + "url", + "urlencoding", +] + +[[package]] +name = "stringprep" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb41d74e231a107a1b4ee36bd1214b11285b77768d2e3824aedafa988fd36ee6" +dependencies = [ + "finl_unicode", + "unicode-bidi", + "unicode-normalization", +] [[package]] name = "strsim" @@ -1500,6 +2294,12 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" +[[package]] +name = "subtle" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81cdd64d312baedb58e21336b31bc043b77e01cc99033ce76ef539f78e965ebc" + [[package]] name = "syn" version = "1.0.109" @@ -1555,6 +2355,32 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "testcontainers" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d2931d7f521af5bae989f716c3fa43a6af9af7ec7a5e21b59ae40878cec00" +dependencies = [ + "bollard-stubs", + "futures", + "hex", + "hmac", + "log", + "rand", + "serde", + "serde_json", + "sha2", +] + +[[package]] +name = "testcontainers-modules" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47c405c3757803e54818eaaf6b5b2af485dff4ab89a7130b72f62fd19b8bb6cd" +dependencies = [ + "testcontainers", +] + [[package]] name = "textwrap" version = "0.16.0" @@ -1581,6 +2407,16 @@ dependencies = [ "syn 2.0.39", ] +[[package]] +name = "thread_local" +version = "1.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fdd6f064ccff2d6567adcb3873ca630700f00b5ad3f060c25b5dcfd9a4ce152" +dependencies = [ + "cfg-if", + "once_cell", +] + [[package]] name = "time" version = "0.1.45" @@ -1663,6 +2499,17 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-stream" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "397c988d37662c7dda6d2208364a706264bf3d6138b11d436cbac0ad38832842" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.10" @@ -1698,10 +2545,23 @@ version = "0.1.40" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef" dependencies = [ + "log", "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-attributes" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.39", +] + [[package]] name = "tracing-core" version = "0.1.32" @@ -1709,6 +2569,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54" dependencies = [ "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad0f048c97dbd9faa9b7df56362b8ebcaa52adb06b498c050d2f4e32f90a7a8b" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", ] [[package]] @@ -1750,6 +2640,18 @@ dependencies = [ "tinyvec", ] +[[package]] +name = "unicode-segmentation" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dd624098567895118886609431a7c3b8f516e41d30e0643f03d94592a147e36" + +[[package]] +name = "unicode_categories" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" + [[package]] name = "untrusted" version = "0.7.1" @@ -1790,6 +2692,24 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "valuable" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.4" @@ -1899,6 +2819,12 @@ dependencies = [ "rustix", ] +[[package]] +name = "whoami" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22fc3756b8a9133049b26c7f61ab35416c130e8c09b660f5b3958b446f52cc50" + [[package]] name = "winapi" version = "0.3.9" @@ -2089,6 +3015,32 @@ dependencies = [ "time 0.3.20", ] +[[package]] +name = "zerocopy" +version = "0.7.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f43de342578a3a14a9314a2dab1942cbfcbe5686e1f91acdc513058063eafe18" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.7.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1012d89e3acb79fad7a799ce96866cfb8098b74638465ea1b1533d35900ca90" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.39", +] + +[[package]] +name = "zeroize" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525b4ec142c6b68a2d10f01f7bbf6755599ca3f81ea53b8431b7dd348f5fdb2d" + [[package]] name = "zip" version = "0.5.13" diff --git a/Cargo.toml b/Cargo.toml index d1910cba..4a377612 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["payjoin", "payjoin-cli"] +members = ["payjoin", "payjoin-cli", "payjoin-relay"] resolver = "2" [patch.crates-io.payjoin] diff --git a/payjoin-relay/Cargo.toml b/payjoin-relay/Cargo.toml new file mode 100644 index 00000000..f04b5f02 --- /dev/null +++ b/payjoin-relay/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "payjoin-relay" +version = "0.0.1" +authors = ["Dan Gould "] +description = "A relay server for Payjoin V2 coordination" +repository = "https://github.com/payjoin/rust-payjoin" +readme = "README.md" +keywords = ["bip78", "payjoin", "bitcoin", "relay"] +categories = ["cryptography::cryptocurrencies", "network-programming"] +license = "MITNFA" +edition = "2021" +resolver = "2" +exclude = ["tests"] + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[features] +danger-local-https = ["hyper-rustls", "rcgen", "rustls"] + +[dependencies] +hyper = { version = "0.14", features = ["full"] } +hyper-rustls = { version = "0.24", optional = true } +anyhow = "1.0.71" +payjoin = { path = "../payjoin", features = ["base64"] } +rcgen = { version = "0.11", optional = true } +rustls = { version = "0.21", optional = true } +sqlx = { version = "0.7.1", features = ["postgres", "runtime-tokio"] } +tokio = { version = "1.12.0", features = ["full"] } +tracing = "0.1.37" +tracing-subscriber = { version = "0.3.17", features = ["env-filter"] } + diff --git a/payjoin-relay/README.md b/payjoin-relay/README.md new file mode 100644 index 00000000..d7f92f51 --- /dev/null +++ b/payjoin-relay/README.md @@ -0,0 +1,15 @@ +# payjoin-relay + +## Payjoin v2 Relay + +Payjoin v2 peers relay HTTP client messages in order to cordinate an asynchronous Payjoin transaction. Version 1 Requires the receiver to host a public HTTP server and to set up security using either HTTPS or Onion Services above and beyond typical HTTP client operation. + +V2 clients use Hybrid Pubkey Encryption established in the bitcoin URI payment request for security instead, allowing lightweight clients secure communication without the burden of setup, which is done by the operator of this third-party relay server. This relay only sees OHTTP encapsulated, encrypted requests to prevent it from collecting metadata to break the privacy benefits of payjoin for messages who follow the spec. + +This relay *only* accepts v2 payloads via Oblivious HTTP (OHTTP), preventing it from identifying IP addresses of clients. + +## Architecture + +The relay is a simple mailbox. Receivers may enroll by making a request to a pubkey identified subdirectory. After success response, they may share this subdirectory as payjoin endpoint to the sender in a bitcoin URI. The sender may poll the subdirectory with a request posting their encrypted Fallback PSBT expecting a Payjoin Proposal PSBT response. The receiver may poll the enroll endpoint to await a request, later posting their Payjoin Proposal PSBT for the sender to receive, sign, and broadcast. + +The relay does depend on a second independent Oblivious HTTP Relay to help secure request/response metadata from the Payjoin Relay. diff --git a/payjoin-relay/src/db.rs b/payjoin-relay/src/db.rs new file mode 100644 index 00000000..7565664e --- /dev/null +++ b/payjoin-relay/src/db.rs @@ -0,0 +1,189 @@ +use std::time::Duration; + +use anyhow::Result; +use sqlx::postgres::{PgListener, PgPoolOptions}; +use sqlx::{PgPool, Pool, Postgres}; +use tracing::debug; + +const RES_COLUMN: &str = "res"; +const REQ_COLUMN: &str = "req"; + +pub(crate) struct DbPool { + pool: Pool, + timeout: Duration, +} + +impl DbPool { + /// Initialize a database connection pool with specified peek timeout + pub async fn new(timeout: Duration, db_host: String) -> Result { + let pool = init_postgres(db_host).await?; + Ok(Self { pool, timeout }) + } + + pub async fn peek_req(&self, pubkey_id: &str) -> Option, sqlx::Error>> { + peek_with_timeout(&self.pool, pubkey_id, REQ_COLUMN, self.timeout).await + } + pub async fn peek_res(&self, pubkey_id: &str) -> Option, sqlx::Error>> { + debug!("peek res"); + peek_with_timeout(&self.pool, pubkey_id, RES_COLUMN, self.timeout).await + } + + pub async fn push_req(&self, pubkey_id: &str, data: Vec) -> Result<(), sqlx::Error> { + push(&self.pool, pubkey_id, REQ_COLUMN, data).await + } + + pub async fn push_res(&self, pubkey_id: &str, data: Vec) -> Result<(), sqlx::Error> { + debug!("push res"); + push(&self.pool, pubkey_id, RES_COLUMN, data).await + } +} + +impl Clone for DbPool { + fn clone(&self) -> Self { Self { pool: self.pool.clone(), timeout: self.timeout } } +} + +async fn init_postgres(db_host: String) -> Result { + let pool = PgPoolOptions::new() + .connect(&format!("postgres://postgres:welcome@{}/postgres", db_host)) + .await?; + // Create table if not exist yet + let (table_exists,): (bool,) = + sqlx::query_as("SELECT EXISTS (SELECT FROM pg_tables WHERE tablename = 'relay')") + .fetch_one(&pool) + .await?; + + if !table_exists { + // Create the table + sqlx::query( + r#" + CREATE TABLE relay ( + pubkey_id VARCHAR PRIMARY KEY, + req BYTEA, + res BYTEA + ); + "#, + ) + .execute(&pool) + .await?; + + // Create the function for notification + sqlx::query( + r#" + CREATE OR REPLACE FUNCTION notify_change() + RETURNS TRIGGER AS $$ + DECLARE + channel_name text; + BEGIN + channel_name := NEW.pubkey_id || '_' || TG_ARGV[0]; + PERFORM pg_notify(channel_name, TG_TABLE_NAME || ', ' || NEW.pubkey_id); + RETURN NEW; + END; + $$ LANGUAGE plpgsql; + "#, + ) + .execute(&pool) + .await?; + + // Create triggers + sqlx::query( + r#" + CREATE TRIGGER relay_req_trigger + AFTER INSERT OR UPDATE OF req ON relay + FOR EACH ROW + EXECUTE FUNCTION notify_change('req'); + "#, + ) + .execute(&pool) + .await?; + + sqlx::query( + r#" + CREATE TRIGGER relay_res_trigger + AFTER INSERT OR UPDATE OF res ON relay + FOR EACH ROW + EXECUTE FUNCTION notify_change('res'); + "#, + ) + .execute(&pool) + .await?; + } + Ok(pool) +} + +async fn push( + pool: &Pool, + pubkey_id: &str, + channel_type: &str, + data: Vec, +) -> Result<(), sqlx::Error> { + // Use an UPSERT operation to insert or update the record + let query = format!( + "INSERT INTO relay (pubkey_id, {}) VALUES ($1, $2) \ + ON CONFLICT (pubkey_id) DO UPDATE SET {} = EXCLUDED.{}", + channel_type, channel_type, channel_type + ); + + sqlx::query(&query).bind(pubkey_id).bind(data).execute(pool).await?; + + Ok(()) +} + +async fn peek_with_timeout( + pool: &Pool, + pubkey_id: &str, + channel_type: &str, + timeout: Duration, +) -> Option, sqlx::Error>> { + tokio::time::timeout(timeout, peek(pool, pubkey_id, channel_type)).await.ok() +} + +async fn peek( + pool: &Pool, + pubkey_id: &str, + channel_type: &str, +) -> Result, sqlx::Error> { + // Step 1: Attempt to fetch existing content for the given pubkey_id and channel_type + match sqlx::query_as::>,)>(&format!( + "SELECT {} FROM relay WHERE pubkey_id = $1", + channel_type + )) + .bind(pubkey_id) + .fetch_one(pool) + .await + { + Ok(row) => + if let Some(content) = row.0 { + if !content.is_empty() { + return Ok(content); + } + }, + Err(e) => { + debug!("Failed to fetch content initially: {}", e); + // We'll continue to the next step even if the query failed + } + } + + // Step 2: If no content was found, set up a listener + let mut listener = PgListener::connect_with(pool).await?; + let channel_name = format!("{}_{}", pubkey_id, channel_type); + listener.listen(&channel_name).await?; + debug!("Listening on channel: {}", channel_name); + + // Step 3: Wait for a notification and then fetch the new content + loop { + let notification = listener.recv().await?; + debug!("Received notification: {:?}", notification); + if notification.channel() == channel_name { + let row: (Vec,) = + sqlx::query_as(&format!("SELECT {} FROM relay WHERE pubkey_id = $1", channel_type)) + .bind(pubkey_id) + .fetch_one(pool) + .await?; + + let updated_content = row.0; + if !updated_content.is_empty() { + return Ok(updated_content); + } + } + } +} diff --git a/payjoin-relay/src/main.rs b/payjoin-relay/src/main.rs new file mode 100644 index 00000000..dc663053 --- /dev/null +++ b/payjoin-relay/src/main.rs @@ -0,0 +1,232 @@ +use std::env; +use std::net::SocketAddr; +use std::str::FromStr; + +use anyhow::Result; +use hyper::server::conn::AddrIncoming; +use hyper::server::Builder; +use hyper::service::{make_service_fn, service_fn}; +use hyper::{Body, HeaderMap, Method, Request, Response, Server, StatusCode}; +use tracing::{debug, error, info}; +use tracing_subscriber::filter::LevelFilter; +use tracing_subscriber::EnvFilter; + +const DEFAULT_RELAY_PORT: &str = "8080"; +const DEFAULT_DB_HOST: &str = "localhost:5432"; +const DEFAULT_TIMEOUT_SECS: u64 = 30; +const MAX_BUFFER_SIZE: usize = 65536; +const V1_UNAVAILABLE_RES_JSON: &str = r#"{{"errorCode": "unavailable", "message": "V2 receiver offline. V1 sends require synchronous communications."}}"#; + +mod db; +use crate::db::DbPool; + +#[tokio::main] +async fn main() -> Result<(), Box> { + init_logging(); + + let relay_port = env::var("PJ_RELAY_PORT").unwrap_or_else(|_| DEFAULT_RELAY_PORT.to_string()); + let timeout_secs = env::var("PJ_RELAY_TIMEOUT_SECS") + .map(|s| s.parse().expect("Invalid timeout")) + .unwrap_or(DEFAULT_TIMEOUT_SECS); + let timeout = std::time::Duration::from_secs(timeout_secs); + let db_host = env::var("PJ_DB_HOST").unwrap_or_else(|_| DEFAULT_DB_HOST.to_string()); + + let pool = DbPool::new(timeout, db_host).await?; + let make_svc = make_service_fn(|_| { + let pool = pool.clone(); + async move { + let handler = move |req| handle_web_req(pool.clone(), req); + Ok::<_, hyper::Error>(service_fn(handler)) + } + }); + + // Parse the bind address using the provided port + let bind_addr_str = format!("0.0.0.0:{}", relay_port); + let bind_addr: SocketAddr = bind_addr_str.parse()?; + let server = init_server(&bind_addr)?.serve(make_svc); + info!("Serverless payjoin relay awaiting HTTP connection at {}", bind_addr_str); + Ok(server.await?) +} + +fn init_logging() { + let env_filter = + EnvFilter::builder().with_default_directive(LevelFilter::INFO.into()).from_env_lossy(); + + tracing_subscriber::fmt().with_target(true).with_level(true).with_env_filter(env_filter).init(); + + println!("Logging initialized"); +} + +#[cfg(not(feature = "danger-local-https"))] +fn init_server(bind_addr: &SocketAddr) -> Result> { + Ok(Server::bind(bind_addr)) +} + +#[cfg(feature = "danger-local-https")] +fn init_server(bind_addr: &SocketAddr) -> Result> { + const LOCAL_CERT_FILE: &str = "localhost.der"; + + use std::io::Write; + + use rustls::{Certificate, PrivateKey}; + + let cert = rcgen::generate_simple_self_signed(vec!["localhost".to_string()])?; + let cert_der = cert.serialize_der()?; + let mut local_cert_path = std::env::temp_dir(); + local_cert_path.push(LOCAL_CERT_FILE); + println!("RELAY CERT PATH {:?}", &local_cert_path); + let mut file = std::fs::File::create(local_cert_path)?; + file.write_all(&cert_der)?; + let key = PrivateKey(cert.serialize_private_key_der()); + let certs = vec![Certificate(cert.serialize_der()?)]; + let incoming = AddrIncoming::bind(bind_addr)?; + let acceptor = hyper_rustls::TlsAcceptor::builder() + .with_single_cert(certs, key) + .map_err(|e| anyhow::anyhow!("TLS error: {}", e))? + .with_all_versions_alpn() + .with_incoming(incoming); + Ok(Server::builder(acceptor)) +} + +async fn handle_web_req(pool: DbPool, req: Request) -> Result> { + let path = req.uri().path().to_string(); + let (parts, body) = req.into_parts(); + + let path_segments: Vec<&str> = path.split('/').collect(); + debug!("{:?}", &path_segments); + let mut response = match (parts.method, path_segments.as_slice()) { + (Method::POST, &["", ""]) => post_enroll(body).await, + (Method::POST, &["", id]) => post_fallback(id, body, parts.headers, pool).await, + (Method::GET, &["", id]) => get_fallback(id, pool).await, + (Method::POST, &["", id, "payjoin"]) => post_payjoin(id, body, pool).await, + _ => Ok(not_found()), + } + .unwrap_or_else(|e| e.to_response()); + + // Allow CORS for third-party access + response + .headers_mut() + .insert("Access-Control-Allow-Origin", hyper::header::HeaderValue::from_static("*")); + + Ok(response) +} + +enum HandlerError { + PayloadTooLarge, + ReceiverOffline, + InternalServerError(anyhow::Error), + BadRequest(anyhow::Error), +} + +impl HandlerError { + fn to_response(&self) -> Response { + let (status, body) = match self { + HandlerError::PayloadTooLarge => (StatusCode::PAYLOAD_TOO_LARGE, Body::empty()), + HandlerError::ReceiverOffline => + (StatusCode::SERVICE_UNAVAILABLE, Body::from(V1_UNAVAILABLE_RES_JSON)), + HandlerError::BadRequest(e) => { + error!("Bad request: {}", e); + (StatusCode::BAD_REQUEST, Body::empty()) + } + HandlerError::InternalServerError(e) => { + error!("Internal server error: {}", e); + (StatusCode::INTERNAL_SERVER_ERROR, Body::empty()) + } + }; + + let mut res = Response::new(body); + *res.status_mut() = status; + res + } +} + +impl From for HandlerError { + fn from(e: hyper::http::Error) -> Self { HandlerError::InternalServerError(e.into()) } +} + +async fn post_enroll(body: Body) -> Result, HandlerError> { + use payjoin::{base64, bitcoin}; + let b64_config = base64::Config::new(base64::CharacterSet::UrlSafe, false); + let bytes = + hyper::body::to_bytes(body).await.map_err(|e| HandlerError::BadRequest(e.into()))?; + let base64_id = + String::from_utf8(bytes.to_vec()).map_err(|e| HandlerError::BadRequest(e.into()))?; + let pubkey_bytes: Vec = base64::decode_config(base64_id, b64_config) + .map_err(|e| HandlerError::BadRequest(e.into()))?; + let pubkey_string = + String::from_utf8(pubkey_bytes).map_err(|e| HandlerError::BadRequest(e.into()))?; + let pubkey = bitcoin::secp256k1::PublicKey::from_str(&pubkey_string) + .map_err(|e| HandlerError::BadRequest(e.into()))?; + tracing::info!("Enrolled valid pubkey: {:?}", pubkey); + Ok(Response::builder().status(StatusCode::NO_CONTENT).body(Body::empty())?) +} + +async fn post_fallback( + id: &str, + body: Body, + headers: HeaderMap, + pool: DbPool, +) -> Result, HandlerError> { + use hyper::header::HeaderValue; + + let id = shorten_string(id); + let is_async = headers.get("Async") == Some(&HeaderValue::from_static("true")); + let req = hyper::body::to_bytes(body) + .await + .map_err(|e| HandlerError::InternalServerError(e.into()))?; + if req.len() > MAX_BUFFER_SIZE { + return Err(HandlerError::PayloadTooLarge); + } + + match pool.push_req(&id, req.into()).await { + Ok(_) => (), + Err(e) => return Err(HandlerError::BadRequest(e.into())), + }; + + match pool.peek_res(&id).await { + Some(result) => match result { + Ok(buffered_res) => Ok(Response::new(Body::from(buffered_res))), + Err(e) => Err(HandlerError::BadRequest(e.into())), + }, + None => fallback_timeout_response(is_async), + } +} + +fn fallback_timeout_response(is_req_async: bool) -> Result, HandlerError> { + if is_req_async { + Ok(Response::builder().status(StatusCode::ACCEPTED).body(Body::empty())?) + } else { + Err(HandlerError::ReceiverOffline) + } +} + +async fn get_fallback(id: &str, pool: DbPool) -> Result, HandlerError> { + let id = shorten_string(id); + match pool.peek_req(&id).await { + Some(result) => match result { + Ok(buffered_req) => Ok(Response::new(Body::from(buffered_req))), + Err(e) => Err(HandlerError::BadRequest(e.into())), + }, + None => Ok(Response::builder().status(StatusCode::ACCEPTED).body(Body::empty())?), + } +} + +async fn post_payjoin(id: &str, body: Body, pool: DbPool) -> Result, HandlerError> { + let id = shorten_string(id); + let res = hyper::body::to_bytes(body) + .await + .map_err(|e| HandlerError::InternalServerError(e.into()))?; + + match pool.push_res(&id, res.into()).await { + Ok(_) => Ok(Response::builder().status(StatusCode::NO_CONTENT).body(Body::empty())?), + Err(e) => Err(HandlerError::BadRequest(e.into())), + } +} + +fn not_found() -> Response { + let mut res = Response::default(); + *res.status_mut() = StatusCode::NOT_FOUND; + res +} + +fn shorten_string(input: &str) -> String { input.chars().take(8).collect() } diff --git a/payjoin/Cargo.toml b/payjoin/Cargo.toml index 645bfaf8..63ea7384 100644 --- a/payjoin/Cargo.toml +++ b/payjoin/Cargo.toml @@ -27,8 +27,13 @@ rand = { version = "0.8.4", optional = true } url = "2.2.2" [dev-dependencies] -env_logger = "0.9.0" bitcoind = { version = "0.31.1", features = ["0_21_2"] } +env_logger = "0.9.0" +rustls = "0.21.9" +testcontainers = "0.15.0" +testcontainers-modules = { version = "0.1.3", features = ["postgres"] } +tokio = { version = "1.12.0", features = ["full"] } +ureq = "2.8.0" [package.metadata.docs.rs] features = ["send", "receive", "base64"] \ No newline at end of file diff --git a/payjoin/src/receive/mod.rs b/payjoin/src/receive/mod.rs index 74a72504..72e257ae 100644 --- a/payjoin/src/receive/mod.rs +++ b/payjoin/src/receive/mod.rs @@ -303,6 +303,25 @@ pub struct UncheckedProposal { } impl UncheckedProposal { + pub fn from_relay_response(mut body: impl std::io::Read) -> Result { + let mut buf = Vec::new(); + let _ = body.read_to_end(&mut buf); + let base64 = bitcoin::base64::decode(buf).map_err(InternalRequestError::Base64)?; + let unchecked_psbt = Psbt::deserialize(&base64).map_err(InternalRequestError::Psbt)?; + + let psbt = unchecked_psbt.validate().map_err(InternalRequestError::InconsistentPsbt)?; + log::debug!("Received original psbt: {:?}", psbt); + + // TODO accept parameters + // let pairs = url::form_urlencoded::parse(query.as_bytes()); + // let params = Params::from_query_pairs(pairs).map_err(InternalRequestError::SenderParams)?; + // log::debug!("Received request with params: {:?}", params); + + // TODO handle v1 and v2 + + Ok(UncheckedProposal { psbt, params: Params::default() }) + } + pub fn from_request( mut body: impl std::io::Read, query: &str, diff --git a/payjoin/src/send/mod.rs b/payjoin/src/send/mod.rs index e06fc1c1..32e5601f 100644 --- a/payjoin/src/send/mod.rs +++ b/payjoin/src/send/mod.rs @@ -353,6 +353,7 @@ impl<'a> RequestBuilder<'a> { /// /// You need to send this request over HTTP(S) to the receiver. #[non_exhaustive] +#[derive(Debug, Clone)] pub struct Request { /// URL to send the request to. /// diff --git a/payjoin/src/uri.rs b/payjoin/src/uri.rs index b8f32b37..eb3152e7 100644 --- a/payjoin/src/uri.rs +++ b/payjoin/src/uri.rs @@ -8,6 +8,7 @@ use url::Url; #[derive(Debug, Clone)] pub enum Payjoin { Supported(PayjoinParams), + V2Only(PayjoinParams), Unsupported, } @@ -15,6 +16,7 @@ impl Payjoin { pub fn pj_is_supported(&self) -> bool { match self { Payjoin::Supported(_) => true, + Payjoin::V2Only(_) => true, Payjoin::Unsupported => false, } } @@ -74,7 +76,7 @@ impl<'a> UriExtNetworkUnchecked<'a> for Uri<'a, NetworkUnchecked> { impl<'a> UriExt<'a> for Uri<'a, NetworkChecked> { fn check_pj_supported(self) -> Result, bip21::Uri<'a>> { match self.extras { - Payjoin::Supported(payjoin) => { + Payjoin::Supported(payjoin) | Payjoin::V2Only(payjoin) => { let mut uri = bip21::Uri::with_extras(self.address, payjoin); uri.amount = self.amount; uri.label = self.label; @@ -169,6 +171,11 @@ impl<'a> bip21::de::DeserializationState<'a> for DeserializationState { _endpoint: endpoint, disable_output_substitution: pjos.unwrap_or(false), })) + } else if endpoint.scheme() == "http" { + Ok(Payjoin::V2Only(PayjoinParams { + _endpoint: endpoint, + disable_output_substitution: pjos.unwrap_or(false), + })) } else { Err(PjParseError(InternalPjParseError::UnsecureEndpoint)) } diff --git a/payjoin/tests/integration.rs b/payjoin/tests/integration.rs index dd4cc545..ab378487 100644 --- a/payjoin/tests/integration.rs +++ b/payjoin/tests/integration.rs @@ -1,91 +1,47 @@ #[cfg(all(feature = "send", feature = "receive"))] mod integration { use std::collections::HashMap; + use std::env; + use std::process::Stdio; use std::str::FromStr; + use std::sync::Arc; + use bitcoin::address::NetworkChecked; use bitcoin::psbt::Psbt; - use bitcoin::{Amount, OutPoint}; + use bitcoin::{Amount, FeeRate, OutPoint}; use bitcoind::bitcoincore_rpc; use bitcoind::bitcoincore_rpc::core_rpc_json::{AddressType, WalletProcessPsbtResult}; use bitcoind::bitcoincore_rpc::RpcApi; use log::{debug, log_enabled, Level}; use payjoin::bitcoin::base64; - use payjoin::receive::Headers; + use payjoin::receive::{Headers, UncheckedProposal}; use payjoin::send::{Request, RequestBuilder}; - use payjoin::{bitcoin, Error, Uri}; + use payjoin::Uri; + use testcontainers::Container; + use testcontainers_modules::postgres::Postgres; + use testcontainers_modules::testcontainers::clients::Cli; + use tokio::process::{Child, Command}; + use tokio::task::spawn_blocking; - #[test] - fn integration_test() { - let _ = env_logger::try_init(); - let bitcoind_exe = std::env::var("BITCOIND_EXE") - .ok() - .or_else(|| bitcoind::downloaded_exe_path().ok()) - .expect("version feature or env BITCOIND_EXE is required for tests"); - let mut conf = bitcoind::Conf::default(); - conf.view_stdout = log_enabled!(Level::Debug); - let bitcoind = bitcoind::BitcoinD::with_conf(bitcoind_exe, &conf).unwrap(); - let receiver = bitcoind.create_wallet("receiver").unwrap(); - let receiver_address = - receiver.get_new_address(None, Some(AddressType::Bech32)).unwrap().assume_checked(); - let sender = bitcoind.create_wallet("sender").unwrap(); - let sender_address = - sender.get_new_address(None, Some(AddressType::Bech32)).unwrap().assume_checked(); - bitcoind.client.generate_to_address(1, &receiver_address).unwrap(); - bitcoind.client.generate_to_address(101, &sender_address).unwrap(); + const EXAMPLE_URL: &str = "https://example.com"; + const RELAY_URL: &str = "https://localhost:8088"; + const LOCAL_CERT_FILE: &str = "localhost.der"; - assert_eq!( - Amount::from_btc(50.0).unwrap(), - receiver.get_balances().unwrap().mine.trusted, - "receiver doesn't own bitcoin" - ); + type BoxError = Box; - assert_eq!( - Amount::from_btc(50.0).unwrap(), - sender.get_balances().unwrap().mine.trusted, - "sender doesn't own bitcoin" - ); + #[test] + fn v1_to_v1() -> Result<(), BoxError> { + let _ = env_logger::try_init(); + let (_bitcoind, sender, receiver) = init_bitcoind_sender_receiver()?; // Receiver creates the payjoin URI - let pj_receiver_address = receiver.get_new_address(None, None).unwrap().assume_checked(); - let amount = Amount::from_btc(1.0).unwrap(); - let pj_uri_string = format!( - "{}?amount={}&pj=https://example.com", - pj_receiver_address.to_qr_uri(), - amount.to_btc() - ); - let pj_uri = Uri::from_str(&pj_uri_string).unwrap(); - let pj_uri = pj_uri.assume_checked(); + let pj_receiver_address = receiver.get_new_address(None, None)?.assume_checked(); + let pj_uri = build_pj_uri(pj_receiver_address, Amount::ONE_BTC, EXAMPLE_URL); // Sender create a funded PSBT (not broadcasted) to address with amount given in the pj_uri - 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(payjoin::bitcoin::Amount::from_sat(2000)), - ..Default::default() - }; - let psbt = sender - .wallet_create_funded_psbt( - &[], // inputs - &outputs, - None, // locktime - Some(options), - None, - ) - .expect("failed to create PSBT") - .psbt; - let psbt = sender.wallet_process_psbt(&psbt, None, None, None).unwrap().psbt; - let psbt = Psbt::from_str(&psbt).unwrap(); + let psbt = build_original_psbt(&sender, &pj_uri)?; debug!("Original psbt: {:#?}", psbt); - let (req, ctx) = RequestBuilder::from_psbt_and_uri(psbt, pj_uri) - .unwrap() - .build_with_additional_fee( - payjoin::bitcoin::Amount::from_sat(10000), - None, - bitcoin::FeeRate::ZERO, - false, - ) - .unwrap(); + let (req, ctx) = RequestBuilder::from_psbt_and_uri(psbt, pj_uri)? + .build_with_additional_fee(Amount::from_sat(10000), None, FeeRate::ZERO, false)?; let headers = HeaderMock::from_vec(&req.body); // ********************** @@ -97,16 +53,187 @@ mod integration { // ********************** // Inside the Sender: // Sender checks, signs, finalizes, extracts, and broadcasts - let checked_payjoin_proposal_psbt = ctx.process_response(&mut response.as_bytes()).unwrap(); - let payjoin_base64_string = base64::encode(&checked_payjoin_proposal_psbt.serialize()); - let payjoin_psbt = - sender.wallet_process_psbt(&payjoin_base64_string, None, None, None).unwrap().psbt; - let payjoin_psbt = sender.finalize_psbt(&payjoin_psbt, Some(false)).unwrap().psbt.unwrap(); - let payjoin_psbt = Psbt::from_str(&payjoin_psbt).unwrap(); - debug!("Sender's Payjoin PSBT: {:#?}", payjoin_psbt); + let checked_payjoin_proposal_psbt = ctx.process_response(&mut response.as_bytes())?; + let payjoin_tx = extract_pj_tx(&sender, checked_payjoin_proposal_psbt)?; + sender.send_raw_transaction(&payjoin_tx)?; + Ok(()) + } + + #[tokio::test] + async fn v2_to_v2() -> Result<(), BoxError> { + std::env::set_var("RUST_LOG", "debug"); + let _ = env_logger::builder().is_test(true).try_init(); + let docker = Cli::default(); + let (mut relay, _db) = init_relay(&docker).await; + let (_bitcoind, sender, receiver) = init_bitcoind_sender_receiver()?; + + // ********************** + // Inside the Receiver: + // Enroll with relay + let secp = bitcoin::secp256k1::Secp256k1::new(); + let mut rng = bitcoin::secp256k1::rand::thread_rng(); + let key = bitcoin::secp256k1::KeyPair::new(&secp, &mut rng); + let b64_config = base64::Config::new(base64::CharacterSet::UrlSafe, false); + let pubkey_base64 = base64::encode_config(key.public_key().to_string(), b64_config); + let pk64 = pubkey_base64.clone(); + let enroll = + spawn_blocking(move || http_agent().post(RELAY_URL).send_string(&pk64)).await??; + assert!(enroll.status() == 204); + // Receiver creates the payjoin URI + let pj_receiver_address = receiver.get_new_address(None, None)?.assume_checked(); + let relay_endpoint = format!("{}/{}", RELAY_URL, &pubkey_base64); + let pj_uri = build_pj_uri(pj_receiver_address, Amount::ONE_BTC, &relay_endpoint); + + // ********************** + // Inside the Sender: + // Create a funded PSBT (not broadcasted) to address with amount given in the pj_uri + let psbt = build_original_psbt(&sender, &pj_uri)?; + debug!("Original psbt: {:#?}", psbt); + let (req, ctx) = RequestBuilder::from_psbt_and_uri(psbt, pj_uri)? + .build_with_additional_fee(Amount::from_sat(10000), None, FeeRate::ZERO, false)?; + log::info!("send fallback v2"); + log::debug!("Request: {:#?}", &req.body); + let response = spawn_blocking(move || { + http_agent() + .post(req.url.as_str()) + .set("Content-Type", "text/plain") + .set("Async", "true") + .send_string(String::from_utf8(req.body).unwrap().as_ref()) + }) + .await??; + log::info!("Response: {:#?}", &response); + assert!(response.status() == 202); + // no response body yet since we are async and pushed fallback_psbt to the buffer + + // ********************** + // Inside the Receiver: + // this data would transit from one party to another over the network in production + let receive_endpoint = format!("{}/{}", RELAY_URL, &pubkey_base64); + let response = spawn_blocking(move || http_agent().get(&receive_endpoint).call()).await??; + let response = handle_relay_response(response.into_reader(), receiver); + // this response would be returned as http response to the sender + + // ********************** + // Inside the Sender: + // Sender checks, signs, finalizes, extracts, and broadcasts + let checked_payjoin_proposal_psbt = ctx.process_response(&mut response.as_bytes())?; + let payjoin_tx = extract_pj_tx(&sender, checked_payjoin_proposal_psbt)?; + sender.send_raw_transaction(&payjoin_tx)?; + log::info!("sent"); + relay.kill().await?; + let output = &relay.wait_with_output().await?; + log::info!("Status: {}", output.status); + Ok(()) + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn v1_to_v2() -> Result<(), BoxError> { + std::env::set_var("RUST_LOG", "debug"); + let _ = env_logger::builder().is_test(true).try_init(); + let docker = Cli::default(); + let (mut relay, _db) = init_relay(&docker).await; + let (_bitcoind, sender, receiver) = init_bitcoind_sender_receiver()?; + + // ********************** + // Inside the Receiver: + // Enroll with relay + let secp = bitcoin::secp256k1::Secp256k1::new(); + let mut rng = bitcoin::secp256k1::rand::thread_rng(); + let key = bitcoin::secp256k1::KeyPair::new(&secp, &mut rng); + let b64_config = base64::Config::new(base64::CharacterSet::UrlSafe, false); + let pubkey_base64 = base64::encode_config(key.public_key().to_string(), b64_config); + let pk64 = pubkey_base64.clone(); + let enroll = + spawn_blocking(move || http_agent().post(RELAY_URL).send_string(&pk64.clone())) + .await? + .unwrap(); + assert!(enroll.status() == 204); - let payjoin_tx = payjoin_psbt.extract_tx(); - bitcoind.client.send_raw_transaction(&payjoin_tx).unwrap(); + // Receiver creates the payjoin URI + let pj_receiver_address = receiver.get_new_address(None, None).unwrap().assume_checked(); + let relay_endpoint = format!("{}/{}", RELAY_URL, &pubkey_base64); + let pj_uri = build_pj_uri(pj_receiver_address, Amount::ONE_BTC, &relay_endpoint); + + // ********************** + // Inside the V1 Sender: + // Create a funded PSBT (not broadcasted) to address with amount given in the pj_uri + let psbt = build_original_psbt(&sender, &pj_uri)?; + debug!("Original psbt: {:#?}", psbt); + let (req, ctx) = RequestBuilder::from_psbt_and_uri(psbt, pj_uri)? + .build_with_additional_fee(Amount::from_sat(10000), None, FeeRate::ZERO, false)?; + log::info!("send fallback v1 to offline receiver fail"); + let req_clone = req.clone(); + let res = spawn_blocking(move || { + http_agent() + .post(req_clone.url.as_str()) + .set("Content-Type", "text/plain") + .send_bytes(&req_clone.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 receiver_loop = tokio::task::spawn(async move { + let fallback_psbt_body = loop { + let pk64 = pubkey_base64.clone(); + let response = spawn_blocking(move || { + let receive_endpoint = format!("{}/{}", RELAY_URL, &pk64); + http_agent().get(&receive_endpoint).call() + }) + .await??; + + if response.status() == 200 { + debug!("GET'd fallback_psbt"); + break response.into_reader(); + } 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 relay response"); + let response = handle_relay_response(fallback_psbt_body, receiver); + debug!("Post payjoin_psbt to relay"); + // Respond with payjoin psbt within the time window the sender is willing to wait + let payjoin_endpoint = format!("{}/{}/payjoin", RELAY_URL, &pubkey_base64); + let response = + spawn_blocking(move || http_agent().post(&payjoin_endpoint).send_string(&response)) + .await??; + debug!("POSTed with payjoin_psbt response status {}", response.status()); + assert!(response.status() == 204); + Ok::<_, Box>(()) + }); + + // ********************** + // send fallback v1 to online receiver + log::info!("send fallback v1 to online receiver should succeed"); + let req_clone = req.clone(); + let response = spawn_blocking(move || { + http_agent() + .post(req_clone.url.as_str()) + .set("Content-Type", "text/plain") + .send_bytes(&req_clone.body) + .expect("Failed to send request") + }) + .await?; + log::info!("Response: {:#?}", &response); + assert!(response.status() == 200); + + let checked_payjoin_proposal_psbt = 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"); + relay.kill().await?; + let output = &relay.wait_with_output().await?; + log::info!("Status: {}", output.status); + Ok(()) } struct HeaderMock(HashMap); @@ -124,6 +251,102 @@ mod integration { } } + async fn init_relay<'a>(docker: &'a Cli) -> (Child, Container<'a, Postgres>) { + println!("Initializing relay server"); + env::set_var("PJ_RELAY_PORT", "8088"); + env::set_var("PJ_RELAY_TIMEOUT_SECS", "2"); + //env::set_var("PGPASSWORD", "welcome"); + let postgres = docker.run(Postgres::default()); + env::set_var("PJ_DB_HOST", format!("127.0.0.1:{}", postgres.get_host_port_ipv4(5432))); + println!("Postgres running on {}", postgres.get_host_port_ipv4(5432)); + compile_payjoin_relay().await.wait().await.unwrap(); + let workspace_root = env::var("CARGO_MANIFEST_DIR").unwrap(); + let binary_path = format!("{}/../target/debug/payjoin-relay", workspace_root); + let mut command = Command::new(binary_path); + command.stdout(Stdio::inherit()).stderr(Stdio::inherit()); + (command.spawn().unwrap(), postgres) + } + + async fn compile_payjoin_relay() -> Child { + // set payjoin relay target dir to payjoin-relay + let mut command = Command::new("cargo"); + command.stdout(Stdio::inherit()).stderr(Stdio::inherit()).args([ + "build", + "--package", + "payjoin-relay", + "--features", + "danger-local-https", + ]); + command.spawn().unwrap() + } + + 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_pj_uri( + address: bitcoin::Address, + amount: Amount, + pj: &str, + ) -> Uri<'_, NetworkChecked> { + let pj_uri_string = + format!("{}?amount={}&pj={}", address.to_qr_uri(), amount.to_btc(), pj,); + let pj_uri = Uri::from_str(&pj_uri_string).unwrap(); + pj_uri.assume_checked() + } + + 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)?) + } + // Receiver receive and process original_psbt from a sender // In production it it will come in as an HTTP request (over ssl or onion) fn handle_pj_request( @@ -138,7 +361,15 @@ mod integration { headers, ) .unwrap(); + handle_proposal(proposal, receiver) + } + fn handle_relay_response(res: impl std::io::Read, receiver: bitcoincore_rpc::Client) -> String { + let proposal = payjoin::receive::UncheckedProposal::from_relay_response(res).unwrap(); + handle_proposal(proposal, receiver) + } + + fn handle_proposal(proposal: UncheckedProposal, receiver: bitcoincore_rpc::Client) -> String { // 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(); @@ -226,4 +457,37 @@ mod integration { debug!("Receiver's Payjoin proposal PSBT: {:#?}", &psbt); base64::encode(&psbt.serialize()) } + + 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() -> ureq::Agent { + use rustls::client::ClientConfig; + use rustls::{Certificate, RootCertStore}; + use ureq::AgentBuilder; + + let mut local_cert_path = std::env::temp_dir(); + local_cert_path.push(LOCAL_CERT_FILE); + println!("TEST CERT PATH {:?}", &local_cert_path); + let cert_der = std::fs::read(local_cert_path).unwrap(); + let mut root_cert_store = RootCertStore::empty(); + root_cert_store.add(&Certificate(cert_der)).unwrap(); + let client_config = ClientConfig::builder() + .with_safe_defaults() + .with_root_certificates(root_cert_store) + .with_no_client_auth(); + + AgentBuilder::new().tls_config(Arc::new(client_config)).build() + } }