diff --git a/Cargo.lock b/Cargo.lock index fd83b51c..049a99ce 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,77 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" +[[package]] +name = "aead" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b613b8e1e3cf911a086f53f03bf286f52fd7a7258e4fa606f0ef220d39d8877" +dependencies = [ + "generic-array", + "rand_core 0.6.4", +] + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e8b47f52ea9bae42228d07ec09eb676433d7c4ed1ebdf0f1d1c29ed446f1ab8" +dependencies = [ + "cfg-if", + "cipher 0.3.0", + "cpufeatures 0.2.11", + "opaque-debug", +] + +[[package]] +name = "aes" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac1f845298e95f983ff1944b728ae08b8cebab80d684f0a832ed0fc74dfa27e2" +dependencies = [ + "cfg-if", + "cipher 0.4.4", + "cpufeatures 0.2.11", +] + +[[package]] +name = "aes-gcm" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df5f85a83a7d8b0442b6aa7b504b8212c1733da07b98aae43d4bc21b2cb3cdf6" +dependencies = [ + "aead 0.4.3", + "aes 0.7.5", + "cipher 0.3.0", + "ctr 0.8.0", + "ghash 0.4.4", + "subtle", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead 0.5.2", + "aes 0.8.3", + "cipher 0.4.4", + "ctr 0.9.2", + "ghash 0.5.0", + "subtle", +] + [[package]] name = "ahash" version = "0.7.7" @@ -28,6 +99,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 +121,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 +144,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,12 +207,28 @@ 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" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d86b93f97252c47b41663388e6d155714a9d0c398b99f1005cbc5f978b29f445" +[[package]] +name = "bhttp" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3dbe26a7ede99e55ac5ba2e1bcbfa1656a65d01e6b580dd6224eb4c26a20cc5" +dependencies = [ + "thiserror", + "url", +] + [[package]] name = "bip21" version = "0.3.1" @@ -200,6 +325,18 @@ name = "bitflags" version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "327762f6e5a765692301e5bb513e0d9fef63be86bbc14528052b1cd3e6f03e07" +dependencies = [ + "serde", +] + +[[package]] +name = "block-buffer" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4152116fd6e9dadb291ae18fc1ec3575ed6d84c29642d97890f4b4a3417297e4" +dependencies = [ + "generic-array", +] [[package]] name = "block-buffer" @@ -210,6 +347,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" @@ -264,6 +411,75 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "chacha20" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fee7ad89dc1128635074c268ee661f90c3f7e83d9fd12910608c36b47d6c3412" +dependencies = [ + "cfg-if", + "cipher 0.3.0", + "cpufeatures 0.1.5", + "zeroize", +] + +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher 0.4.4", + "cpufeatures 0.2.11", +] + +[[package]] +name = "chacha20poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1580317203210c517b6d44794abfbe600698276db18127e37ad3e69bf5e848e5" +dependencies = [ + "aead 0.4.3", + "chacha20 0.7.1", + "cipher 0.3.0", + "poly1305 0.7.2", + "zeroize", +] + +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead 0.5.2", + "chacha20 0.9.1", + "cipher 0.4.4", + "poly1305 0.8.0", + "zeroize", +] + +[[package]] +name = "cipher" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ee52072ec15386f770805afd189a01c8841be8696bed250fa2f13c4c0d6dfb7" +dependencies = [ + "generic-array", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", + "zeroize", +] + [[package]] name = "clap" version = "3.2.25" @@ -322,11 +538,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 +556,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" @@ -364,6 +586,15 @@ dependencies = [ "serde_json", ] +[[package]] +name = "cpufeatures" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66c99696f6c9dd7f35d486b9d04d7e6e202aa3e8c40d553f2fdf5e7e0c6a71ef" +dependencies = [ + "libc", +] + [[package]] name = "cpufeatures" version = "0.2.11" @@ -373,6 +604,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 +628,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" @@ -389,17 +654,125 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] +[[package]] +name = "crypto-mac" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1d1a86f49236c215f271d40892d5fc950490551400b02ef360692c29815c714" +dependencies = [ + "generic-array", + "subtle", +] + +[[package]] +name = "ctr" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "049bb91fb4aaf0e3c7efa6cd5ef877dbbbd15b39dad06d9948de4ec8a75761ea" +dependencies = [ + "cipher 0.3.0", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher 0.4.4", +] + +[[package]] +name = "curve25519-dalek" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b9fdf9972b2bd6af2d913799d9ebc165ea4d2e65878e329d9c6b372c4491b61" +dependencies = [ + "byteorder", + "digest 0.9.0", + "rand_core 0.5.1", + "subtle", + "zeroize", +] + +[[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 = "deranged" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eb30d70a07a3b04884d2677f06bec33509dc67ca60d92949e5535352d3191dc" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "digest" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066" +dependencies = [ + "generic-array", +] + [[package]] name = "digest" version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", + "block-buffer 0.10.4", + "const-oid", "crypto-common", + "subtle", ] [[package]] @@ -408,11 +781,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 +825,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" @@ -451,16 +850,22 @@ checksum = "25cbce373ec4653f1a01a31e8a5e5ec0c622dc27ff9c4e6606eefef5cbbed4a5" [[package]] name = "filetime" -version = "0.2.22" +version = "0.2.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4029edd3e734da6fe05b6cd7bd2960760a616bd2ddd0d59a0124746d6272af0" +checksum = "1ee447700ac8aa0b2f2bd7bc4462ad686ba06baa6727ac149a2d6277f0d240fd" dependencies = [ "cfg-if", "libc", - "redox_syscall 0.3.5", - "windows-sys 0.48.0", + "redox_syscall", + "windows-sys 0.52.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 +876,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 +902,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 +924,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff4dd66668b557604244583e3e1e1eada8c5c2e96a6d0d6653ede395b78bbacb" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -501,6 +933,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 +990,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]] @@ -546,6 +1023,26 @@ dependencies = [ "wasi 0.11.0+wasi-snapshot-preview1", ] +[[package]] +name = "ghash" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1583cc1656d7839fd3732b80cf4f38850336cdb9b8ded1cd399ca62958de3c99" +dependencies = [ + "opaque-debug", + "polyval 0.5.3", +] + +[[package]] +name = "ghash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d930750de5717d2dd0b8c0d42c076c0e884c81a73e6cab859bbd2339c71e3e40" +dependencies = [ + "opaque-debug", + "polyval 0.6.1", +] + [[package]] name = "gimli" version = "0.28.1" @@ -577,7 +1074,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 +1082,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 +1120,56 @@ 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.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01706d578d5c281058480e673ae4086a9f4710d8df1ad80a5b03e39ece5f886b" +dependencies = [ + "digest 0.9.0", + "hmac 0.11.0", +] + +[[package]] +name = "hkdf" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791a029f6b9fc27657f6f188ec6e5e43f6911f6f878e0dc5501396e09809d437" +dependencies = [ + "hmac 0.12.1", +] + +[[package]] +name = "hmac" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a2a2320eb7ec0ebe8da8f744d7812d9fc4cb4d09344ac01898dbcb6a20ae69b" +dependencies = [ + "crypto-mac", + "digest 0.9.0", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + [[package]] name = "home" version = "0.5.5" @@ -622,6 +1179,27 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "hpke" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf39e5461bfdc6ad0fbc97067519fcaf96a7a2e67f24cc0eb8a1e7c0c45af792" +dependencies = [ + "aead 0.5.2", + "aes-gcm 0.10.3", + "byteorder", + "chacha20poly1305 0.10.1", + "digest 0.10.7", + "generic-array", + "hkdf 0.12.3", + "hmac 0.12.1", + "rand_core 0.6.4", + "sha2 0.10.8", + "subtle", + "x25519-dalek", + "zeroize", +] + [[package]] name = "http" version = "0.2.11" @@ -635,9 +1213,9 @@ dependencies = [ [[package]] name = "http-body" -version = "0.4.5" +version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5f38f16d184e36f2408a55281cd658ecbd3ca05cce6d6510a176eca393e26d1" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" dependencies = [ "bytes", "http", @@ -702,6 +1280,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,11 +1316,29 @@ dependencies = [ "hashbrown 0.14.3", ] +[[package]] +name = "inout" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0c10553d664a4d0bcff9f4215d0aac67a639cc68ef660840afe309b807bc9f5" +dependencies = [ + "generic-array", +] + +[[package]] +name = "itertools" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25db6b064527c5d482d0423354fcd07a89a2dfe07b67892e62411946db7f07b0" +dependencies = [ + "either", +] + [[package]] name = "itoa" -version = "1.0.9" +version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af150ab688ff2122fcef229be89cb50dd66af9e01a4ff320cc137eecc9bacc38" +checksum = "b1a46d1a171d865aa5f83f92695765caa047a9b4cbae2cbf37dbd613a793fd4c" [[package]] name = "js-sys" @@ -785,12 +1387,32 @@ 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" -version = "0.2.150" +version = "0.2.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "302d7ab3130588088d277783b1e2d2e10c9e9e4a16dd9050e6ec93fb3e7048f4" + +[[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 = "89d92a4743f9a61002fae18374ed11e7973f530cb3a3255fb354818118b2203c" +checksum = "cf4e226dcd58b4be396f7bd3c20da8fdee2911400705297ba7d2d7cc2c30f716" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] [[package]] name = "linked-hash-map" @@ -800,9 +1422,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 +1442,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 0.10.7", +] + [[package]] name = "memchr" version = "2.6.4" @@ -855,24 +1496,93 @@ dependencies = [ ] [[package]] -name = "mio" -version = "0.8.9" +name = "mio" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f3d0b296e374a4e6f3c7b0a1f5a51d748a0d34c85e7dc48fc3fa9a87657fe09" +dependencies = [ + "libc", + "wasi 0.11.0+wasi-snapshot-preview1", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nu-ansi-term" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" +dependencies = [ + "overload", + "winapi", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +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 = "3dce281c5e46beae905d4de1870d8b1509a9142b62eedf18b443b011ca8343d0" +checksum = "7d03e6c028c5dc5cac6e2dec0efda81fc887605bb3d884578bb6d6bf7514e252" dependencies = [ - "libc", - "wasi 0.11.0+wasi-snapshot-preview1", - "windows-sys 0.48.0", + "autocfg", + "num-integer", + "num-traits", ] [[package]] -name = "nom" -version = "7.1.3" +name = "num-traits" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +checksum = "39e3200413f237f41ab11ad6d161bc7239c84dcb631773ccd7de3dfe4b5c267c" dependencies = [ - "memchr", - "minimal-lexical", + "autocfg", + "libm", ] [[package]] @@ -894,11 +1604,40 @@ dependencies = [ "memchr", ] +[[package]] +name = "ohttp" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eef3e1559d017cef55f296e593737811b65106eb822bac13622ed49cbfebcf62" +dependencies = [ + "aead 0.4.3", + "aes-gcm 0.9.4", + "byteorder", + "chacha20poly1305 0.8.0", + "hex", + "hkdf 0.11.0", + "hpke", + "lazy_static", + "log", + "rand", + "serde", + "serde_derive", + "sha2 0.9.9", + "thiserror", + "toml", +] + [[package]] name = "once_cell" -version = "1.18.0" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" + +[[package]] +name = "opaque-debug" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd8b5dd2ae5ed71462c540258bedcb51965123ad7e7ccf4b9a8cafaa4a63576d" +checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5" [[package]] name = "openssl-probe" @@ -922,6 +1661,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" @@ -940,11 +1685,17 @@ checksum = "4c42a9226546d68acdd9c0a280d17ce19bfe27a46bf68784e4066115788d008e" dependencies = [ "cfg-if", "libc", - "redox_syscall 0.4.1", + "redox_syscall", "smallvec", "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" @@ -955,12 +1706,21 @@ checksum = "8835116a5c179084a830efb3adc117ab007512b535bc1a21c991d3b32a6b44dd" name = "payjoin" version = "0.11.0" dependencies = [ + "bhttp", "bip21", "bitcoin", "bitcoind", + "chacha20poly1305 0.10.1", "env_logger", "log", + "ohttp", "rand", + "rustls", + "serde", + "testcontainers", + "testcontainers-modules", + "tokio", + "ureq", "url", ] @@ -987,6 +1747,24 @@ dependencies = [ "url", ] +[[package]] +name = "payjoin-relay" +version = "0.0.1" +dependencies = [ + "anyhow", + "bhttp", + "bitcoin", + "hyper", + "hyper-rustls", + "ohttp", + "rcgen", + "rustls", + "sqlx", + "tokio", + "tracing", + "tracing-subscriber", +] + [[package]] name = "pem" version = "3.0.2" @@ -997,6 +1775,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" @@ -1051,7 +1838,7 @@ checksum = "7c747191d4ad9e4a4ab9c8798f1e82a39affe7ef9648390b7e5548d18e099de6" dependencies = [ "once_cell", "pest", - "sha2", + "sha2 0.10.8", ] [[package]] @@ -1066,12 +1853,85 @@ 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" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26072860ba924cbfa98ea39c8c19b4dd6a4a25423dbdf219c1eca91aa0cf6964" +[[package]] +name = "poly1305" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "048aeb476be11a4b6ca432ca569e375810de9294ae78f4774e78ea98a9246ede" +dependencies = [ + "cpufeatures 0.2.11", + "opaque-debug", + "universal-hash 0.4.1", +] + +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures 0.2.11", + "opaque-debug", + "universal-hash 0.5.1", +] + +[[package]] +name = "polyval" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8419d2b623c7c0896ff2d5d96e2cb4ede590fed28fcc34934f4c33c036e620a1" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.11", + "opaque-debug", + "universal-hash 0.4.1", +] + +[[package]] +name = "polyval" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d52cff9d1d4dee5fe6d03729099f4a310a41179e0a10dbf542039873f2e826fb" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.11", + "opaque-debug", + "universal-hash 0.5.1", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.17" @@ -1128,7 +1988,7 @@ checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ "libc", "rand_chacha", - "rand_core", + "rand_core 0.6.4", ] [[package]] @@ -1138,9 +1998,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.4", ] +[[package]] +name = "rand_core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" + [[package]] name = "rand_core" version = "0.6.4" @@ -1158,19 +2024,10 @@ checksum = "52c4f3084aa3bc7dfbba4eff4fab2a54db4324965d8872ab933565e6fbd83bc6" dependencies = [ "pem", "ring 0.16.20", - "time 0.3.20", + "time 0.3.30", "yasna", ] -[[package]] -name = "redox_syscall" -version = "0.3.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "567664f262709473930a4bf9e51bf2ebf3348f2e748ccc50dea20646858f8f29" -dependencies = [ - "bitflags 1.3.2", -] - [[package]] name = "redox_syscall" version = "0.4.1" @@ -1182,32 +2039,47 @@ dependencies = [ [[package]] name = "regex" -version = "1.9.6" +version = "1.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebee201405406dbf528b8b672104ae6d6d63e6d118cb10e4d51abbc7b58044ff" +checksum = "380b951a9c5e80ddfd6136919eef32310721aa4aacd4889a8d39124b026ab343" dependencies = [ "aho-corasick", "memchr", - "regex-automata", - "regex-syntax", + "regex-automata 0.4.3", + "regex-syntax 0.8.2", ] [[package]] name = "regex-automata" -version = "0.3.9" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" +dependencies = [ + "regex-syntax 0.6.29", +] + +[[package]] +name = "regex-automata" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59b23e92ee4318893fa3fe3e6fb365258efbfe6ac6ab30f090cdcbb7aa37efa9" +checksum = "5f804c7828047e88b2d32e2d7fe5a105da8ee3264f01902f796c8e067dc2483f" dependencies = [ "aho-corasick", "memchr", - "regex-syntax", + "regex-syntax 0.8.2", ] [[package]] name = "regex-syntax" -version = "0.7.5" +version = "0.6.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" + +[[package]] +name = "regex-syntax" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbb5fb1acd8a1a18b3dd5be62d25485eb770e05afb408a9627d14d451bae12da" +checksum = "c08c74e62047bb2de4ff487b251e4a92e24f48745648451635cec7d591162d9f" [[package]] name = "ring" @@ -1226,9 +2098,9 @@ dependencies = [ [[package]] name = "ring" -version = "0.17.6" +version = "0.17.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "684d5e6e18f669ccebf64a92236bb7db9a34f07be010e3627368182027180866" +checksum = "688c63d65483050968b2a8937f7995f443e27041a0f7700aa59b0822aedebb74" dependencies = [ "cc", "getrandom", @@ -1249,6 +2121,26 @@ dependencies = [ "serde", ] +[[package]] +name = "rsa" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e5124fcb30e76a7e79bfee683a2746db83784b86289f6251b54b7950a0dfc" +dependencies = [ + "const-oid", + "digest 0.10.7", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + [[package]] name = "rust-ini" version = "0.18.0" @@ -1267,25 +2159,25 @@ checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76" [[package]] name = "rustix" -version = "0.38.25" +version = "0.38.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc99bc2d4f1fed22595588a013687477aedf3cdcfb26558c559edb67b4d9b22e" +checksum = "72e572a5e8ca657d7366229cdde4bd14c4eb5499a9573d4d366fe1b599daa316" dependencies = [ "bitflags 2.4.1", "errno", "libc", "linux-raw-sys", - "windows-sys 0.48.0", + "windows-sys 0.52.0", ] [[package]] name = "rustls" -version = "0.21.9" +version = "0.21.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "629648aced5775d558af50b2b4c7b02983a04b312126d45eeead26e7caa498b9" +checksum = "f9d5a6813c0759e4609cd494e8e725babae6a2ca7b62a5536a13daaec6fcb7ba" dependencies = [ "log", - "ring 0.17.6", + "ring 0.17.7", "rustls-webpki", "sct", ] @@ -1317,15 +2209,15 @@ version = "0.101.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" dependencies = [ - "ring 0.17.6", + "ring 0.17.7", "untrusted 0.9.0", ] [[package]] name = "ryu" -version = "1.0.15" +version = "1.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ad4cc8da4ef723ed60bced201181d83791ad433213d8c24efffda1eec85d741" +checksum = "f98d2aa92eebf49b69786be48e4477826b256916e84a57ff2a4f21923b48eb4c" [[package]] name = "schannel" @@ -1348,7 +2240,7 @@ version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" dependencies = [ - "ring 0.17.6", + "ring 0.17.7", "untrusted 0.9.0", ] @@ -1411,88 +2303,385 @@ version = "1.0.193" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "43576ca501357b9b071ac53cdc7da8ef0cbd9493d8df094cd821777ea6e894d3" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.39", + "proc-macro2", + "quote", + "syn 2.0.39", +] + +[[package]] +name = "serde_json" +version = "1.0.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d1c7e3eac408d115102c4c24ad393e0821bb3a5df4d506a80f85f7a742a526b" +dependencies = [ + "itoa", + "ryu", + "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 0.2.11", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d58a1e1bf39749807d89cf2d98ac2dfa0ff1cb3faa38fbb64dd88ac8013d800" +dependencies = [ + "block-buffer 0.9.0", + "cfg-if", + "cpufeatures 0.2.11", + "digest 0.9.0", + "opaque-debug", +] + +[[package]] +name = "sha2" +version = "0.10.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.11", + "digest 0.10.7", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8229b473baa5980ac72ef434c4415e70c4b5e71b423043adb4ba059f89c99a1" +dependencies = [ + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest 0.10.7", + "rand_core 0.6.4", +] + +[[package]] +name = "slab" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" +dependencies = [ + "autocfg", +] + +[[package]] +name = "smallvec" +version = "1.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dccd0940a2dcdf68d092b8cbab7dc0ad8fa938bf95787e1b916b0e3d0e8e970" + +[[package]] +name = "socket2" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7916fc008ca5542385b89a3d3ce689953c143e9304a9bf8beec1de48994c0d" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "socket2" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5fac59a5cb5dd637972e5fca70daf0523c9067fcdc4842f053dae04a18f8e9" +dependencies = [ + "libc", + "windows-sys 0.48.0", +] + +[[package]] +name = "spin" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" + +[[package]] +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 = "serde_json" -version = "1.0.108" +name = "sqlformat" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d1c7e3eac408d115102c4c24ad393e0821bb3a5df4d506a80f85f7a742a526b" +checksum = "ce81b7bd7c4493975347ef60d8c7e8b742d4694f4c49f93e0a12ea263938176c" dependencies = [ - "itoa", - "ryu", - "serde", + "itertools", + "nom", + "unicode_categories", ] [[package]] -name = "sha2" -version = "0.10.8" +name = "sqlx" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" +checksum = "dba03c279da73694ef99763320dea58b51095dfe87d001b1d4b5fe78ba8763cf" dependencies = [ - "cfg-if", - "cpufeatures", - "digest", + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", ] [[package]] -name = "signal-hook-registry" -version = "1.4.1" +name = "sqlx-core" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8229b473baa5980ac72ef434c4415e70c4b5e71b423043adb4ba059f89c99a1" +checksum = "d84b0a3c3739e220d94b3239fd69fb1f74bc36e16643423bd99de3b43c21bfbd" dependencies = [ - "libc", + "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 0.10.8", + "smallvec", + "sqlformat", + "thiserror", + "tokio", + "tokio-stream", + "tracing", + "url", ] [[package]] -name = "slab" -version = "0.4.9" +name = "sqlx-macros" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" +checksum = "89961c00dc4d7dffb7aee214964b065072bff69e36ddb9e2c107541f75e4f2a5" dependencies = [ - "autocfg", + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn 1.0.109", ] [[package]] -name = "smallvec" -version = "1.11.2" +name = "sqlx-macros-core" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4dccd0940a2dcdf68d092b8cbab7dc0ad8fa938bf95787e1b916b0e3d0e8e970" +checksum = "d0bd4519486723648186a08785143599760f7cc81c52334a55d6a83ea1e20841" +dependencies = [ + "atomic-write-file", + "dotenvy", + "either", + "heck", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2 0.10.8", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn 1.0.109", + "tempfile", + "tokio", + "url", +] [[package]] -name = "socket2" -version = "0.4.10" +name = "sqlx-mysql" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7916fc008ca5542385b89a3d3ce689953c143e9304a9bf8beec1de48994c0d" +checksum = "e37195395df71fd068f6e2082247891bc11e3289624bbc776a0cdfa1ca7f1ea4" dependencies = [ - "libc", - "winapi", + "atoi", + "base64 0.21.5", + "bitflags 2.4.1", + "byteorder", + "bytes", + "crc", + "digest 0.10.7", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array", + "hex", + "hkdf 0.12.3", + "hmac 0.12.1", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "percent-encoding", + "rand", + "rsa", + "serde", + "sha1", + "sha2 0.10.8", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "whoami", ] [[package]] -name = "socket2" -version = "0.5.5" +name = "sqlx-postgres" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b5fac59a5cb5dd637972e5fca70daf0523c9067fcdc4842f053dae04a18f8e9" +checksum = "d6ac0ac3b7ccd10cc96c7ab29791a7dd236bd94021f31eec7ba3d46a74aa1c24" dependencies = [ - "libc", - "windows-sys 0.48.0", + "atoi", + "base64 0.21.5", + "bitflags 2.4.1", + "byteorder", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "hex", + "hkdf 0.12.3", + "hmac 0.12.1", + "home", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "rand", + "serde", + "serde_json", + "sha1", + "sha2 0.10.8", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "whoami", ] [[package]] -name = "spin" -version = "0.5.2" +name = "sqlx-sqlite" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" +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 = "spin" -version = "0.9.8" +name = "stringprep" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +checksum = "bb41d74e231a107a1b4ee36bd1214b11285b77768d2e3824aedafa988fd36ee6" +dependencies = [ + "finl_unicode", + "unicode-bidi", + "unicode-normalization", +] [[package]] name = "strsim" @@ -1500,6 +2689,12 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" +[[package]] +name = "subtle" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bdef32e8150c2a081110b42772ffe7d7c9032b606bc226c8260fd97e0976601" + [[package]] name = "syn" version = "1.0.109" @@ -1541,7 +2736,7 @@ checksum = "7ef1adac450ad7f4b3c28589471ade84f25f731a7a0fe30d71dfa9f60fd808e5" dependencies = [ "cfg-if", "fastrand", - "redox_syscall 0.4.1", + "redox_syscall", "rustix", "windows-sys 0.48.0", ] @@ -1555,6 +2750,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 0.12.1", + "log", + "rand", + "serde", + "serde_json", + "sha2 0.10.8", +] + +[[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 +2802,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" @@ -1594,19 +2825,21 @@ dependencies = [ [[package]] name = "time" -version = "0.3.20" +version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd0cbfecb4d19b5ea75bb31ad904eb5b9fa13f21079c3b92017ebdf4999a5890" +checksum = "c4a34ab300f2dee6e562c10a046fc05e358b29f9bf92277f30c3c8d82275f6f5" dependencies = [ + "deranged", + "powerfmt", "serde", "time-core", ] [[package]] name = "time-core" -version = "0.1.0" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e153e1f1acaef8acc537e68b44906d2db6436e2b35ac2c6b42640fff91f00fd" +checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" [[package]] name = "tinyvec" @@ -1625,9 +2858,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.34.0" +version = "1.35.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0c014766411e834f7af5b8f4cf46257aab4036ca95e9d2c144a10f59ad6f5b9" +checksum = "841d45b238a16291a4e1584e61820b8ae57d696cc5015c459c229ccc6990cc1c" dependencies = [ "backtrace", "bytes", @@ -1663,6 +2896,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 +2942,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,13 +2966,43 @@ 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]] name = "try-lock" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3528ecfd12c466c6f163363caf2d02a71161dd5e1cc6ae7b34207ea2d42d81ed" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "typenum" @@ -1731,9 +3018,9 @@ checksum = "ed646292ffc8188ef8ea4d1e0e0150fb15a5c2e12ad9b8fc191ae7a8a7f3c4b9" [[package]] name = "unicode-bidi" -version = "0.3.13" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92888ba5573ff080736b3648696b70cafad7d250551175acbaa4e0385b3e1460" +checksum = "6f2528f27a9eb2b21e69c95319b30bd0efd85d09c379741b0f78ea1d86be2416" [[package]] name = "unicode-ident" @@ -1750,6 +3037,38 @@ 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 = "universal-hash" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f214e8f697e925001e66ec2c6e37a4ef93f0f78c2eed7814394e10c62025b05" +dependencies = [ + "generic-array", + "subtle", +] + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + [[package]] name = "untrusted" version = "0.7.1" @@ -1790,6 +3109,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 +3236,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" @@ -2062,13 +3405,26 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dff9641d1cd4be8d1a070daf9e3773c5f67e78b4d9d42263020c057706765c04" +[[package]] +name = "x25519-dalek" +version = "2.0.0-pre.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5da623d8af10a62342bcbbb230e33e58a63255a58012f8653c578e54bab48df" +dependencies = [ + "curve25519-dalek", + "rand_core 0.6.4", + "zeroize", +] + [[package]] name = "xattr" -version = "1.0.1" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4686009f71ff3e5c4dbcf1a282d0a44db3f021ba69350cd42086b3e5f1c6985" +checksum = "d367426ae76bdfce3d8eaea6e94422afd6def7d46f9c89e2980309115b3c2c41" dependencies = [ "libc", + "linux-raw-sys", + "rustix", ] [[package]] @@ -2086,7 +3442,47 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" dependencies = [ - "time 0.3.20", + "time 0.3.30", +] + +[[package]] +name = "zerocopy" +version = "0.7.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "306dca4455518f1f31635ec308b6b3e4eb1b11758cefafc782827d0aa7acb5c7" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.7.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be912bf68235a88fbefd1b73415cb218405958d1655b2ece9035a19920bdf6ba" +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" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.39", ] [[package]] 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-cli/Cargo.toml b/payjoin-cli/Cargo.toml index fe757a53..ab1699b6 100644 --- a/payjoin-cli/Cargo.toml +++ b/payjoin-cli/Cargo.toml @@ -20,6 +20,7 @@ path = "src/main.rs" [features] native-certs = ["ureq/native-certs"] danger-local-https = ["rcgen", "rustls/dangerous_configuration", "hyper-rustls"] +v2 = ["payjoin/v2"] [dependencies] anyhow = "1.0.70" @@ -41,3 +42,4 @@ url = "2.2.1" [dev-dependencies] bitcoind = { version = "0.31.1", features = ["0_21_2"] } +tokio = { version = "1.12.0", features = ["full"] } diff --git a/payjoin-cli/src/app.rs b/payjoin-cli/src/app.rs index 8531fa04..768f5147 100644 --- a/payjoin-cli/src/app.rs +++ b/payjoin-cli/src/app.rs @@ -1,5 +1,6 @@ use std::collections::{HashMap, HashSet}; use std::convert::TryFrom; +#[cfg(not(feature = "v2"))] use std::net::SocketAddr; use std::str::FromStr; use std::sync::{Arc, Mutex}; @@ -10,12 +11,20 @@ use bitcoincore_rpc::jsonrpc::serde_json; use bitcoincore_rpc::RpcApi; use clap::ArgMatches; use config::{Config, File, FileFormat}; +#[cfg(not(feature = "v2"))] use hyper::service::{make_service_fn, service_fn}; +#[cfg(not(feature = "v2"))] use hyper::{Body, Method, Request, Response, Server, StatusCode}; -use payjoin::bitcoin; use payjoin::bitcoin::psbt::Psbt; -use payjoin::receive::{Error, ProvisionalProposal}; +use payjoin::bitcoin::{self, base64}; +#[cfg(feature = "v2")] +use payjoin::receive::v2; +use payjoin::receive::Error; +#[cfg(not(feature = "v2"))] +use payjoin::receive::{PayjoinProposal, UncheckedProposal}; +use payjoin::send::RequestContext; use serde::{Deserialize, Serialize}; +#[cfg(feature = "v2")] use tokio::task::spawn_blocking; #[cfg(feature = "danger-local-https")] @@ -50,10 +59,158 @@ impl App { .with_context(|| "Failed to connect to bitcoind") } + #[cfg(feature = "v2")] pub async fn send_payjoin(&self, bip21: &str, fee_rate: &f32) -> Result<()> { + let req_ctx = self.create_pj_request(bip21, fee_rate)?; + log::debug!("Awaiting response"); + let res = self.long_poll_post(req_ctx).await?; + self.process_pj_response(res)?; + Ok(()) + } + + #[cfg(not(feature = "v2"))] + pub async fn send_payjoin(&self, bip21: &str, fee_rate: &f32) -> Result<()> { + let (req, ctx) = self.create_pj_request(bip21, fee_rate)?.extract_v1()?; + let http = http_agent()?; + let body = String::from_utf8(req.body.clone()).unwrap(); + println!("Sending fallback request to {}", &req.url); + let response = http + .post(req.url.as_str()) + .set("Content-Type", "text/plain") + .send_string(&body.clone()) + .with_context(|| "HTTP request failed")?; + let fallback_tx = Psbt::from_str(&body) + .map_err(|e| anyhow!("Failed to load PSBT from base64: {}", e))? + .extract_tx(); + println!("Sent fallback transaction txid: {}", fallback_tx.txid()); + println!( + "Sent fallback transaction hex: {:#}", + payjoin::bitcoin::consensus::encode::serialize_hex(&fallback_tx) + ); + let psbt = ctx + .process_response(&mut response.into_reader()) + .map_err(|e| anyhow!("Failed to process response {}", e))?; + + self.process_pj_response(psbt)?; + Ok(()) + } + + #[cfg(feature = "v2")] + pub async fn receive_payjoin(self, amount_arg: &str) -> Result<()> { + use v2::Enroller; + + let mut enroller = Enroller::from_relay_config( + &self.config.pj_endpoint, + &self.config.ohttp_config, + &self.config.ohttp_proxy, + ); + let (req, ctx) = enroller.extract_req()?; + log::debug!("Enrolling receiver"); + let ohttp_response = spawn_blocking(move || { + let http = http_agent()?; + http.post(req.url.as_ref()).send_bytes(&req.body).with_context(|| "HTTP request failed") + }) + .await??; + + let mut enrolled = enroller + .process_res(ohttp_response.into_reader(), ctx) + .map_err(|_| anyhow!("Enrollment failed"))?; + log::debug!("Enrolled receiver"); + + let pj_uri_string = + self.construct_payjoin_uri(amount_arg, Some(&enrolled.fallback_target()))?; + println!( + "Listening at {}. Configured to accept payjoin at BIP 21 Payjoin Uri:", + self.config.pj_host + ); + println!("{}", pj_uri_string); + + log::debug!("Awaiting proposal"); + let res = self.long_poll_fallback(&mut enrolled).await?; + log::debug!("Received request"); + let payjoin_proposal = self + .process_v2_proposal(res) + .map_err(|e| anyhow!("Failed to process proposal {}", e))?; + log::debug!("Posting payjoin back"); + let (req, ohttp_ctx) = payjoin_proposal + .extract_v2_req() + .map_err(|e| anyhow!("v2 req extraction failed {}", e))?; + let http = http_agent()?; + let res = http.post(req.url.as_str()).send_bytes(&req.body)?; + let mut buf = Vec::new(); + let _ = res.into_reader().read_to_end(&mut buf)?; + let res = payjoin_proposal.deserialize_res(buf, ohttp_ctx); + log::debug!("Received response {:?}", res); + Ok(()) + } + + #[cfg(not(feature = "v2"))] + pub async fn receive_payjoin(self, amount_arg: &str) -> Result<()> { + let pj_uri_string = self.construct_payjoin_uri(amount_arg, None)?; + println!( + "Listening at {}. Configured to accept payjoin at BIP 21 Payjoin Uri:", + self.config.pj_host + ); + println!("{}", pj_uri_string); + + self.start_http_server().await?; + Ok(()) + } + + #[cfg(feature = "v2")] + async fn long_poll_post(&self, req_ctx: payjoin::send::RequestContext<'_>) -> Result { + loop { + let (req, ctx) = req_ctx.extract_v2(&self.config.ohttp_proxy)?; + println!("Sending fallback request to {}", &req.url); + let http = http_agent()?; + let response = spawn_blocking(move || { + http.post(req.url.as_ref()) + .set("Content-Type", "text/plain") + .send_bytes(&req.body) + .with_context(|| "HTTP request failed") + }) + .await??; + + println!("Sent fallback transaction"); + let psbt = ctx.process_response(&mut response.into_reader())?; + if let Some(psbt) = psbt { + return Ok(psbt); + } else { + log::info!("No response yet for POST payjoin request, retrying some seconds"); + std::thread::sleep(std::time::Duration::from_secs(5)); + } + } + } + + #[cfg(feature = "v2")] + async fn long_poll_fallback( + &self, + enrolled: &mut payjoin::receive::v2::Enrolled, + ) -> Result { + loop { + let (req, context) = + enrolled.extract_req().map_err(|_| anyhow!("Failed to extract request"))?; + log::debug!("GET fallback_psbt"); + let http = http_agent()?; + let ohttp_response = + spawn_blocking(move || http.post(req.url.as_str()).send_bytes(&req.body)).await??; + + let proposal = enrolled + .process_res(ohttp_response.into_reader(), context) + .map_err(|_| anyhow!("GET fallback failed"))?; + log::debug!("got response"); + match proposal { + Some(proposal) => break Ok(proposal), + None => std::thread::sleep(std::time::Duration::from_secs(5)), + } + } + } + + fn create_pj_request<'a>(&self, bip21: &'a str, fee_rate: &f32) -> Result> { let uri = payjoin::Uri::try_from(bip21) - .map_err(|e| anyhow!("Failed to create URI from BIP21: {}", e))? - .assume_checked(); + .map_err(|e| anyhow!("Failed to create URI from BIP21: {}", e))?; + + let uri = uri.assume_checked(); let amount = uri.amount.ok_or_else(|| anyhow!("please specify the amount in the Uri"))?; @@ -90,29 +247,16 @@ impl App { .psbt; let psbt = Psbt::from_str(&psbt).with_context(|| "Failed to load PSBT from base64")?; log::debug!("Original psbt: {:#?}", psbt); - let fallback_tx = psbt.clone().extract_tx(); - let (req, ctx) = payjoin::send::RequestBuilder::from_psbt_and_uri(psbt, uri) + let req_ctx = payjoin::send::RequestBuilder::from_psbt_and_uri(psbt, uri) .with_context(|| "Failed to build payjoin request")? .build_recommended(fee_rate) .with_context(|| "Failed to build payjoin request")?; - let http = http_agent()?; - println!("Sending fallback request to {}", &req.url); - let response = spawn_blocking(move || { - http.post(req.url.as_str()) - .set("Content-Type", "text/plain") - .send_bytes(&req.body) - .with_context(|| "HTTP request failed") - }) - .await??; - println!("Sent fallback transaction txid: {}", fallback_tx.txid()); - println!( - "Sent fallback transaction hex: {:#}", - payjoin::bitcoin::consensus::encode::serialize_hex(&fallback_tx) - ); + + Ok(req_ctx) + } + + fn process_pj_response(&self, psbt: Psbt) -> Result { // TODO display well-known errors and log::debug the rest - let psbt = ctx - .process_response(&mut response.into_reader()) - .with_context(|| "Failed to process response")?; log::debug!("Proposed psbt: {:#?}", psbt); let psbt = self .bitcoind()? @@ -130,30 +274,12 @@ impl App { .send_raw_transaction(&tx) .with_context(|| "Failed to send raw transaction")?; println!("Payjoin sent: {}", txid); - Ok(()) + Ok(txid) } - pub async fn receive_payjoin(self, amount_arg: &str) -> Result<()> { - use payjoin::Uri; - - let pj_receiver_address = self.bitcoind()?.get_new_address(None, None)?.assume_checked(); - let amount = Amount::from_sat(amount_arg.parse()?); - let pj_uri_string = format!( - "{}?amount={}&pj={}", - pj_receiver_address.to_qr_uri(), - amount.to_btc(), - self.config.pj_endpoint - ); - // check that the URI is corrctly formatted - let _pj_uri = Uri::from_str(&pj_uri_string) - .map_err(|e| anyhow!("Constructed a bad URI string from args: {}", e))? - .assume_checked(); + #[cfg(not(feature = "v2"))] + async fn start_http_server(self) -> Result<()> { let bind_addr: SocketAddr = self.config.pj_host.parse()?; - println!( - "Listening at {}. Configured to accept payjoin at BIP 21 Payjoin Uri:", - self.config.pj_host - ); - println!("{}", pj_uri_string); #[cfg(feature = "danger-local-https")] let server = { @@ -181,9 +307,9 @@ impl App { #[cfg(not(feature = "danger-local-https"))] let server = Server::bind(&bind_addr); - + let app = self.clone(); let make_svc = make_service_fn(|_| { - let app = self.clone(); + let app = app.clone(); async move { let handler = move |req| app.clone().handle_web_request(req); Ok::<_, hyper::Error>(service_fn(handler)) @@ -193,6 +319,34 @@ impl App { Ok(()) } + fn construct_payjoin_uri( + &self, + amount_arg: &str, + fallback_target: Option<&str>, + ) -> Result { + let pj_receiver_address = self.bitcoind()?.get_new_address(None, None)?.assume_checked(); + let amount = Amount::from_sat(amount_arg.parse()?); + let pj_part = match fallback_target { + Some(target) => target, + None => self.config.pj_endpoint.as_str(), + }; + + let pj_uri_string = format!( + "{}?amount={}&pj={}&ohttp={}", + pj_receiver_address.to_qr_uri(), + amount.to_btc(), + pj_part, + self.config.ohttp_config, + ); + + // to check uri validity + let _pj_uri = payjoin::Uri::from_str(&pj_uri_string) + .map_err(|e| anyhow!("Constructed a bad URI string from args: {}", e))?; + + Ok(pj_uri_string) + } + + #[cfg(not(feature = "v2"))] async fn handle_web_request(self, req: Request) -> Result> { log::debug!("Received request: {:?}", req); let mut response = match (req.method(), req.uri().path()) { @@ -236,6 +390,7 @@ impl App { Ok(response) } + #[cfg(not(feature = "v2"))] fn handle_get_bip21(&self, amount: Option) -> Result, Error> { let address = self .bitcoind() @@ -260,6 +415,7 @@ impl App { Ok(Response::new(Body::from(uri_string))) } + #[cfg(not(feature = "v2"))] async fn handle_payjoin_post(&self, req: Request) -> Result, Error> { let (parts, body) = req.into_parts(); let headers = Headers(&parts.headers); @@ -270,6 +426,15 @@ impl App { let proposal = payjoin::receive::UncheckedProposal::from_request(body, query_string, headers)?; + let payjoin_proposal = self.process_v1_proposal(proposal)?; + let psbt = payjoin_proposal.psbt(); + let body = base64::encode(psbt.serialize()); + println!("Responded with Payjoin proposal {}", psbt.clone().extract_tx().txid()); + Ok(Response::new(Body::from(body))) + } + + #[cfg(not(feature = "v2"))] + fn process_v1_proposal(&self, proposal: UncheckedProposal) -> Result { let bitcoind = self.bitcoind().map_err(|e| Error::Server(e.into()))?; // in a payment processor where the sender could go offline, this is where you schedule to broadcast the original_tx @@ -340,26 +505,110 @@ impl App { .assume_checked(); provisional_payjoin.substitute_output_address(receiver_substitute_address); - let payjoi_proposal = provisional_payjoin.finalize_proposal( + let payjoin_proposal = provisional_payjoin.finalize_proposal( |psbt: &Psbt| { bitcoind - .wallet_process_psbt( - &payjoin::base64::encode(psbt.serialize()), - None, - None, - Some(false), - ) + .wallet_process_psbt(&base64::encode(psbt.serialize()), None, None, Some(false)) .map(|res| Psbt::from_str(&res.psbt).map_err(|e| Error::Server(e.into()))) .map_err(|e| Error::Server(e.into()))? }, Some(bitcoin::FeeRate::MIN), )?; - let payjoin_proposal_psbt = payjoi_proposal.psbt(); - log::debug!("Receiver's Payjoin proposal PSBT Rsponse: {:#?}", payjoin_proposal_psbt); + let payjoin_proposal_psbt = payjoin_proposal.psbt(); + println!( + "Responded with Payjoin proposal {}", + payjoin_proposal_psbt.clone().extract_tx().txid() + ); + Ok(payjoin_proposal) + } + + #[cfg(feature = "v2")] + fn process_v2_proposal( + &self, + proposal: v2::UncheckedProposal, + ) -> Result { + let bitcoind = self.bitcoind().map_err(|e| Error::Server(e.into()))?; + + // 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(); + + // The network is used for checks later + let network = + bitcoind.get_blockchain_info().map_err(|e| Error::Server(e.into())).and_then( + |info| bitcoin::Network::from_str(&info.chain).map_err(|e| Error::Server(e.into())), + )?; + + // Receive Check 1: Can Broadcast + let proposal = proposal.check_can_broadcast(|tx| { + let raw_tx = bitcoin::consensus::encode::serialize_hex(&tx); + let mempool_results = + bitcoind.test_mempool_accept(&[raw_tx]).map_err(|e| Error::Server(e.into()))?; + match mempool_results.first() { + Some(result) => Ok(result.allowed), + None => Err(Error::Server( + anyhow!("No mempool results returned on broadcast check").into(), + )), + } + })?; + log::trace!("check1"); + + // Receive Check 2: receiver can't sign for proposal inputs + let proposal = proposal.check_inputs_not_owned(|input| { + if let Ok(address) = bitcoin::Address::from_script(input, network) { + bitcoind + .get_address_info(&address) + .map(|info| info.is_mine.unwrap_or(false)) + .map_err(|e| Error::Server(e.into())) + } else { + Ok(false) + } + })?; + log::trace!("check2"); + // Receive Check 3: receiver can't sign for proposal inputs + let proposal = proposal.check_no_mixed_input_scripts()?; + log::trace!("check3"); + + // Receive Check 4: have we seen this input before? More of a check for non-interactive i.e. payment processor receivers. + let payjoin = proposal.check_no_inputs_seen_before(|input| { + Ok(!self.insert_input_seen_before(*input).map_err(|e| Error::Server(e.into()))?) + })?; + log::trace!("check4"); + + let mut provisional_payjoin = payjoin.identify_receiver_outputs(|output_script| { + if let Ok(address) = bitcoin::Address::from_script(output_script, network) { + bitcoind + .get_address_info(&address) + .map(|info| info.is_mine.unwrap_or(false)) + .map_err(|e| Error::Server(e.into())) + } else { + Ok(false) + } + })?; - let payload = payjoin::base64::encode(payjoin_proposal_psbt.serialize()); - log::info!("successful response"); - Ok(Response::new(Body::from(payload))) + if !self.config.sub_only { + // Select receiver payjoin inputs. + _ = try_contributing_inputs(&mut provisional_payjoin.inner, &bitcoind) + .map_err(|e| log::warn!("Failed to contribute inputs: {}", e)); + } + + let receiver_substitute_address = bitcoind + .get_new_address(None, None) + .map_err(|e| Error::Server(e.into()))? + .assume_checked(); + provisional_payjoin.substitute_output_address(receiver_substitute_address); + + let payjoin_proposal = provisional_payjoin.finalize_proposal( + |psbt: &Psbt| { + bitcoind + .wallet_process_psbt(&base64::encode(psbt.serialize()), None, None, Some(false)) + .map(|res| Psbt::from_str(&res.psbt).map_err(|e| Error::Server(e.into()))) + .map_err(|e| Error::Server(e.into()))? + }, + Some(bitcoin::FeeRate::MIN), + )?; + let payjoin_proposal_psbt = payjoin_proposal.psbt(); + log::debug!("Receiver's Payjoin proposal PSBT Rsponse: {:#?}", payjoin_proposal_psbt); + Ok(payjoin_proposal) } fn insert_input_seen_before(&self, input: bitcoin::OutPoint) -> Result { @@ -406,6 +655,9 @@ pub(crate) struct AppConfig { pub bitcoind_cookie: Option, pub bitcoind_rpcuser: String, pub bitcoind_rpcpass: String, + pub ohttp_config: String, + #[cfg(feature = "v2")] + pub ohttp_proxy: String, // receive-only pub pj_host: String, @@ -436,6 +688,16 @@ impl AppConfig { "bitcoind_rpcpass", matches.get_one::("rpcpass").map(|s| s.as_str()), )? + .set_default("ohttp_config", "")? + .set_override_option( + "ohttp_config", + matches.get_one::("ohttp_config").map(|s| s.as_str()), + )? + .set_default("ohttp_proxy", "")? + .set_override_option( + "ohttp_proxy", + matches.get_one::("ohttp_proxy").map(|s| s.as_str()), + )? // Subcommand defaults without which file serialization fails. .set_default("pj_host", "0.0.0.0:3000")? .set_default("pj_endpoint", "https://localhost:3000")? @@ -457,12 +719,13 @@ impl AppConfig { _ => unreachable!(), // If all subcommands are defined above, anything else is unreachabe!() }; let app_conf = builder.build()?; + log::debug!("App config: {:?}", app_conf); app_conf.try_deserialize().context("Failed to deserialize config") } } fn try_contributing_inputs( - payjoin: &mut ProvisionalProposal, + payjoin: &mut payjoin::receive::ProvisionalProposal, bitcoind: &bitcoincore_rpc::Client, ) -> Result<()> { use bitcoin::OutPoint; @@ -500,7 +763,7 @@ impl payjoin::receive::Headers for Headers<'_> { } } -fn serialize_psbt(psbt: &Psbt) -> String { payjoin::base64::encode(&psbt.serialize()) } +fn serialize_psbt(psbt: &Psbt) -> String { base64::encode(psbt.serialize()) } #[cfg(feature = "danger-local-https")] fn http_agent() -> Result { diff --git a/payjoin-cli/src/main.rs b/payjoin-cli/src/main.rs index 0c82f06f..f6c53e58 100644 --- a/payjoin-cli/src/main.rs +++ b/payjoin-cli/src/main.rs @@ -52,6 +52,12 @@ fn cli() -> ArgMatches { .takes_value(true) .help("The password for the bitcoin node")) .subcommand_required(true) + .arg(Arg::new("ohttp_config") + .long("ohttp-config") + .help("The ohttp config file")) + .arg(Arg::new("ohttp_proxy") + .long("ohttp-proxy") + .help("The ohttp proxy url")) .subcommand( Command::new("send") .arg_required_else_help(true) diff --git a/payjoin-cli/tests/e2e.rs b/payjoin-cli/tests/e2e.rs index e26d9d6f..13194f22 100644 --- a/payjoin-cli/tests/e2e.rs +++ b/payjoin-cli/tests/e2e.rs @@ -1,4 +1,5 @@ #[cfg(feature = "danger-local-https")] +#[cfg(not(feature = "v2"))] mod e2e { use std::env; use std::process::Stdio; diff --git a/payjoin-relay/Cargo.toml b/payjoin-relay/Cargo.toml new file mode 100644 index 00000000..30462c80 --- /dev/null +++ b/payjoin-relay/Cargo.toml @@ -0,0 +1,32 @@ +[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] +anyhow = "1.0.71" +bitcoin = { version = "0.30.0", features = ["base64"] } +bhttp = { version = "0.4.0", features = ["http"] } +hyper = { version = "0.14", features = ["full"] } +hyper-rustls = { version = "0.24", optional = true } +ohttp = "0.4.0" +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..dcd7dd39 --- /dev/null +++ b/payjoin-relay/src/main.rs @@ -0,0 +1,361 @@ +use std::env; +use std::net::SocketAddr; +use std::sync::Arc; + +use anyhow::Result; +use bitcoin::{self, base64}; +use hyper::server::conn::AddrIncoming; +use hyper::server::Builder; +use hyper::service::{make_service_fn, service_fn}; +use hyper::{Body, Method, Request, Response, Server, StatusCode, Uri}; +use tokio::sync::Mutex; +use tracing::{debug, error, info, trace}; +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_REJECT_RES_JSON: &str = + r#"{{"errorCode": "original-psbt-rejected ", "message": "Body is not a string"}}"#; +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 ohttp = Arc::new(Mutex::new(init_ohttp()?)); + let make_svc = make_service_fn(|_| { + let pool = pool.clone(); + let ohttp = ohttp.clone(); + async move { + let handler = move |req| handle_ohttp_gateway(req, pool.clone(), ohttp.clone()); + 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)) +} + +fn init_ohttp() -> Result { + use ohttp::hpke::{Aead, Kdf, Kem}; + use ohttp::{KeyId, SymmetricSuite}; + + const KEY_ID: KeyId = 1; + const KEM: Kem = Kem::X25519Sha256; + const SYMMETRIC: &[SymmetricSuite] = + &[SymmetricSuite::new(Kdf::HkdfSha256, Aead::ChaCha20Poly1305)]; + + // create or read from file + let server_config = ohttp::KeyConfig::new(KEY_ID, KEM, Vec::from(SYMMETRIC))?; + let encoded_config = server_config.encode()?; + let b64_config = base64::encode_config( + encoded_config, + base64::Config::new(base64::CharacterSet::UrlSafe, false), + ); + info!("ohttp server config base64 UrlSafe: {:?}", b64_config); + Ok(ohttp::Server::new(server_config)?) +} + +async fn handle_ohttp_gateway( + req: Request, + pool: DbPool, + ohttp: Arc>, +) -> Result> { + let path = req.uri().path().to_string(); + let query = req.uri().query().unwrap_or_default().to_string(); + let (parts, body) = req.into_parts(); + + let path_segments: Vec<&str> = path.split('/').collect(); + debug!("handle_ohttp_gateway: {:?}", &path_segments); + let mut response = match (parts.method, path_segments.as_slice()) { + (Method::POST, ["", ""]) => handle_ohttp(body, pool, ohttp).await, + (Method::GET, ["", "ohttp-config"]) => + Ok(get_ohttp_config(ohttp_config(&ohttp).await?).await), + (Method::POST, ["", id]) => post_fallback_v1(id, query, 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) +} + +async fn handle_ohttp( + body: Body, + pool: DbPool, + ohttp: Arc>, +) -> Result, HandlerError> { + // decapsulate + let ohttp_body = + hyper::body::to_bytes(body).await.map_err(|e| HandlerError::BadRequest(e.into()))?; + let mut ohttp_locked = ohttp.lock().await; + let (bhttp_req, res_ctx) = + ohttp_locked.decapsulate(&ohttp_body).map_err(|e| HandlerError::BadRequest(e.into()))?; + drop(ohttp_locked); + let mut cursor = std::io::Cursor::new(bhttp_req); + let req = + bhttp::Message::read_bhttp(&mut cursor).map_err(|e| HandlerError::BadRequest(e.into()))?; + let uri = Uri::builder() + .scheme(req.control().scheme().unwrap_or_default()) + .authority(req.control().authority().unwrap_or_default()) + .path_and_query(req.control().path().unwrap_or_default()) + .build()?; + let body = req.content().to_vec(); + let mut http_req = + Request::builder().uri(uri).method(req.control().method().unwrap_or_default()); + for header in req.header().fields() { + http_req = http_req.header(header.name(), header.value()) + } + let request = http_req.body(Body::from(body))?; + + let response = handle_v2(pool, request).await?; + + let (parts, body) = response.into_parts(); + let mut bhttp_res = bhttp::Message::response(parts.status.as_u16()); + let full_body = hyper::body::to_bytes(body) + .await + .map_err(|e| HandlerError::InternalServerError(e.into()))?; + bhttp_res.write_content(&full_body); + let mut bhttp_bytes = Vec::new(); + bhttp_res + .write_bhttp(bhttp::Mode::KnownLength, &mut bhttp_bytes) + .map_err(|e| HandlerError::InternalServerError(e.into()))?; + let ohttp_res = res_ctx + .encapsulate(&bhttp_bytes) + .map_err(|e| HandlerError::InternalServerError(e.into()))?; + Ok(Response::new(Body::from(ohttp_res))) +} + +async fn handle_v2(pool: DbPool, req: Request) -> Result, HandlerError> { + let path = req.uri().path().to_string(); + let (parts, body) = req.into_parts(); + + let path_segments: Vec<&str> = path.split('/').collect(); + debug!("handle_v2: {:?}", &path_segments); + match (parts.method, path_segments.as_slice()) { + (Method::POST, &["", ""]) => post_enroll(body).await, + (Method::POST, &["", id]) => post_fallback_v2(id, body, pool).await, + (Method::GET, &["", id]) => get_fallback(id, pool).await, + (Method::POST, &["", id, "payjoin"]) => post_payjoin(id, body, pool).await, + _ => Ok(not_found()), + } +} + +enum HandlerError { + PayloadTooLarge, + InternalServerError(anyhow::Error), + BadRequest(anyhow::Error), +} + +impl HandlerError { + fn to_response(&self) -> Response { + let status = match self { + HandlerError::PayloadTooLarge => StatusCode::PAYLOAD_TOO_LARGE, + HandlerError::BadRequest(e) => { + error!("Bad request: {}", e); + StatusCode::BAD_REQUEST + } + HandlerError::InternalServerError(e) => { + error!("Internal server error: {}", e); + StatusCode::INTERNAL_SERVER_ERROR + } + }; + + let mut res = Response::new(Body::empty()); + *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> { + 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 = bitcoin::secp256k1::PublicKey::from_slice(&pubkey_bytes) + .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_v1( + id: &str, + query: String, + body: Body, + pool: DbPool, +) -> Result, HandlerError> { + trace!("Post fallback v1"); + let none_response = Response::builder() + .status(StatusCode::SERVICE_UNAVAILABLE) + .body(Body::from(V1_UNAVAILABLE_RES_JSON))?; + let bad_request_body_res = + Response::builder().status(StatusCode::BAD_REQUEST).body(Body::from(V1_REJECT_RES_JSON))?; + + let body_bytes = match hyper::body::to_bytes(body).await { + Ok(bytes) => bytes.to_vec(), + Err(_) => return Ok(bad_request_body_res), + }; + + let body_str = match String::from_utf8(body_bytes) { + Ok(body_str) => body_str, + Err(_) => return Ok(bad_request_body_res), + }; + + let v2_compat_body = Body::from(format!("{}\n{}", body_str, query)); + post_fallback(id, v2_compat_body, pool, none_response).await +} + +async fn post_fallback_v2( + id: &str, + body: Body, + pool: DbPool, +) -> Result, HandlerError> { + trace!("Post fallback v2"); + let none_response = Response::builder().status(StatusCode::ACCEPTED).body(Body::empty())?; + post_fallback(id, body, pool, none_response).await +} + +async fn post_fallback( + id: &str, + body: Body, + pool: DbPool, + none_response: Response, +) -> Result, HandlerError> { + tracing::trace!("Post fallback"); + let id = shorten_string(id); + 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 => Ok(none_response), + } +} + +async fn get_fallback(id: &str, pool: DbPool) -> Result, HandlerError> { + trace!("GET fallback"); + 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> { + trace!("POST payjoin"); + 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 +} + +async fn get_ohttp_config(config: String) -> Response { + trace!("GET ohttp config: {:?}", config); + let mut res = Response::default(); + *res.body_mut() = Body::from(config); + res +} + +fn shorten_string(input: &str) -> String { input.chars().take(8).collect() } + +async fn ohttp_config(server: &Arc>) -> Result { + let b64_config = base64::Config::new(base64::CharacterSet::UrlSafe, false); + let server = server.lock().await; + let encoded_config = server.config().encode()?; + Ok(base64::encode_config(encoded_config, b64_config)) +} diff --git a/payjoin-relay/tests/e2e.rs b/payjoin-relay/tests/e2e.rs new file mode 100644 index 00000000..b21db82e --- /dev/null +++ b/payjoin-relay/tests/e2e.rs @@ -0,0 +1,192 @@ +use std::env; +use std::process::Stdio; + +use bitcoind::bitcoincore_rpc::core_rpc_json::AddressType; +use bitcoind::bitcoincore_rpc::RpcApi; +use log::{log_enabled, Level}; +use payjoin::bitcoin::Amount; +use testcontainers_modules::postgres::Postgres; +use testcontainers_modules::testcontainers::clients::Cli; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::process::{Child, Command}; + +const PJ_RELAY_URL: &str = "https://localhost:8088"; +const OH_RELAY_URL: &str = "https://localhost:8088"; +const RECEIVE_SATS: &str = "54321"; + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +#[cfg(feature = "danger-local-https")] +async fn e2e() { + // Compile payjoin-cli with default features + let mut compile_v1 = compile_payjoin_cli(false).await; + // Compile payjoin-cli with v2 features + let mut compile_v2 = compile_payjoin_cli(true).await; + + std::env::set_var("RUST_LOG", "debug"); + std::env::set_var("PJ_RELAY_PORT", "8088"); + std::env::set_var("PJ_RELAY_TIMEOUT_SECS", "1"); + let _ = env_logger::builder().is_test(true).try_init(); + let docker = Cli::default(); + let node = docker.run(Postgres::default()); + std::env::set_var("PJ_DB_HOST", format!("localhost:{}", node.get_host_port_ipv4(5432))); + + 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 mut relay = Command::new(env!("CARGO_BIN_EXE_payjoin-relay")) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .spawn() + .expect("Failed to execute payjoin-relay"); + log::debug!("Relay started"); + 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(); + + assert_eq!( + Amount::from_btc(50.0).unwrap(), + receiver.get_balances().unwrap().mine.trusted, + "receiver doesn't own bitcoin" + ); + + assert_eq!( + Amount::from_btc(50.0).unwrap(), + sender.get_balances().unwrap().mine.trusted, + "sender doesn't own bitcoin" + ); + + let http = reqwest::Client::builder() + .danger_accept_invalid_certs(true) + .build() + .expect("Failed to build reqwest http client"); + + // ********************** + // From a connection distinct from the client, perhaps a service provider, or over a VPN or Tor + let response = http + .get(format!("{}/ohttp-config", PJ_RELAY_URL)) + .send() + .await + .expect("Failed to send request"); + let ohttp_config = response.text().await.expect("Failed to read response"); + log::debug!("Got ohttp-config {}", &ohttp_config); + + let receiver_rpchost = format!("{}/wallet/receiver", bitcoind.params.rpc_socket); + let sender_rpchost = format!("{}/wallet/sender", bitcoind.params.rpc_socket); + + let cookie_file = &bitcoind.params.cookie_file; + + // Paths to the compiled binaries + let v1_status = compile_v1.wait().await.unwrap(); + let v2_status = compile_v2.wait().await.unwrap(); + assert!(v1_status.success(), "Process did not exit successfully"); + assert!(v2_status.success(), "Process did not exit successfully"); + + let v2_receiver = "target/v2/debug/payjoin"; + let v1_sender = "target/v1/debug/payjoin"; + + let mut cli_receiver = Command::new(v2_receiver) + .arg("--rpchost") + .arg(&receiver_rpchost) + .arg("--cookie-file") + .arg(&cookie_file) + .arg("--ohttp-config") + .arg(&ohttp_config) + .arg("--ohttp-proxy") + .arg(OH_RELAY_URL) + .arg("receive") + .arg(RECEIVE_SATS) + .arg("--endpoint") + .arg(PJ_RELAY_URL) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .spawn() + .expect("Failed to execute payjoin-cli"); + + let stdout = cli_receiver.stdout.take().expect("Failed to take stdout of child process"); + let reader = BufReader::new(stdout); + let mut stdout = tokio::io::stdout(); + + let mut bip21 = String::new(); + + let mut lines = reader.lines(); + + while let Some(line) = lines.next_line().await.expect("Failed to read line from stdout") { + // Write to stdout regardless + stdout + .write_all(format!("{}\n", line).as_bytes()) + .await + .expect("Failed to write to stdout"); + + // Check if it's the line we're interested in + if line.starts_with("BITCOIN") { + bip21 = line; + break; + } + } + log::debug!("Got bip21 {}", &bip21); + tokio::spawn(async move { + let mut stdout = tokio::io::stdout(); + while let Some(line) = lines.next_line().await.expect("Failed to read line from stdout") { + // Continue to write to stdout + stdout + .write_all(format!("{}\n", line).as_bytes()) + .await + .expect("Failed to write to stdout"); + + if line.contains("Transaction sent") { + log::debug!("HOLY MOLY BATMAN! Transaction sent!") + } + } + }); + + let mut cli_sender = Command::new(v1_sender) + .arg("--rpchost") + .arg(&sender_rpchost) + .arg("--cookie-file") + .arg(&cookie_file) + .arg("--ohttp-config") + .arg(&ohttp_config) + .arg("--ohttp-proxy") + .arg(OH_RELAY_URL) + .arg("send") + .arg(&bip21) + .arg("--fee_rate") + .arg("1") + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .spawn() + .expect("Failed to execute payjoin-cli"); + + // delay 10 seconds + tokio::time::sleep(std::time::Duration::from_secs(5)).await; + relay.kill().await.expect("Failed to kill payjoin-relay"); + cli_receiver.kill().await.expect("Failed to kill payjoin-cli"); + cli_sender.kill().await.expect("Failed to kill payjoin-cli"); +} + +async fn compile_payjoin_cli(feature_v2: bool) -> Child { + let target_dir = if feature_v2 { "target/v2" } else { "target/v1" }; + + env::set_var("CARGO_TARGET_DIR", target_dir); + + let mut command = Command::new("cargo"); + command.stdout(Stdio::inherit()).stderr(Stdio::inherit()).args([ + "build", + "--package", + "payjoin-cli", + ]); + + if feature_v2 { + command.args(["--features", "v2"]); + } + command.spawn().unwrap() +} diff --git a/payjoin/Cargo.toml b/payjoin/Cargo.toml index 645bfaf8..9e73fa22 100644 --- a/payjoin/Cargo.toml +++ b/payjoin/Cargo.toml @@ -18,17 +18,27 @@ exclude = ["tests"] send = [] receive = ["rand"] base64 = ["bitcoin/base64"] +v2 = ["bitcoin/rand-std", "chacha20poly1305", "ohttp", "bhttp", "serde"] [dependencies] bitcoin = { version = "0.30.0", features = ["base64"] } bip21 = "0.3.1" +chacha20poly1305 = { version = "0.10.1", optional = true } log = { version = "0.4.14"} +ohttp = { version = "0.4.0", optional = true } +bhttp = { version = "0.4.0", optional = true } rand = { version = "0.8.4", optional = true } +serde = { version = "1.0.186", default-features = false, 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/lib.rs b/payjoin/src/lib.rs index 284d3ff3..969390c3 100644 --- a/payjoin/src/lib.rs +++ b/payjoin/src/lib.rs @@ -27,6 +27,9 @@ pub use crate::receive::Error; #[cfg(feature = "send")] pub mod send; +#[cfg(feature = "v2")] +pub mod v2; + #[cfg(any(feature = "send", feature = "receive"))] pub(crate) mod input_type; #[cfg(any(feature = "send", feature = "receive"))] diff --git a/payjoin/src/receive/error.rs b/payjoin/src/receive/error.rs index ffc91360..5de611bd 100644 --- a/payjoin/src/receive/error.rs +++ b/payjoin/src/receive/error.rs @@ -7,6 +7,9 @@ pub enum Error { BadRequest(RequestError), // To be returned as HTTP 500 Server(Box), + // V2 d/encapsulation failed + #[cfg(feature = "v2")] + V2(crate::v2::Error), } impl fmt::Display for Error { @@ -14,6 +17,8 @@ impl fmt::Display for Error { match &self { Self::BadRequest(e) => e.fmt(f), Self::Server(e) => write!(f, "Internal Server Error: {}", e), + #[cfg(feature = "v2")] + Self::V2(e) => e.fmt(f), } } } @@ -23,6 +28,8 @@ impl error::Error for Error { match &self { Self::BadRequest(_) => None, Self::Server(e) => Some(e.as_ref()), + #[cfg(feature = "v2")] + Self::V2(e) => Some(e), } } } @@ -31,6 +38,15 @@ impl From for Error { fn from(e: RequestError) -> Self { Error::BadRequest(e) } } +impl From for Error { + fn from(e: InternalRequestError) -> Self { Error::BadRequest(e.into()) } +} + +#[cfg(feature = "v2")] +impl From for Error { + fn from(e: crate::v2::Error) -> Self { Error::V2(e) } +} + /// Error that may occur when the request from sender is malformed. /// /// This is currently opaque type because we aren't sure which variants will stay. @@ -65,6 +81,11 @@ pub(crate) enum InternalRequestError { /// Original PSBT input has been seen before. Only automatic receivers, aka "interactive" in the spec /// look out for these to prevent probing attacks. InputSeen(bitcoin::OutPoint), + /// Serde deserialization failed + #[cfg(feature = "v2")] + ParsePsbt(bitcoin::psbt::PsbtParseError), + #[cfg(feature = "v2")] + Utf8(std::string::FromUtf8Error), } impl From for RequestError { @@ -125,6 +146,10 @@ impl fmt::Display for RequestError { write_error(f, "original-psbt-rejected", &format!("Input Type Error: {}.", e)), InternalRequestError::InputSeen(_) => write_error(f, "original-psbt-rejected", "The receiver rejected the original PSBT."), + #[cfg(feature = "v2")] + InternalRequestError::ParsePsbt(e) => write_error(f, "Error parsing PSBT:", e), + #[cfg(feature = "v2")] + InternalRequestError::Utf8(e) => write_error(f, "Error parsing PSBT:", e), } } } diff --git a/payjoin/src/receive/mod.rs b/payjoin/src/receive/mod.rs index 74a72504..596b7750 100644 --- a/payjoin/src/receive/mod.rs +++ b/payjoin/src/receive/mod.rs @@ -270,10 +270,12 @@ use std::cmp::{max, min}; use std::collections::{BTreeMap, HashMap}; use bitcoin::psbt::Psbt; -use bitcoin::{Amount, FeeRate, OutPoint, Script, TxOut}; +use bitcoin::{base64, Amount, FeeRate, OutPoint, Script, TxOut}; mod error; mod optional_parameters; +#[cfg(feature = "v2")] +pub mod v2; pub use error::{Error, RequestError, SelectionError}; use error::{InternalRequestError, InternalSelectionError}; @@ -327,7 +329,7 @@ impl UncheckedProposal { // enforce the limit let mut buf = vec![0; content_length as usize]; // 4_000_000 * 4 / 3 fits in u32 body.read_exact(&mut buf).map_err(InternalRequestError::Io)?; - let base64 = bitcoin::base64::decode(&buf).map_err(InternalRequestError::Base64)?; + let base64 = 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)?; @@ -549,6 +551,7 @@ impl OutputsUnknown { } /// A mutable checked proposal that the receiver may contribute inputs to to make a payjoin. +#[derive(Debug)] pub struct ProvisionalProposal { original_psbt: Psbt, payjoin_psbt: Psbt, diff --git a/payjoin/src/receive/v2.rs b/payjoin/src/receive/v2.rs new file mode 100644 index 00000000..97850990 --- /dev/null +++ b/payjoin/src/receive/v2.rs @@ -0,0 +1,577 @@ +use std::collections::HashMap; + +use bitcoin::psbt::Psbt; +use bitcoin::{base64, Amount, FeeRate, OutPoint, Script, TxOut}; +use serde::ser::SerializeStruct; +use serde::{Deserialize, Serialize, Serializer}; + +use super::{Error, InternalRequestError, RequestError, SelectionError}; +use crate::psbt::PsbtExt; +use crate::receive::optional_parameters::Params; + +/// Represents data that needs to be transmitted to the payjoin relay. +/// +/// You need to send this request over HTTP(S) to the relay. +#[non_exhaustive] +#[derive(Debug)] +pub struct Request { + /// URL to send the request to. + /// + /// This is full URL with scheme etc - you can pass it right to `reqwest` or a similar library. + pub url: url::Url, + + /// Bytes to be sent to the receiver. + pub body: Vec, +} + +#[derive(Debug)] +pub struct V2Context { + relay_url: url::Url, + ohttp_config: Vec, + ohttp_proxy: url::Url, + s: bitcoin::secp256k1::KeyPair, + e: Option, +} + +#[derive(Debug)] +pub struct Enroller { + relay_url: url::Url, + ohttp_config: Vec, + ohttp_proxy: url::Url, + s: bitcoin::secp256k1::KeyPair, +} + +#[cfg(feature = "v2")] +impl Enroller { + pub fn from_relay_config( + relay_url: &str, + ohttp_config_base64: &str, + ohttp_proxy_url: &str, + ) -> Self { + let ohttp_config = base64::decode_config(ohttp_config_base64, base64::URL_SAFE).unwrap(); + let ohttp_proxy = url::Url::parse(ohttp_proxy_url).unwrap(); + let relay_url = url::Url::parse(relay_url).unwrap(); + let secp = bitcoin::secp256k1::Secp256k1::new(); + let (sk, _) = secp.generate_keypair(&mut rand::rngs::OsRng); + Enroller { + ohttp_config, + ohttp_proxy, + relay_url, + s: bitcoin::secp256k1::KeyPair::from_secret_key(&secp, &sk), + } + } + + pub fn subdirectory(&self) -> String { + let pubkey = &self.s.public_key().serialize(); + let b64_config = base64::Config::new(base64::CharacterSet::UrlSafe, false); + base64::encode_config(pubkey, b64_config) + } + + pub fn payjoin_subdir(&self) -> String { format!("{}/{}", self.subdirectory(), "payjoin") } + + pub fn extract_req(&mut self) -> Result<(Request, ohttp::ClientResponse), crate::v2::Error> { + let url = self.ohttp_proxy.clone(); + let (body, ctx) = crate::v2::ohttp_encapsulate( + &self.ohttp_config, + "POST", + self.relay_url.as_str(), + Some(self.subdirectory().as_bytes()), + )?; + let req = Request { url, body }; + Ok((req, ctx)) + } + + pub fn process_res( + self, + mut res: impl std::io::Read, + ctx: ohttp::ClientResponse, + ) -> Result { + // TODO decapsulate enroll response, for now it does no auth or nothing + let mut buf = Vec::new(); + let _ = res.read_to_end(&mut buf); + let _success = crate::v2::ohttp_decapsulate(ctx, &buf).map_err(Error::V2)?; + + let ctx = Enrolled { + relay_url: self.relay_url, + ohttp_config: self.ohttp_config, + ohttp_proxy: self.ohttp_proxy, + s: self.s, + }; + Ok(ctx) + } +} + +fn subdirectory(pubkey: &bitcoin::secp256k1::PublicKey) -> String { + let pubkey = pubkey.serialize(); + let b64_config = base64::Config::new(base64::CharacterSet::UrlSafe, false); + base64::encode_config(pubkey, b64_config) +} + +#[derive(Debug, Clone, PartialEq)] +pub struct Enrolled { + relay_url: url::Url, + ohttp_config: Vec, + ohttp_proxy: url::Url, + s: bitcoin::secp256k1::KeyPair, +} + +impl Serialize for Enrolled { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + let mut state = serializer.serialize_struct("Enrolled", 4)?; + state.serialize_field("relay_url", &self.relay_url.to_string())?; + state.serialize_field("ohttp_config", &self.ohttp_config)?; + state.serialize_field("ohttp_proxy", &self.ohttp_proxy.to_string())?; + state.serialize_field("s", &self.s.secret_key().secret_bytes())?; + + state.end() + } +} + +use std::fmt; +use std::str::FromStr; + +use serde::de::{self, Deserializer, MapAccess, Visitor}; + +impl<'de> Deserialize<'de> for Enrolled { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + enum Field { + RelayUrl, + OhttpConfig, + OhttpProxy, + S, + } + + impl<'de> Deserialize<'de> for Field { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + struct FieldVisitor; + + impl<'de> Visitor<'de> for FieldVisitor { + type Value = Field; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("`relay_url`, `ohttp_config`, `ohttp_proxy`, or `s`") + } + + fn visit_str(self, value: &str) -> Result + where + E: de::Error, + { + match value { + "relay_url" => Ok(Field::RelayUrl), + "ohttp_config" => Ok(Field::OhttpConfig), + "ohttp_proxy" => Ok(Field::OhttpProxy), + "s" => Ok(Field::S), + _ => Err(de::Error::unknown_field(value, FIELDS)), + } + } + } + + deserializer.deserialize_identifier(FieldVisitor) + } + } + + struct EnrolledVisitor; + + impl<'de> Visitor<'de> for EnrolledVisitor { + type Value = Enrolled; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("struct Enrolled") + } + + fn visit_map(self, mut map: V) -> Result + where + V: MapAccess<'de>, + { + let mut relay_url = None; + let mut ohttp_config = None; + let mut ohttp_proxy = None; + let mut s = None; + while let Some(key) = map.next_key()? { + match key { + Field::RelayUrl => { + if relay_url.is_some() { + return Err(de::Error::duplicate_field("relay_url")); + } + let url_str: String = map.next_value()?; + relay_url = Some(url::Url::parse(&url_str).map_err(de::Error::custom)?); + } + Field::OhttpConfig => { + if ohttp_config.is_some() { + return Err(de::Error::duplicate_field("ohttp_config")); + } + ohttp_config = Some(map.next_value()?); + } + Field::OhttpProxy => { + if ohttp_proxy.is_some() { + return Err(de::Error::duplicate_field("ohttp_proxy")); + } + let proxy_str: String = map.next_value()?; + ohttp_proxy = + Some(url::Url::parse(&proxy_str).map_err(de::Error::custom)?); + } + Field::S => { + if s.is_some() { + return Err(de::Error::duplicate_field("s")); + } + let s_bytes: Vec = map.next_value()?; + let secp = bitcoin::secp256k1::Secp256k1::new(); + s = Some( + bitcoin::secp256k1::KeyPair::from_seckey_slice(&secp, &s_bytes) + .map_err(de::Error::custom)?, + ); + } + } + } + let relay_url = relay_url.ok_or_else(|| de::Error::missing_field("relay_url"))?; + let ohttp_config = + ohttp_config.ok_or_else(|| de::Error::missing_field("ohttp_config"))?; + let ohttp_proxy = + ohttp_proxy.ok_or_else(|| de::Error::missing_field("ohttp_proxy"))?; + let s = s.ok_or_else(|| de::Error::missing_field("s"))?; + Ok(Enrolled { relay_url, ohttp_config, ohttp_proxy, s }) + } + } + + const FIELDS: &[&str] = &["relay_url", "ohttp_config", "ohttp_proxy", "s"]; + deserializer.deserialize_struct("Enrolled", FIELDS, EnrolledVisitor) + } +} + +impl Enrolled { + pub fn extract_req(&self) -> Result<(Request, ohttp::ClientResponse), Error> { + let (body, ohttp_ctx) = self.fallback_req_body()?; + let url = self.ohttp_proxy.clone(); + let req = Request { url, body }; + Ok((req, ohttp_ctx)) + } + + /// The response can either be an UncheckedProposal or an ACCEPTED message + /// indicating no UncheckedProposal is available yet. + pub fn process_res( + &self, + mut body: impl std::io::Read, + context: ohttp::ClientResponse, + ) -> Result, Error> { + let mut buf = Vec::new(); + let _ = body.read_to_end(&mut buf); + log::trace!("decapsulating relay response"); + let response = crate::v2::ohttp_decapsulate(context, &buf)?; + if response.is_empty() { + log::debug!("response is empty"); + return Ok(None); + } + // parse v1 or v2 proposal + match String::from_utf8(response.clone()) { + Ok(proposal) => { + let context = V2Context { + relay_url: self.relay_url.clone(), + ohttp_config: self.ohttp_config.clone(), + ohttp_proxy: self.ohttp_proxy.clone(), + s: self.s, + e: None, + }; + log::debug!("Received proposal: {}", proposal); + Ok(Some(UncheckedProposal::from_v2_payload(proposal.into_bytes(), context)?)) + } + Err(_) => { + let (proposal, e) = crate::v2::decrypt_message_a(&response, self.s.secret_key())?; + log::debug!("Some e: {}", e); + let context = V2Context { + relay_url: self.relay_url.clone(), + ohttp_config: self.ohttp_config.clone(), + ohttp_proxy: self.ohttp_proxy.clone(), + s: self.s, + e: Some(e), + }; + let proposal = UncheckedProposal::from_v2_payload(proposal, context)?; + + Ok(Some(proposal)) + } + } + } + + fn fallback_req_body(&self) -> Result<(Vec, ohttp::ClientResponse), crate::v2::Error> { + let fallback_target = format!("{}{}", &self.relay_url, self.fallback_target()); + log::trace!("Fallback request target: {}", fallback_target.as_str()); + crate::v2::ohttp_encapsulate(&self.ohttp_config, "GET", &self.fallback_target(), None) + } + + pub fn pubkey(&self) -> [u8; 33] { self.s.public_key().serialize() } + + pub fn fallback_target(&self) -> String { + let pubkey = &self.s.public_key().serialize(); + let b64_config = base64::Config::new(base64::CharacterSet::UrlSafe, false); + let pubkey_base64 = base64::encode_config(pubkey, b64_config); + format!("{}{}", &self.relay_url, pubkey_base64) + } +} + +/// The sender's original PSBT and optional parameters +/// +/// This type is used to proces the request. It is returned by +/// [`UncheckedProposal::from_request()`](super::::UncheckedProposal::from_request()). +/// +/// If you are implementing an interactive payment processor, you should get extract the original +/// transaction with extract_tx_to_schedule_broadcast() and schedule, followed by checking +/// that the transaction can be broadcast with check_can_broadcast. Otherwise it is safe to +/// call assume_interactive_receive to proceed with validation. +pub struct UncheckedProposal { + inner: super::UncheckedProposal, + context: V2Context, +} + +impl UncheckedProposal { + fn from_v2_payload(body: Vec, context: V2Context) -> Result { + let buf_as_string = String::from_utf8(body).map_err(InternalRequestError::Utf8)?; + log::debug!("{}", &buf_as_string); + let (base64, padded_query) = buf_as_string.split_once('\n').unwrap_or_default(); + let query = padded_query.trim_matches('\0'); + log::trace!("Received query: {}, base64: {}", query, base64); // my guess is no \n so default is wrong + let unchecked_psbt = Psbt::from_str(base64).map_err(InternalRequestError::ParsePsbt)?; + let psbt = unchecked_psbt.validate().map_err(InternalRequestError::InconsistentPsbt)?; + log::debug!("Received original psbt: {:?}", psbt); + let params = Params::from_query_pairs(url::form_urlencoded::parse(query.as_bytes())) + .map_err(InternalRequestError::SenderParams)?; + log::debug!("Received request with params: {:?}", params); + let inner = super::UncheckedProposal { psbt, params }; + Ok(Self { inner, context }) + } + + /// The Sender's Original PSBT + pub fn extract_tx_to_schedule_broadcast(&self) -> bitcoin::Transaction { + self.inner.extract_tx_to_schedule_broadcast() + } + + /// Call after checking that the Original PSBT can be broadcast. + /// + /// Receiver MUST check that the Original PSBT from the sender + /// can be broadcast, i.e. `testmempoolaccept` bitcoind rpc returns { "allowed": true,.. } + /// for `extract_tx_to_sheculed_broadcast()` before calling this method. + /// + /// Do this check if you generate bitcoin uri to receive Payjoin on sender request without manual human approval, like a payment processor. + /// Such so called "non-interactive" receivers are otherwise vulnerable to probing attacks. + /// If a sender can make requests at will, they can learn which bitcoin the receiver owns at no cost. + /// Broadcasting the Original PSBT after some time in the failure case makes incurs sender cost and prevents probing. + /// + /// Call this after checking downstream. + pub fn check_can_broadcast( + self, + can_broadcast: impl Fn(&bitcoin::Transaction) -> Result, + ) -> Result { + let inner = self.inner.check_can_broadcast(can_broadcast)?; + Ok(MaybeInputsOwned { inner, context: self.context }) + } + + /// Call this method if the only way to initiate a Payjoin with this receiver + /// requires manual intervention, as in most consumer wallets. + /// + /// So-called "non-interactive" receivers, like payment processors, that allow arbitrary requests are otherwise vulnerable to probing attacks. + /// Those receivers call `extract_tx_to_check_broadcast()` and `attest_tested_and_scheduled_broadcast()` after making those checks downstream. + pub fn assume_interactive_receiver(self) -> MaybeInputsOwned { + let inner = self.inner.assume_interactive_receiver(); + MaybeInputsOwned { inner, context: self.context } + } +} + +/// Typestate to validate that the Original PSBT has no receiver-owned inputs. +/// +/// Call [`check_no_receiver_owned_inputs()`](struct.UncheckedProposal.html#method.check_no_receiver_owned_inputs) to proceed. +pub struct MaybeInputsOwned { + inner: super::MaybeInputsOwned, + context: V2Context, +} + +impl MaybeInputsOwned { + /// Check that the Original PSBT has no receiver-owned inputs. + /// Return original-psbt-rejected error or otherwise refuse to sign undesirable inputs. + /// + /// An attacker could try to spend receiver's own inputs. This check prevents that. + pub fn check_inputs_not_owned( + self, + is_owned: impl Fn(&Script) -> Result, + ) -> Result { + let inner = self.inner.check_inputs_not_owned(is_owned)?; + Ok(MaybeMixedInputScripts { inner, context: self.context }) + } +} + +/// Typestate to validate that the Original PSBT has no mixed input types. +/// +/// Call [`check_no_mixed_input_types`](struct.UncheckedProposal.html#method.check_no_mixed_input_scripts) to proceed. +pub struct MaybeMixedInputScripts { + inner: super::MaybeMixedInputScripts, + context: V2Context, +} + +impl MaybeMixedInputScripts { + /// Verify the original transaction did not have mixed input types + /// Call this after checking downstream. + /// + /// Note: mixed spends do not necessarily indicate distinct wallet fingerprints. + /// This check is intended to prevent some types of wallet fingerprinting. + pub fn check_no_mixed_input_scripts(self) -> Result { + let inner = self.inner.check_no_mixed_input_scripts()?; + Ok(MaybeInputsSeen { inner, context: self.context }) + } +} + +/// Typestate to validate that the Original PSBT has no inputs that have been seen before. +/// +/// Call [`check_no_inputs_seen`](struct.MaybeInputsSeen.html#method.check_no_inputs_seen_before) to proceed. +pub struct MaybeInputsSeen { + inner: super::MaybeInputsSeen, + context: V2Context, +} + +impl MaybeInputsSeen { + /// Make sure that the original transaction inputs have never been seen before. + /// This prevents probing attacks. This prevents reentrant Payjoin, where a sender + /// proposes a Payjoin PSBT as a new Original PSBT for a new Payjoin. + pub fn check_no_inputs_seen_before( + self, + is_known: impl Fn(&OutPoint) -> Result, + ) -> Result { + let inner = self.inner.check_no_inputs_seen_before(is_known)?; + Ok(OutputsUnknown { inner, context: self.context }) + } +} + +/// The receiver has not yet identified which outputs belong to the receiver. +/// +/// Only accept PSBTs that send us money. +/// Identify those outputs with `identify_receiver_outputs()` to proceed +pub struct OutputsUnknown { + inner: super::OutputsUnknown, + context: V2Context, +} + +impl OutputsUnknown { + /// Find which outputs belong to the receiver + pub fn identify_receiver_outputs( + self, + is_receiver_output: impl Fn(&Script) -> Result, + ) -> Result { + let inner = self.inner.identify_receiver_outputs(is_receiver_output)?; + Ok(ProvisionalProposal { inner, context: self.context }) + } +} + +/// A mutable checked proposal that the receiver may contribute inputs to to make a payjoin. +#[derive(Debug)] +pub struct ProvisionalProposal { + pub inner: super::ProvisionalProposal, + context: V2Context, +} + +impl ProvisionalProposal { + /// Select receiver input such that the payjoin avoids surveillance. + /// Return the input chosen that has been applied to the Proposal. + /// + /// Proper coin selection allows payjoin to resemble ordinary transactions. + /// To ensure the resemblence, a number of heuristics must be avoided. + /// + /// UIH "Unecessary input heuristic" is one class of them to avoid. We define + /// UIH1 and UIH2 according to the BlockSci practice + /// BlockSci UIH1 and UIH2: + // if min(out) < min(in) then UIH1 else UIH2 + // https://eprint.iacr.org/2022/589.pdf + pub fn try_preserving_privacy( + &self, + candidate_inputs: HashMap, + ) -> Result { + self.inner.try_preserving_privacy(candidate_inputs) + } + + pub fn contribute_witness_input(&mut self, txo: TxOut, outpoint: OutPoint) { + self.inner.contribute_witness_input(txo, outpoint) + } + + pub fn contribute_non_witness_input(&mut self, tx: bitcoin::Transaction, outpoint: OutPoint) { + self.inner.contribute_non_witness_input(tx, outpoint) + } + + /// Just replace an output address with + pub fn substitute_output_address(&mut self, substitute_address: bitcoin::Address) { + self.inner.substitute_output_address(substitute_address) + } + + pub fn finalize_proposal( + self, + wallet_process_psbt: impl Fn(&Psbt) -> Result, + min_feerate_sat_per_vb: Option, + ) -> Result { + let inner = self.inner.finalize_proposal(wallet_process_psbt, min_feerate_sat_per_vb)?; + Ok(PayjoinProposal { inner, context: self.context }) + } +} + +/// A mutable checked proposal that the receiver may contribute inputs to to make a payjoin. +pub struct PayjoinProposal { + inner: super::PayjoinProposal, + context: V2Context, +} + +impl PayjoinProposal { + pub fn utxos_to_be_locked(&self) -> impl '_ + Iterator { + self.inner.utxos_to_be_locked() + } + + pub fn is_output_substitution_disabled(&self) -> bool { + self.inner.is_output_substitution_disabled() + } + + pub fn owned_vouts(&self) -> &Vec { self.inner.owned_vouts() } + + pub fn psbt(&self) -> &Psbt { self.inner.psbt() } + + pub fn extract_v1_req(&self) -> String { base64::encode(self.inner.payjoin_psbt.serialize()) } + + #[cfg(feature = "v2")] + pub fn extract_v2_req(&self) -> Result<(Request, ohttp::ClientResponse), Error> { + let body = match self.context.e { + Some(e) => { + let mut payjoin_bytes = self.inner.payjoin_psbt.serialize(); + log::debug!("THERE IS AN e: {}", e); + crate::v2::encrypt_message_b(&mut payjoin_bytes, e) + } + None => Ok(self.extract_v1_req().as_bytes().to_vec()), + }?; + let post_payjoin_target = format!( + "{}{}/payjoin", + self.context.relay_url.as_str(), + subdirectory(&self.context.s.public_key()) + ); + log::debug!("Payjoin post target: {}", post_payjoin_target.as_str()); + let (body, ctx) = crate::v2::ohttp_encapsulate( + &self.context.ohttp_config, + "POST", + &post_payjoin_target, + Some(&body), + )?; + let url = self.context.ohttp_proxy.clone(); + let req = Request { url, body }; + Ok((req, ctx)) + } + + #[cfg(feature = "v2")] + pub fn deserialize_res( + &self, + res: Vec, + ohttp_context: ohttp::ClientResponse, + ) -> Result, Error> { + // TODO return error code + // display success or failure + let res = crate::v2::ohttp_decapsulate(ohttp_context, &res)?; + Ok(res) + } +} diff --git a/payjoin/src/send/error.rs b/payjoin/src/send/error.rs index 8aeebfc5..6e70a822 100644 --- a/payjoin/src/send/error.rs +++ b/payjoin/src/send/error.rs @@ -16,13 +16,22 @@ pub struct ValidationError { #[derive(Debug)] pub(crate) enum InternalValidationError { - Psbt(bitcoin::psbt::PsbtParseError), + PsbtParse(bitcoin::psbt::PsbtParseError), Io(std::io::Error), InvalidInputType(InputTypeError), InvalidProposedInput(crate::psbt::PrevTxOutError), - VersionsDontMatch { proposed: i32, original: i32 }, - LockTimesDontMatch { proposed: LockTime, original: LockTime }, - SenderTxinSequenceChanged { proposed: Sequence, original: Sequence }, + VersionsDontMatch { + proposed: i32, + original: i32, + }, + LockTimesDontMatch { + proposed: LockTime, + original: LockTime, + }, + SenderTxinSequenceChanged { + proposed: Sequence, + original: Sequence, + }, SenderTxinContainsNonWitnessUtxo, SenderTxinContainsWitnessUtxo, SenderTxinContainsFinalScriptSig, @@ -32,7 +41,10 @@ pub(crate) enum InternalValidationError { ReceiverTxinNotFinalized, ReceiverTxinMissingUtxoInfo, MixedSequence, - MixedInputTypes { proposed: InputType, original: InputType }, + MixedInputTypes { + proposed: InputType, + original: InputType, + }, MissingOrShuffledInputs, TxOutContainsKeyPaths, FeeContributionExceedsMaximum, @@ -44,6 +56,10 @@ pub(crate) enum InternalValidationError { PayeeTookContributedFee, FeeContributionPaysOutputSizeIncrease, FeeRateBelowMinimum, + #[cfg(feature = "v2")] + V2(crate::v2::Error), + #[cfg(feature = "v2")] + Psbt(bitcoin::psbt::Error), } impl From for ValidationError { @@ -58,7 +74,7 @@ impl fmt::Display for ValidationError { use InternalValidationError::*; match &self.internal { - Psbt(e) => write!(f, "couldn't decode PSBT: {}", e), + PsbtParse(e) => write!(f, "couldn't decode PSBT: {}", e), Io(e) => write!(f, "couldn't read PSBT: {}", e), InvalidInputType(e) => write!(f, "invalid transaction input type: {}", e), InvalidProposedInput(e) => write!(f, "invalid proposed transaction input: {}", e), @@ -86,6 +102,10 @@ impl fmt::Display for ValidationError { PayeeTookContributedFee => write!(f, "payee tried to take fee contribution for himself"), FeeContributionPaysOutputSizeIncrease => write!(f, "fee contribution pays for additional outputs"), FeeRateBelowMinimum => write!(f, "the fee rate of proposed transaction is below minimum"), + #[cfg(feature = "v2")] + V2(e) => write!(f, "v2 error: {}", e), + #[cfg(feature = "v2")] + Psbt(e) => write!(f, "psbt error: {}", e), } } } @@ -95,7 +115,7 @@ impl std::error::Error for ValidationError { use InternalValidationError::*; match &self.internal { - Psbt(error) => Some(error), + PsbtParse(error) => Some(error), Io(error) => Some(error), InvalidInputType(error) => Some(error), InvalidProposedInput(error) => Some(error), @@ -123,6 +143,10 @@ impl std::error::Error for ValidationError { PayeeTookContributedFee => None, FeeContributionPaysOutputSizeIncrease => None, FeeRateBelowMinimum => None, + #[cfg(feature = "v2")] + V2(error) => Some(error), + #[cfg(feature = "v2")] + Psbt(error) => Some(error), } } } @@ -152,6 +176,8 @@ pub(crate) enum InternalCreateRequestError { UriDoesNotSupportPayjoin, PrevTxOut(crate::psbt::PrevTxOutError), InputType(crate::input_type::InputTypeError), + #[cfg(feature = "v2")] + V2(crate::v2::Error), } impl fmt::Display for CreateRequestError { @@ -170,10 +196,12 @@ impl fmt::Display for CreateRequestError { AmbiguousChangeOutput => write!(f, "can not determine which output is change because there's more than two outputs"), ChangeIndexOutOfBounds => write!(f, "fee output index is points out of bounds"), ChangeIndexPointsAtPayee => write!(f, "fee output index is points at output belonging to the payee"), - Url(e) => write!(f, "cannot parse endpoint url: {:#?}", e), + Url(e) => write!(f, "cannot parse url: {:#?}", e), UriDoesNotSupportPayjoin => write!(f, "the URI does not support payjoin"), PrevTxOut(e) => write!(f, "invalid previous transaction output: {}", e), InputType(e) => write!(f, "invalid input type: {}", e), + #[cfg(feature = "v2")] + V2(e) => write!(f, "v2 error: {}", e), } } } @@ -198,6 +226,8 @@ impl std::error::Error for CreateRequestError { UriDoesNotSupportPayjoin => None, PrevTxOut(error) => Some(error), InputType(error) => Some(error), + #[cfg(feature = "v2")] + V2(error) => Some(error), } } } diff --git a/payjoin/src/send/mod.rs b/payjoin/src/send/mod.rs index e06fc1c1..735f4c02 100644 --- a/payjoin/src/send/mod.rs +++ b/payjoin/src/send/mod.rs @@ -211,7 +211,7 @@ impl<'a> RequestBuilder<'a> { pub fn build_recommended( self, min_fee_rate: FeeRate, - ) -> Result<(Request, Context), CreateRequestError> { + ) -> Result, CreateRequestError> { // TODO support optional batched payout scripts. This would require a change to // build() which now checks for a single payee. let mut payout_scripts = std::iter::once(self.uri.address.script_pubkey()); @@ -283,7 +283,7 @@ impl<'a> RequestBuilder<'a> { change_index: Option, min_fee_rate: FeeRate, clamp_fee_contribution: bool, - ) -> Result<(Request, Context), CreateRequestError> { + ) -> Result, CreateRequestError> { self.fee_contribution = Some((max_fee_contribution, change_index)); self.clamp_fee_contribution = clamp_fee_contribution; self.min_fee_rate = min_fee_rate; @@ -294,7 +294,7 @@ impl<'a> RequestBuilder<'a> { /// /// While it's generally better to offer some contribution some users may wish not to. /// This function disables contribution. - pub fn build_non_incentivizing(mut self) -> Result<(Request, Context), CreateRequestError> { + pub fn build_non_incentivizing(mut self) -> Result, CreateRequestError> { // since this is a builder, these should already be cleared // but we'll reset them to be sure self.fee_contribution = None; @@ -303,7 +303,7 @@ impl<'a> RequestBuilder<'a> { self.build() } - fn build(self) -> Result<(Request, Context), CreateRequestError> { + fn build(self) -> Result, CreateRequestError> { let mut psbt = self.psbt.validate().map_err(InternalCreateRequestError::InconsistentOriginalPsbt)?; psbt.validate_input_utxos(true) @@ -326,33 +326,118 @@ impl<'a> RequestBuilder<'a> { let sequence = zeroth_input.txin.sequence; let txout = zeroth_input.previous_txout().expect("We already checked this above"); let input_type = InputType::from_spent_input(txout, zeroth_input.psbtin).unwrap(); - let url = serialize_url( - self.uri.extras._endpoint.into(), + + Ok(RequestContext { + psbt, + uri: self.uri, disable_output_substitution, fee_contribution, + payee, + input_type, + sequence, + min_fee_rate: self.min_fee_rate, + }) + } +} + +pub struct RequestContext<'a> { + psbt: Psbt, + uri: PjUri<'a>, + disable_output_substitution: bool, + fee_contribution: Option<(bitcoin::Amount, usize)>, + min_fee_rate: FeeRate, + input_type: InputType, + sequence: Sequence, + payee: ScriptBuf, +} + +impl<'a> RequestContext<'a> { + /// Extract serialized V1 Request and Context froma Payjoin Proposal + pub fn extract_v1(self) -> Result<(Request, ContextV1), CreateRequestError> { + let url = serialize_url( + self.uri.extras._endpoint.into(), + self.disable_output_substitution, + self.fee_contribution, self.min_fee_rate, ) .map_err(InternalCreateRequestError::Url)?; - let body = serialize_psbt(&psbt); + let body = self.psbt.to_string().as_bytes().to_vec(); Ok(( Request { url, body }, - Context { - original_psbt: psbt, - disable_output_substitution, - fee_contribution, - payee, - input_type, - sequence, + ContextV1 { + original_psbt: self.psbt, + disable_output_substitution: self.disable_output_substitution, + fee_contribution: self.fee_contribution, + payee: self.payee, + input_type: self.input_type, + sequence: self.sequence, min_fee_rate: self.min_fee_rate, }, )) } + + /// Extract serialized Request and Context from a Payjoin Proposal. + /// + /// In order to support polling, this may need to be called many times to be encrypted with + /// new unique nonces to make independent OHTTP requests. + /// + /// The `ohttp_proxy` merely passes the encrypted payload to the ohttp gateway of the receiver + #[cfg(feature = "v2")] + pub fn extract_v2( + &self, + ohttp_proxy_url: &str, + ) -> Result<(Request, ContextV2), CreateRequestError> { + let rs_base64 = crate::v2::subdir(self.uri.extras._endpoint.as_str()).to_string(); + log::debug!("rs_base64: {:?}", rs_base64); + let b64_config = + bitcoin::base64::Config::new(bitcoin::base64::CharacterSet::UrlSafe, false); + let rs = bitcoin::base64::decode_config(rs_base64, b64_config).unwrap(); + log::debug!("rs: {:?}", rs.len()); + let rs = bitcoin::secp256k1::PublicKey::from_slice(&rs).unwrap(); + + let url = self.uri.extras._endpoint.clone(); + let body = serialize_v2_body( + &self.psbt, + self.disable_output_substitution, + self.fee_contribution, + self.min_fee_rate, + )?; + let (body, e) = + crate::v2::encrypt_message_a(body, rs).map_err(InternalCreateRequestError::V2)?; + let (body, ohttp_res) = crate::v2::ohttp_encapsulate( + &self.uri.extras.ohttp_config.as_ref().unwrap().encode().unwrap(), + "POST", + url.as_str(), + Some(&body), + ) + .map_err(InternalCreateRequestError::V2)?; + log::debug!("ohttp_proxy_url: {:?}", ohttp_proxy_url); + let url = Url::parse(ohttp_proxy_url).map_err(InternalCreateRequestError::Url)?; + Ok(( + Request { url, body }, + // this method may be called more than once to re-construct the ohttp, therefore we must clone (or TODO memoize) + ContextV2 { + context_v1: ContextV1 { + original_psbt: self.psbt.clone(), + disable_output_substitution: self.disable_output_substitution, + fee_contribution: self.fee_contribution, + payee: self.payee.clone(), + input_type: self.input_type, + sequence: self.sequence, + min_fee_rate: self.min_fee_rate, + }, + e, + ohttp_res, + }, + )) + } } /// Represents data that needs to be transmitted to the receiver. /// /// 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. /// @@ -371,7 +456,8 @@ pub struct Request { /// /// This type is used to process the response. Get it from [`RequestBuilder`](crate::send::RequestBuilder)'s build methods. /// Then you only need to call [`.process_response()`](crate::send::Context::process_response()) on it to continue BIP78 flow. -pub struct Context { +#[derive(Debug)] +pub struct ContextV1 { original_psbt: Psbt, disable_output_substitution: bool, fee_contribution: Option<(bitcoin::Amount, usize)>, @@ -381,6 +467,13 @@ pub struct Context { payee: ScriptBuf, } +#[cfg(feature = "v2")] +pub struct ContextV2 { + context_v1: ContextV1, + e: bitcoin::secp256k1::SecretKey, + ohttp_res: ohttp::ClientResponse, +} + macro_rules! check_eq { ($proposed:expr, $original:expr, $error:ident) => { match ($proposed, $original) { @@ -399,7 +492,35 @@ macro_rules! ensure { }; } -impl Context { +#[cfg(feature = "v2")] +impl ContextV2 { + /// Decodes and validates the response. + /// + /// Call this method with response from receiver to continue BIP-??? flow. + /// A successful response can either be None if the relay has not response yet or Some(Psbt). + /// + /// If the response is some valid PSBT you should sign and broadcast. + #[inline] + pub fn process_response( + self, + response: &mut impl std::io::Read, + ) -> Result, ValidationError> { + let mut res_buf = Vec::new(); + response.read_to_end(&mut res_buf).map_err(InternalValidationError::Io)?; + let mut res_buf = crate::v2::ohttp_decapsulate(self.ohttp_res, &res_buf) + .map_err(InternalValidationError::V2)?; + let psbt = crate::v2::decrypt_message_b(&mut res_buf, self.e) + .map_err(InternalValidationError::V2)?; + if psbt.is_empty() { + return Ok(None); + } + let proposal = Psbt::deserialize(&psbt).map_err(InternalValidationError::Psbt)?; + let processed_proposal = self.context_v1.process_proposal(proposal)?; + Ok(Some(processed_proposal)) + } +} + +impl ContextV1 { /// Decodes and validates the response. /// /// Call this method with response from receiver to continue BIP78 flow. If the response is @@ -411,7 +532,7 @@ impl Context { ) -> Result { let mut res_str = String::new(); response.read_to_string(&mut res_str).map_err(InternalValidationError::Io)?; - let proposal = Psbt::from_str(&res_str).map_err(InternalValidationError::Psbt)?; + let proposal = Psbt::from_str(&res_str).map_err(InternalValidationError::PsbtParse)?; // process in non-generic function self.process_proposal(proposal).map(Into::into).map_err(Into::into) @@ -768,6 +889,26 @@ fn determine_fee_contribution( }) } +#[cfg(feature = "v2")] +fn serialize_v2_body( + psbt: &Psbt, + disable_output_substitution: bool, + fee_contribution: Option<(bitcoin::Amount, usize)>, + min_feerate: FeeRate, +) -> Result, CreateRequestError> { + // Grug say localhost base be discarded anyway. no big brain needed. + let placeholder_url = serialize_url( + "http:/localhost".to_string(), + disable_output_substitution, + fee_contribution, + min_feerate, + ) + .map_err(InternalCreateRequestError::Url)?; + let query_params = placeholder_url.query().unwrap_or_default(); + let base64 = psbt.to_string(); + Ok(format!("{}\n{}", base64, query_params).into_bytes()) +} + fn serialize_url( endpoint: String, disable_output_substitution: bool, @@ -792,11 +933,6 @@ fn serialize_url( Ok(url) } -fn serialize_psbt(psbt: &Psbt) -> Vec { - let bytes = psbt.serialize(); - bitcoin::base64::encode(bytes).into_bytes() -} - #[cfg(test)] mod tests { #[test] @@ -817,7 +953,7 @@ mod tests { eprintln!("original: {:#?}", original_psbt); let payee = original_psbt.unsigned_tx.output[1].script_pubkey.clone(); let sequence = original_psbt.unsigned_tx.input[0].sequence; - let ctx = super::Context { + let ctx = super::ContextV1 { original_psbt, disable_output_substitution: false, fee_contribution: None, diff --git a/payjoin/src/uri.rs b/payjoin/src/uri.rs index b8f32b37..ddaa8173 100644 --- a/payjoin/src/uri.rs +++ b/payjoin/src/uri.rs @@ -5,9 +5,9 @@ use bitcoin::address::{Error, NetworkChecked, NetworkUnchecked}; use bitcoin::Network; use url::Url; -#[derive(Debug, Clone)] pub enum Payjoin { Supported(PayjoinParams), + V2Only(PayjoinParams), Unsupported, } @@ -15,15 +15,17 @@ impl Payjoin { pub fn pj_is_supported(&self) -> bool { match self { Payjoin::Supported(_) => true, + Payjoin::V2Only(_) => true, Payjoin::Unsupported => false, } } } -#[derive(Debug, Clone)] pub struct PayjoinParams { pub(crate) _endpoint: Url, pub(crate) disable_output_substitution: bool, + #[cfg(feature = "v2")] + pub(crate) ohttp_config: Option, } pub type Uri<'a, NetworkValidation> = bip21::Uri<'a, NetworkValidation, Payjoin>; @@ -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; @@ -110,6 +112,8 @@ impl<'a> bip21::de::DeserializeParams<'a> for Payjoin { pub struct DeserializationState { pj: Option, pjos: Option, + #[cfg(feature = "v2")] + ohttp: Option, } #[derive(Debug)] @@ -133,6 +137,19 @@ impl<'a> bip21::de::DeserializationState<'a> for DeserializationState { ::Error, > { match key { + #[cfg(feature = "v2")] + "ohttp" if self.ohttp.is_none() => { + let base64_config = Cow::try_from(value).map_err(InternalPjParseError::NotUtf8)?; + let config_bytes = + bitcoin::base64::decode_config(&*base64_config, bitcoin::base64::URL_SAFE) + .map_err(InternalPjParseError::NotBase64)?; + let config = ohttp::KeyConfig::decode(&config_bytes) + .map_err(InternalPjParseError::BadOhttp)?; + self.ohttp = Some(config); + Ok(bip21::de::ParamKind::Known) + } + #[cfg(feature = "v2")] + "ohttp" => Err(PjParseError(InternalPjParseError::MultipleParams("ohttp"))), "pj" if self.pj.is_none() => { let endpoint = Cow::try_from(value).map_err(InternalPjParseError::NotUtf8)?; let url = Url::parse(&endpoint).map_err(InternalPjParseError::BadEndpoint)?; @@ -154,6 +171,51 @@ impl<'a> bip21::de::DeserializationState<'a> for DeserializationState { } } + #[cfg(feature = "v2")] + fn finalize( + self, + ) -> std::result::Result::Error> { + match (self.pj, self.pjos, self.ohttp) { + (None, None, _) => Ok(Payjoin::Unsupported), + (None, Some(_), _) => Err(PjParseError(InternalPjParseError::MissingEndpoint)), + (Some(endpoint), pjos, None) => { + if endpoint.scheme() == "https" + || endpoint.scheme() == "http" + && endpoint.domain().unwrap_or_default().ends_with(".onion") + { + Ok(Payjoin::Supported(PayjoinParams { + _endpoint: endpoint, + disable_output_substitution: pjos.unwrap_or(false), + ohttp_config: None, + })) + } else { + Err(PjParseError(InternalPjParseError::UnsecureEndpoint)) + } + } + (Some(endpoint), pjos, Some(ohttp)) => { + if endpoint.scheme() == "https" + || endpoint.scheme() == "http" + && endpoint.domain().unwrap_or_default().ends_with(".onion") + { + Ok(Payjoin::Supported(PayjoinParams { + _endpoint: endpoint, + disable_output_substitution: pjos.unwrap_or(false), + ohttp_config: Some(ohttp), + })) + } else if endpoint.scheme() == "http" { + Ok(Payjoin::V2Only(PayjoinParams { + _endpoint: endpoint, + disable_output_substitution: pjos.unwrap_or(false), + ohttp_config: Some(ohttp), + })) + } else { + Err(PjParseError(InternalPjParseError::UnsecureEndpoint)) + } + } + } + } + + #[cfg(not(feature = "v2"))] fn finalize( self, ) -> std::result::Result::Error> { @@ -186,7 +248,11 @@ impl std::fmt::Display for PjParseError { } InternalPjParseError::MissingEndpoint => write!(f, "Missing payjoin endpoint"), InternalPjParseError::NotUtf8(_) => write!(f, "Endpoint is not valid UTF-8"), + #[cfg(feature = "v2")] + InternalPjParseError::NotBase64(_) => write!(f, "ohttp config is not valid base64"), InternalPjParseError::BadEndpoint(_) => write!(f, "Endpoint is not valid"), + #[cfg(feature = "v2")] + InternalPjParseError::BadOhttp(_) => write!(f, "ohttp config is not valid"), InternalPjParseError::UnsecureEndpoint => { write!(f, "Endpoint scheme is not secure (https or onion)") } @@ -200,7 +266,11 @@ enum InternalPjParseError { MultipleParams(&'static str), MissingEndpoint, NotUtf8(core::str::Utf8Error), + #[cfg(feature = "v2")] + NotBase64(bitcoin::base64::DecodeError), BadEndpoint(url::ParseError), + #[cfg(feature = "v2")] + BadOhttp(ohttp::Error), UnsecureEndpoint, } diff --git a/payjoin/src/v2.rs b/payjoin/src/v2.rs new file mode 100644 index 00000000..5f4fe54e --- /dev/null +++ b/payjoin/src/v2.rs @@ -0,0 +1,216 @@ +use std::{error, fmt}; + +pub const MAX_BUFFER_SIZE: usize = 65536; +pub const PADDED_MESSAGE_BYTES: usize = 7168; // 7KB + +pub fn subdir(path: &str) -> String { + let subdirectory: String; + + if let Some(pos) = path.rfind('/') { + subdirectory = path[pos + 1..].to_string(); + } else { + subdirectory = path.to_string(); + } + + let pubkey_id: String; + + if let Some(pos) = subdirectory.find('?') { + pubkey_id = subdirectory[..pos].to_string(); + } else { + pubkey_id = subdirectory; + } + pubkey_id +} + +use bitcoin::secp256k1::ecdh::SharedSecret; +use bitcoin::secp256k1::{PublicKey, Secp256k1, SecretKey}; +use chacha20poly1305::aead::{Aead, KeyInit, OsRng, Payload}; +use chacha20poly1305::{AeadCore, ChaCha20Poly1305, Nonce}; + +/// crypto context +/// +/// <- Receiver S +/// -> Sender E, ES(payload), payload protected by knowledge of receiver key +/// <- Receiver E, EE(payload), payload protected by knowledge of sender & receiver key +pub fn encrypt_message_a( + mut raw_msg: Vec, + s: PublicKey, +) -> Result<(Vec, SecretKey), Error> { + let secp = Secp256k1::new(); + let (e_sec, e_pub) = secp.generate_keypair(&mut OsRng); + let es = SharedSecret::new(&s, &e_sec); + let cipher = ChaCha20Poly1305::new_from_slice(&es.secret_bytes()) + .map_err(|_| InternalError::InvalidKeyLength)?; + let nonce = ChaCha20Poly1305::generate_nonce(&mut OsRng); // key es encrypts only 1 message so 0 is unique + let aad = &e_pub.serialize(); + let msg = pad(&mut raw_msg)?; + let payload = Payload { msg, aad }; + let c_t: Vec = cipher.encrypt(&nonce, payload)?; + let mut message_a = e_pub.serialize().to_vec(); + message_a.extend(&nonce[..]); + message_a.extend(&c_t[..]); + Ok((message_a, e_sec)) +} + +pub fn decrypt_message_a(message_a: &[u8], s: SecretKey) -> Result<(Vec, PublicKey), Error> { + // let message a = [pubkey/AD][nonce][authentication tag][ciphertext] + let e = PublicKey::from_slice(&message_a[..33])?; + let nonce = Nonce::from_slice(&message_a[33..45]); + let es = SharedSecret::new(&e, &s); + let cipher = ChaCha20Poly1305::new_from_slice(&es.secret_bytes()) + .map_err(|_| InternalError::InvalidKeyLength)?; + let c_t = &message_a[45..]; + let aad = &e.serialize(); + let payload = Payload { msg: c_t, aad }; + let buffer = cipher.decrypt(nonce, payload)?; + Ok((buffer, e)) +} + +pub fn encrypt_message_b(raw_msg: &mut Vec, re_pub: PublicKey) -> Result, Error> { + // let message b = [pubkey/AD][nonce][authentication tag][ciphertext] + let secp = Secp256k1::new(); + let (e_sec, e_pub) = secp.generate_keypair(&mut OsRng); + let ee = SharedSecret::new(&re_pub, &e_sec); + let cipher = ChaCha20Poly1305::new_from_slice(&ee.secret_bytes()) + .map_err(|_| InternalError::InvalidKeyLength)?; + let nonce = Nonce::from_slice(&[0u8; 12]); // key es encrypts only 1 message so 0 is unique + let aad = &e_pub.serialize(); + let msg = pad(raw_msg)?; + let payload = Payload { msg, aad }; + let c_t = cipher.encrypt(nonce, payload)?; + let mut message_b = e_pub.serialize().to_vec(); + message_b.extend(&nonce[..]); + message_b.extend(&c_t[..]); + Ok(message_b) +} + +pub fn decrypt_message_b(message_b: &mut [u8], e: SecretKey) -> Result, Error> { + // let message b = [pubkey/AD][nonce][authentication tag][ciphertext] + let re = PublicKey::from_slice(&message_b[..33])?; + let nonce = Nonce::from_slice(&message_b[33..45]); + let ee = SharedSecret::new(&re, &e); + let cipher = ChaCha20Poly1305::new_from_slice(&ee.secret_bytes()) + .map_err(|_| InternalError::InvalidKeyLength)?; + let payload = Payload { msg: &message_b[45..], aad: &re.serialize() }; + let buffer = cipher.decrypt(nonce, payload)?; + Ok(buffer) +} + +fn pad(msg: &mut Vec) -> Result<&[u8], Error> { + if msg.len() > PADDED_MESSAGE_BYTES { + return Err(Error(InternalError::PayloadTooLarge)); + } + while msg.len() < PADDED_MESSAGE_BYTES { + msg.push(0); + } + Ok(msg) +} + +/// Error that may occur when de/encrypting or de/capsulating a v2 message. +/// +/// This is currently opaque type because we aren't sure which variants will stay. +/// You can only display it. +#[derive(Debug)] +pub struct Error(InternalError); + +#[derive(Debug)] +pub(crate) enum InternalError { + Ohttp(ohttp::Error), + Bhttp(bhttp::Error), + ParseUrl(url::ParseError), + Secp256k1(bitcoin::secp256k1::Error), + ChaCha20Poly1305(chacha20poly1305::aead::Error), + InvalidKeyLength, + PayloadTooLarge, +} + +impl From for Error { + fn from(value: ohttp::Error) -> Self { Self(InternalError::Ohttp(value)) } +} + +impl From for Error { + fn from(value: bhttp::Error) -> Self { Self(InternalError::Bhttp(value)) } +} + +impl From for Error { + fn from(value: url::ParseError) -> Self { Self(InternalError::ParseUrl(value)) } +} + +impl From for Error { + fn from(value: bitcoin::secp256k1::Error) -> Self { Self(InternalError::Secp256k1(value)) } +} + +impl From for Error { + fn from(value: chacha20poly1305::aead::Error) -> Self { + Self(InternalError::ChaCha20Poly1305(value)) + } +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + use InternalError::*; + + match &self.0 { + Ohttp(e) => e.fmt(f), + Bhttp(e) => e.fmt(f), + ParseUrl(e) => e.fmt(f), + Secp256k1(e) => e.fmt(f), + ChaCha20Poly1305(e) => e.fmt(f), + InvalidKeyLength => write!(f, "Invalid Length"), + PayloadTooLarge => + write!(f, "Payload too large, max size is {} bytes", PADDED_MESSAGE_BYTES), + } + } +} + +impl error::Error for Error { + fn source(&self) -> Option<&(dyn error::Error + 'static)> { + use InternalError::*; + + match &self.0 { + Ohttp(e) => Some(e), + Bhttp(e) => Some(e), + ParseUrl(e) => Some(e), + Secp256k1(e) => Some(e), + ChaCha20Poly1305(_) | InvalidKeyLength | PayloadTooLarge => None, + } + } +} + +impl From for Error { + fn from(value: InternalError) -> Self { Self(value) } +} + +pub fn ohttp_encapsulate( + ohttp_config: &[u8], + method: &str, + target_resource: &str, + body: Option<&[u8]>, +) -> Result<(Vec, ohttp::ClientResponse), Error> { + let ctx = ohttp::ClientRequest::from_encoded_config(ohttp_config)?; + let url = url::Url::parse(target_resource)?; + let mut bhttp_message = bhttp::Message::request( + method.as_bytes().to_vec(), + url.scheme().as_bytes().to_vec(), + url.authority().as_bytes().to_vec(), + url.path().as_bytes().to_vec(), + ); + if let Some(body) = body { + bhttp_message.write_content(body); + } + let mut bhttp_req = Vec::new(); + let _ = bhttp_message.write_bhttp(bhttp::Mode::KnownLength, &mut bhttp_req); + let encapsulated = ctx.encapsulate(&bhttp_req)?; + Ok(encapsulated) +} + +/// decapsulate ohttp, bhttp response and return http response body and status code +pub fn ohttp_decapsulate( + res_ctx: ohttp::ClientResponse, + ohttp_body: &[u8], +) -> Result, Error> { + let bhttp_body = res_ctx.decapsulate(ohttp_body)?; + let mut r = std::io::Cursor::new(bhttp_body); + let response = bhttp::Message::read_bhttp(&mut r)?; + Ok(response.content().to_vec()) +} diff --git a/payjoin/tests/integration.rs b/payjoin/tests/integration.rs index dd4cc545..7848408a 100644 --- a/payjoin/tests/integration.rs +++ b/payjoin/tests/integration.rs @@ -1,67 +1,647 @@ #[cfg(all(feature = "send", feature = "receive"))] mod integration { use std::collections::HashMap; + use std::env; + use std::fmt::Write; use std::str::FromStr; + 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::send::{Request, RequestBuilder}; - use payjoin::{bitcoin, Error, Uri}; - - #[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"); + use payjoin::Uri; + + type BoxError = Box; + + #[cfg(not(feature = "v2"))] + mod v1 { + use payjoin::receive::{Headers, PayjoinProposal, UncheckedProposal}; + + use super::*; + + const EXAMPLE_URL: &str = "https://example.com"; + + #[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)?.assume_checked(); + let pj_uri = build_pj_uri(pj_receiver_address, Amount::ONE_BTC, EXAMPLE_URL, None); + // 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)? + .extract_v1()?; + let headers = HeaderMock::from_vec(&req.body); + + // ********************** + // Inside the Receiver: + // this data would transit from one party to another over the network in production + let response = handle_pj_request(req, headers, 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)?; + Ok(()) + } + + struct HeaderMock(HashMap); + + impl Headers for HeaderMock { + fn get_header(&self, key: &str) -> Option<&str> { self.0.get(key).map(|e| e.as_str()) } + } + + impl HeaderMock { + fn from_vec(body: &[u8]) -> HeaderMock { + let mut h = HashMap::new(); + h.insert("content-type".to_string(), "text/plain".to_string()); + h.insert("content-length".to_string(), body.len().to_string()); + HeaderMock(h) + } + } + + // 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( + req: Request, + headers: impl Headers, + receiver: bitcoincore_rpc::Client, + ) -> String { + // Receiver receive payjoin proposal, IRL it will be an HTTP request (over ssl or onion) + let proposal = payjoin::receive::UncheckedProposal::from_request( + req.body.as_slice(), + req.url.query().unwrap_or(""), + headers, + ) + .unwrap(); + let proposal = handle_proposal(proposal, receiver); + let psbt = proposal.psbt(); + debug!("Receiver's Payjoin proposal PSBT: {:#?}", &psbt); + base64::encode(&psbt.serialize()) + } + + fn handle_proposal( + proposal: UncheckedProposal, + receiver: bitcoincore_rpc::Client, + ) -> 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_can_broadcast(|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(); + payjoin_proposal + } + } + + #[cfg(feature = "v2")] + mod v2 { + use std::process::Stdio; + use std::sync::Arc; + + use payjoin::receive::v2::{Enroller, PayjoinProposal, UncheckedProposal}; + use testcontainers::Container; + use testcontainers_modules::postgres::Postgres; + use testcontainers_modules::testcontainers::clients::Cli; + use tokio::process::{Child, Command}; + use tokio::task::spawn_blocking; + + use super::*; + + const PJ_RELAY_URL: &str = "https://localhost:8088"; + const OH_RELAY_URL: &str = "https://localhost:8088"; + const LOCAL_CERT_FILE: &str = "localhost.der"; + + #[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()?; + + // ********************** + // From a connection distinct from the client, perhaps a service provider, or over a VPN or Tor + // get ohttp-config at PJ_RELAY_URL in spawn_blocking + let ohttp_config = { + let response = spawn_blocking(move || { + http_agent().get(&format!("{}/ohttp-config", PJ_RELAY_URL)).call() + }) + .await??; + response.into_string()? + }; + debug!("GET'd ohttp-config: {}", ohttp_config); + + // ********************** + // Inside the Receiver: + // Enroll with relay + let mut enroller = + Enroller::from_relay_config(&PJ_RELAY_URL, &ohttp_config, &OH_RELAY_URL); + let (req, ctx) = enroller.extract_req()?; + let res = + spawn_blocking(move || http_agent().post(req.url.as_str()).send_bytes(&req.body)) + .await??; + assert!(is_success(res.status())); + let enrolled = enroller.process_res(res.into_reader(), ctx)?; + let fallback_target = enrolled.fallback_target(); + // Receiver creates the payjoin URI + let pj_receiver_address = receiver.get_new_address(None, None)?.assume_checked(); + let pj_uri = build_pj_uri( + pj_receiver_address, + Amount::ONE_BTC, + &fallback_target, + Some(&ohttp_config), + ); + + // ********************** + // 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 (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(OH_RELAY_URL)?; + log::info!("send fallback v2"); + log::debug!("Request: {:#?}", &send_req.body); + let response = { + let Request { url, body, .. } = send_req.clone(); + spawn_blocking(move || { + http_agent() + .post(url.as_str()) + .set("Content-Type", "text/plain") + .set("Async", "true") + .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 response = + spawn_blocking(move || http_agent().post(req.url.as_str()).send_bytes(&req.body)) + .await??; + + // POST payjoin + let proposal = enrolled.process_res(response.into_reader(), ctx)?.unwrap(); + let payjoin_proposal = handle_relay_proposal(receiver, proposal); + let (req, ctx) = payjoin_proposal.extract_v2_req()?; + let response = + spawn_blocking(move || http_agent().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 response = spawn_blocking(move || { + http_agent().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"); + relay.kill().await?; + let output = &relay.wait_with_output().await?; + log::info!("Status: {}", output.status); + Ok(()) + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + #[cfg(feature = "v2")] + 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()?; + + // ********************** + // From a connection distinct from the client, perhaps a service provider, or over a VPN or Tor + // get ohttp-config at PJ_RELAY_URL in spawn_blocking + let ohttp_config = { + let response = spawn_blocking(move || { + http_agent().get(&format!("{}/ohttp-config", PJ_RELAY_URL)).call() + }) + .await??; + response.into_string()? + }; + debug!("GET'd ohttp-config: {}", ohttp_config); + + // ********************** + // Inside the Receiver: + // Enroll with relay + let mut enroller = + Enroller::from_relay_config(&PJ_RELAY_URL, &ohttp_config, &OH_RELAY_URL); + let (req, ctx) = enroller.extract_req()?; + let res = + spawn_blocking(move || http_agent().post(req.url.as_str()).send_bytes(&req.body)) + .await??; + assert!(is_success(res.status())); + let enrolled = enroller.process_res(res.into_reader(), ctx)?; + + // Receiver creates the payjoin URI + let pj_receiver_address = receiver.get_new_address(None, None)?.assume_checked(); + let fallback_target = enrolled.fallback_target(); + let pj_uri = build_pj_uri( + pj_receiver_address, + Amount::ONE_BTC, + &fallback_target, + Some(&ohttp_config), + ); + + // ********************** + // 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 (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(); + spawn_blocking(move || { + http_agent() + .post(url.as_str()) + .set("Content-Type", "text/plain") + .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 receiver_loop = tokio::task::spawn(async move { + let (response, ctx) = loop { + let (req, ctx) = enrolled.extract_req().unwrap(); + let response = spawn_blocking(move || { + http_agent().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 relay response"); + let proposal = enrolled.process_res(response, ctx).unwrap().unwrap(); + let payjoin_proposal = handle_relay_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 || { + http_agent().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 relay"); + // 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(); + spawn_blocking(move || { + http_agent() + .post(url.as_str()) + .set("Content-Type", "text/plain") + .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"); + relay.kill().await?; + let output = &relay.wait_with_output().await?; + log::info!("Status: {}", output.status); + Ok(()) + } + + 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 handle_relay_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_can_broadcast(|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() -> 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() + } + } + + 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).unwrap(); - let receiver = bitcoind.create_wallet("receiver").unwrap(); + 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)).unwrap().assume_checked(); - let sender = bitcoind.create_wallet("sender").unwrap(); + 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)).unwrap().assume_checked(); - bitcoind.client.generate_to_address(1, &receiver_address).unwrap(); - bitcoind.client.generate_to_address(101, &sender_address).unwrap(); + 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).unwrap(), - receiver.get_balances().unwrap().mine.trusted, + Amount::from_btc(50.0)?, + receiver.get_balances()?.mine.trusted, "receiver doesn't own bitcoin" ); assert_eq!( - Amount::from_btc(50.0).unwrap(), - sender.get_balances().unwrap().mine.trusted, + Amount::from_btc(50.0)?, + sender.get_balances()?.mine.trusted, "sender doesn't own bitcoin" ); + Ok((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() - ); + fn build_pj_uri<'a>( + address: bitcoin::Address, + amount: Amount, + pj: &'a str, + ohttp: Option<&'a str>, + ) -> Uri<'a, NetworkChecked> { + let mut pj_uri_string = + format!("{}?amount={}&pj={}", address.to_qr_uri(), amount.to_btc(), pj,); + if let Some(ohttp) = ohttp { + write!(pj_uri_string, "&ohttp={}", ohttp).unwrap(); + } + debug!("PJ URI: {}", &pj_uri_string); let pj_uri = Uri::from_str(&pj_uri_string).unwrap(); - let pj_uri = pj_uri.assume_checked(); - // Sender create a funded PSBT (not broadcasted) to address with amount given in the pj_uri + 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(payjoin::bitcoin::Amount::from_sat(2000)), + fee_rate: Some(Amount::from_sat(2000)), ..Default::default() }; let psbt = sender @@ -71,159 +651,25 @@ mod integration { 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(); - 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 headers = HeaderMock::from_vec(&req.body); - - // ********************** - // Inside the Receiver: - // this data would transit from one party to another over the network in production - let response = handle_pj_request(req, headers, 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()).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 payjoin_tx = payjoin_psbt.extract_tx(); - bitcoind.client.send_raw_transaction(&payjoin_tx).unwrap(); + let psbt = sender.wallet_process_psbt(&psbt, None, None, None)?.psbt; + Ok(Psbt::from_str(&psbt)?) } - struct HeaderMock(HashMap); - - impl Headers for HeaderMock { - fn get_header(&self, key: &str) -> Option<&str> { self.0.get(key).map(|e| e.as_str()) } - } + 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); - impl HeaderMock { - fn from_vec(body: &[u8]) -> HeaderMock { - let mut h = HashMap::new(); - h.insert("content-type".to_string(), "text/plain".to_string()); - h.insert("content-length".to_string(), body.len().to_string()); - HeaderMock(h) - } + Ok(payjoin_psbt.extract_tx()) } - // 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( - req: Request, - headers: impl Headers, - receiver: bitcoincore_rpc::Client, - ) -> String { - // Receiver receive payjoin proposal, IRL it will be an HTTP request (over ssl or onion) - let proposal = payjoin::receive::UncheckedProposal::from_request( - req.body.as_slice(), - req.url.query().unwrap_or(""), - headers, - ) - .unwrap(); - - // 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_can_broadcast(|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(); - let psbt = payjoin_proposal.psbt(); - debug!("Receiver's Payjoin proposal PSBT: {:#?}", &psbt); - base64::encode(&psbt.serialize()) - } + fn is_success(status: u16) -> bool { status >= 200 && status < 300 } }