diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 53693fac2..c0fc3c139 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -136,3 +136,78 @@ jobs: # Run the functional tests LIANAD_PATH=$PWD/target/release/lianad pytest tests/ -vvv -n 8 + + payjoin-functional-test: + runs-on: self-hosted + timeout-minutes: 30 + + env: + VERBOSE: 0 + LOG_LEVEL: debug + TIMEOUT: 120 + + steps: + - uses: actions/checkout@v4 + + - name: cleanup /tmp + run: | + find /tmp -maxdepth 1 -type d -name 'lianad*' -mtime +0 -exec rm -rf {} + + + - name: Setup Python dependencies + run: | + pip install --break-system-packages -r tests/requirements.txt + + - name: Add local bin to PATH + run: echo "$HOME/.local/bin" >> $GITHUB_PATH + + - name: Cache Cargo registry + uses: actions/cache@v4 + with: + path: ~/.cargo/registry + key: ${{ runner.os }}-cargo-registry-payjoin + restore-keys: | + ${{ runner.os }}-cargo-registry- + + - name: Cache Cargo git + uses: actions/cache@v4 + with: + path: ~/.cargo/git + key: ${{ runner.os }}-cargo-git-payjoin + restore-keys: | + ${{ runner.os }}-cargo-git- + + - name: Cache Cargo target (lianad) + uses: actions/cache@v4 + with: + path: target + key: ${{ runner.os }}-target-${{ hashFiles('Cargo.lock') }}-payjoin + restore-keys: | + ${{ runner.os }}-target- + + - name: Build lianad + run: | + cd lianad + cargo build --release + + - name: Install payjoin-cli + run: | + PAYJOIN_REV="7f8ec333a79aa4c0a6d5be437dd858452d45bd64" + rm -rf /tmp/rust-payjoin + git clone https://github.com/payjoin/rust-payjoin.git /tmp/rust-payjoin + git -C /tmp/rust-payjoin checkout "$PAYJOIN_REV" + cd /tmp/rust-payjoin + cargo build --release -p payjoin-cli + cp target/release/payjoin-cli "$HOME/.local/bin/" + + - name: Install bitcoind + run: | + curl -O https://bitcoincore.org/bin/bitcoin-core-29.0/bitcoin-29.0-x86_64-linux-gnu.tar.gz + echo "a681e4f6ce524c338a105f214613605bac6c33d58c31dc5135bbc02bc458bb6c bitcoin-29.0-x86_64-linux-gnu.tar.gz" | sha256sum -c + tar -xzf bitcoin-29.0-x86_64-linux-gnu.tar.gz + + - name: Run payjoin integration test + run: | + BITCOIND_PATH=$PWD/bitcoin-29.0/bin/bitcoind \ + PAYJOIN_CLI_PATH=$(which payjoin-cli) \ + LIANAD_PATH=$PWD/target/release/lianad \ + pytest tests/test_payjoin.py -vvv -x diff --git a/Cargo.lock b/Cargo.lock index b69f648b6..78d05c55e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -33,6 +33,16 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" +[[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" @@ -43,6 +53,18 @@ dependencies = [ "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.17", + "opaque-debug", +] + [[package]] name = "aes" version = "0.8.4" @@ -50,8 +72,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", - "cipher", - "cpufeatures", + "cipher 0.4.4", + "cpufeatures 0.2.17", +] + +[[package]] +name = "aes-gcm" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc3be92e19a7ef47457b8e6f90707e12b6ac5d20c6f3866584fa3be0787d839f" +dependencies = [ + "aead 0.4.3", + "aes 0.7.5", + "cipher 0.3.0", + "ctr 0.7.0", + "ghash 0.4.4", + "subtle", ] [[package]] @@ -60,11 +96,11 @@ version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" dependencies = [ - "aead", - "aes", - "cipher", - "ctr", - "ghash", + "aead 0.5.2", + "aes 0.8.4", + "cipher 0.4.4", + "ctr 0.9.2", + "ghash 0.5.1", "subtle", ] @@ -303,7 +339,7 @@ dependencies = [ "ledger-transport-hidapi", "ledger_bitcoin_client", "regex", - "reqwest", + "reqwest 0.11.27", "serde", "serde_bytes", "serde_cbor", @@ -551,6 +587,15 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d965446196e3b7decd44aa7ee49e31d630118f90ef12f97900f262eb915c951d" +[[package]] +name = "bhttp" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16fc24bc615b9fd63148f59b218ea58a444b55762f8845da910e23aca686398b" +dependencies = [ + "thiserror 1.0.69", +] + [[package]] name = "bip329" version = "0.3.0" @@ -624,9 +669,9 @@ dependencies = [ [[package]] name = "bitcoin" -version = "0.32.5" +version = "0.32.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce6bc65742dea50536e35ad42492b234c27904a27f0abdcbce605015cb4ea026" +checksum = "9cf93e61f2dbc3e3c41234ca26a65e2c0b0975c52e0f069ab9893ebbede584d3" dependencies = [ "base58ck", "base64 0.21.7", @@ -635,7 +680,7 @@ dependencies = [ "bitcoin-io", "bitcoin-units", "bitcoin_hashes 0.14.0", - "hex-conservative 0.2.1", + "hex-conservative 0.2.2", "hex_lit", "secp256k1", "serde", @@ -647,12 +692,31 @@ version = "0.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f4e7cfd72b4e1eac651e2908660e90e65b729052bfd5d4004395a402c3e655cc" dependencies = [ - "aes-gcm", + "aes-gcm 0.10.3", "miniscript", "num_enum", "rand 0.9.2", ] +[[package]] +name = "bitcoin-hpke" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d37a54c486727c1d1ae9cc28dcf78b6e6ba20dcb88e8c892f1437d9ce215dc8c" +dependencies = [ + "aead 0.5.2", + "chacha20poly1305 0.10.1", + "digest 0.10.7", + "generic-array", + "hkdf 0.12.4", + "hmac 0.12.1", + "rand_core 0.6.4", + "secp256k1", + "sha2 0.10.9", + "subtle", + "zeroize", +] + [[package]] name = "bitcoin-internals" version = "0.2.0" @@ -674,6 +738,29 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b47c4ab7a93edb0c7198c5535ed9b52b63095f4e9b45279c6736cec4b856baf" +[[package]] +name = "bitcoin-ohttp" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87a803a4b54e44635206b53329c78c0029d0c70926288ac2f07f4bb1267546cb" +dependencies = [ + "aead 0.4.3", + "aes-gcm 0.9.2", + "bitcoin-hpke", + "byteorder", + "chacha20poly1305 0.8.0", + "hex", + "hkdf 0.11.0", + "lazy_static", + "log", + "rand 0.8.5", + "serde", + "serde_derive", + "sha2 0.9.9", + "thiserror 1.0.69", + "toml 0.5.11", +] + [[package]] name = "bitcoin-private" version = "0.1.0" @@ -716,10 +803,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb18c03d0db0247e147a21a6faafd5a7eb851c743db062de72018b6b7e8e4d16" dependencies = [ "bitcoin-io", - "hex-conservative 0.2.1", + "hex-conservative 0.2.2", "serde", ] +[[package]] +name = "bitcoin_uri" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0a228e083d1702f83389b0ac71eb70078dc8d7fcbb6cde864d1cbca145f5cc" +dependencies = [ + "bitcoin", + "percent-encoding-rfc3986", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -744,7 +841,7 @@ version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" dependencies = [ - "digest", + "digest 0.10.7", ] [[package]] @@ -753,6 +850,15 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" +[[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" version = "0.10.4" @@ -813,7 +919,7 @@ dependencies = [ "liana-gui", "liana-ui", "miniscript", - "reqwest", + "reqwest 0.11.27", "rfd", "rustls 0.23.36", "serde", @@ -951,6 +1057,18 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[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" @@ -958,8 +1076,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" dependencies = [ "cfg-if", - "cipher", - "cpufeatures", + "cipher 0.4.4", + "cpufeatures 0.2.17", +] + +[[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]] @@ -968,10 +1099,10 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" dependencies = [ - "aead", - "chacha20", - "cipher", - "poly1305", + "aead 0.5.2", + "chacha20 0.9.1", + "cipher 0.4.4", + "poly1305 0.8.0", "zeroize", ] @@ -988,6 +1119,15 @@ dependencies = [ "windows-link", ] +[[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" @@ -1055,10 +1195,10 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1178252cca421753b1024d240d43e502b019f51ca8022652587cdad4347d58b7" dependencies = [ - "aes", + "aes 0.8.4", "base58", "bitcoin_hashes 0.13.0", - "ctr", + "ctr 0.9.2", "hidapi", "k256", "rand 0.8.5", @@ -1209,6 +1349,15 @@ dependencies = [ "unicode-segmentation", ] +[[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.17" @@ -1321,22 +1470,42 @@ 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.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25fab6889090c8133f3deb8f73ba3c65a7f456f66436fc012a1b1e272b1e103e" +dependencies = [ + "generic-array", + "subtle", +] + [[package]] name = "ctor-lite" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f791803201ab277ace03903de1594460708d2d54df6053f2d9e82f592b19e3b" +[[package]] +name = "ctr" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a232f92a03f37dd7d7dd2adc67166c77e9cd88de5b019b9a9eecfaeaf7bfd481" +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", + "cipher 0.4.4", ] [[package]] @@ -1352,7 +1521,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "curve25519-dalek-derive", "fiat-crypto", "rustc_version", @@ -1404,13 +1573,22 @@ dependencies = [ "syn 2.0.98", ] +[[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", @@ -1516,7 +1694,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" dependencies = [ "der", - "digest", + "digest 0.10.7", "elliptic-curve", "rfc6979", "signature", @@ -1554,7 +1732,7 @@ checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" dependencies = [ "base16ct", "crypto-bigint", - "digest", + "digest 0.10.7", "ff", "generic-array", "group", @@ -1643,7 +1821,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33d852cb9b869c2a9b3df2f71a3074817f01e1844f839a144f5fcef059a4eb5d" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -2046,8 +2224,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi 0.11.0+wasi-snapshot-preview1", + "wasm-bindgen", ] [[package]] @@ -2057,11 +2237,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "43a49c392881ce6d5c3b8cb70f98717b7c07aabbdff06687b9030dbfbe2725f8" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi 0.13.3+wasi-0.2.2", + "wasm-bindgen", "windows-targets 0.52.6", ] +[[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.1" @@ -2069,7 +2261,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" dependencies = [ "opaque-debug", - "polyval", + "polyval 0.6.2", ] [[package]] @@ -2323,9 +2515,9 @@ checksum = "212ab92002354b4819390025006c897e8140934349e8635c9b077f47b4dcbd20" [[package]] name = "hex-conservative" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5313b072ce3c597065a808dbf612c4c8e8590bdbf8b579508bf7a762c5eae6cd" +checksum = "fda06d18ac606267c40c04e41b9947729bf8b9efe74bd4e82b61a5f26a510b9f" dependencies = [ "arrayvec", ] @@ -2355,13 +2547,42 @@ dependencies = [ "windows-sys 0.61.2", ] +[[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.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +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", + "digest 0.10.7", ] [[package]] @@ -2405,6 +2626,29 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http 1.4.0", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http 1.4.0", + "http-body 1.0.1", + "pin-project-lite", +] + [[package]] name = "httparse" version = "1.10.0" @@ -2429,18 +2673,38 @@ dependencies = [ "futures-util", "h2", "http 0.2.12", - "http-body", + "http-body 0.4.6", "httparse", "httpdate", "itoa", "pin-project-lite", - "socket2", + "socket2 0.5.8", "tokio", "tower-service", "tracing", "want", ] +[[package]] +name = "hyper" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http 1.4.0", + "http-body 1.0.1", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + [[package]] name = "hyper-rustls" version = "0.24.2" @@ -2449,10 +2713,49 @@ checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590" dependencies = [ "futures-util", "http 0.2.12", - "hyper", + "hyper 0.14.32", "rustls 0.21.12", "tokio", - "tokio-rustls", + "tokio-rustls 0.24.1", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http 1.4.0", + "hyper 1.9.0", + "hyper-util", + "rustls 0.23.36", + "tokio", + "tokio-rustls 0.26.4", + "tower-service", + "webpki-roots 1.0.5", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "hyper 1.9.0", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2 0.6.3", + "tokio", + "tower-service", + "tracing", ] [[package]] @@ -3061,7 +3364,7 @@ dependencies = [ "ecdsa", "elliptic-curve", "once_cell", - "sha2", + "sha2 0.10.9", "signature", ] @@ -3215,7 +3518,7 @@ dependencies = [ "getrandom 0.3.1", "liana", "miniscript", - "reqwest", + "reqwest 0.11.27", "serde", "serde_json", "tracing", @@ -3261,7 +3564,8 @@ dependencies = [ "libc", "log", "open", - "reqwest", + "payjoin", + "reqwest 0.11.27", "rfd", "rust-ini", "serde", @@ -3301,6 +3605,8 @@ dependencies = [ "liana", "log", "miniscript", + "payjoin", + "reqwest 0.11.27", "rusqlite", "serde", "serde_json", @@ -3330,7 +3636,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc2f4eb4bc735547cfed7c0a4922cbd04a4655978c09b54f1f7b228750664c34" dependencies = [ "cfg-if", - "windows-targets 0.48.5", + "windows-targets 0.52.6", ] [[package]] @@ -3451,6 +3757,12 @@ version = "0.16.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1dc47f592c06f33f8e3aea9591776ec7c9f9e4124778ff8a3c3b87159f7e593" +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "lyon" version = "1.0.1" @@ -3752,11 +4064,11 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4c6159f60beb3bbbcdc266bc789bfc6c37fdad7d7ca7152d3e049ef5af633f0" dependencies = [ - "aes-gcm", + "aes-gcm 0.10.3", "blake2", - "chacha20poly1305", + "chacha20poly1305 0.10.1", "noise-protocol", - "sha2", + "sha2 0.10.9", "x25519-dalek", "zeroize", ] @@ -4303,12 +4615,37 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" +[[package]] +name = "payjoin" +version = "0.25.0" +source = "git+https://github.com/payjoin/rust-payjoin.git?rev=7f8ec333a79aa4c0a6d5be437dd858452d45bd64#7f8ec333a79aa4c0a6d5be437dd858452d45bd64" +dependencies = [ + "bhttp", + "bitcoin", + "bitcoin-hpke", + "bitcoin-ohttp", + "bitcoin_uri", + "http 1.4.0", + "percent-encoding-rfc3986", + "reqwest 0.12.28", + "serde", + "serde_json", + "tracing", + "web-time", +] + [[package]] name = "percent-encoding" version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" +[[package]] +name = "percent-encoding-rfc3986" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3637c05577168127568a64e9dc5a6887da720efef07b3d9472d45f63ab191166" + [[package]] name = "petgraph" version = "0.6.5" @@ -4418,15 +4755,38 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" +[[package]] +name = "poly1305" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "048aeb476be11a4b6ca432ca569e375810de9294ae78f4774e78ea98a9246ede" +dependencies = [ + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash 0.4.0", +] + [[package]] name = "poly1305" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" dependencies = [ - "cpufeatures", + "cpufeatures 0.2.17", + "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.17", "opaque-debug", - "universal-hash", + "universal-hash 0.4.0", ] [[package]] @@ -4436,9 +4796,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "opaque-debug", - "universal-hash", + "universal-hash 0.5.1", ] [[package]] @@ -4638,6 +4998,61 @@ dependencies = [ "memchr", ] +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash 2.1.1", + "rustls 0.23.36", + "socket2 0.5.8", + "thiserror 2.0.17", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.1", + "lru-slab", + "rand 0.9.2", + "ring", + "rustc-hash 2.1.1", + "rustls 0.23.36", + "rustls-pki-types", + "slab", + "thiserror 2.0.17", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2 0.5.8", + "tracing", + "windows-sys 0.59.0", +] + [[package]] name = "quote" version = "1.0.38" @@ -4901,9 +5316,9 @@ dependencies = [ "futures-util", "h2", "http 0.2.12", - "http-body", - "hyper", - "hyper-rustls", + "http-body 0.4.6", + "hyper 0.14.32", + "hyper-rustls 0.24.2", "ipnet", "js-sys", "log", @@ -4916,10 +5331,10 @@ dependencies = [ "serde", "serde_json", "serde_urlencoded", - "sync_wrapper", + "sync_wrapper 0.1.2", "system-configuration", "tokio", - "tokio-rustls", + "tokio-rustls 0.24.1", "tokio-util", "tower-service", "url", @@ -4931,6 +5346,44 @@ dependencies = [ "winreg", ] +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "hyper 1.9.0", + "hyper-rustls 0.27.9", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls 0.23.36", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper 1.0.2", + "tokio", + "tokio-rustls 0.26.4", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots 1.0.5", +] + [[package]] name = "resvg" version = "0.45.1" @@ -4954,7 +5407,7 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" dependencies = [ - "hmac", + "hmac 0.12.1", "subtle", ] @@ -5073,7 +5526,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.4.15", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -5086,7 +5539,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -5131,6 +5584,7 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79" dependencies = [ + "web-time", "zeroize", ] @@ -5250,6 +5704,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113" dependencies = [ "bitcoin_hashes 0.14.0", + "rand 0.8.5", "secp256k1-sys", "serde", ] @@ -5327,14 +5782,15 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.138" +version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d434192e7da787e94a6ea7e9670b26a036d0ca41e0b7efb2676dd32bae872949" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ "itoa", "memchr", - "ryu", "serde", + "serde_core", + "zmij", ] [[package]] @@ -5404,8 +5860,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "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.17", + "digest 0.9.0", + "opaque-debug", ] [[package]] @@ -5415,8 +5884,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", ] [[package]] @@ -5449,7 +5918,7 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ - "digest", + "digest 0.10.7", "rand_core 0.6.4", ] @@ -5604,6 +6073,16 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "softbuffer" version = "0.4.8" @@ -5742,6 +6221,15 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + [[package]] name = "synstructure" version = "0.13.1" @@ -5823,7 +6311,7 @@ dependencies = [ "getrandom 0.3.1", "once_cell", "rustix 0.38.44", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -5981,7 +6469,7 @@ dependencies = [ "mio", "pin-project-lite", "signal-hook-registry", - "socket2", + "socket2 0.5.8", "tokio-macros", "windows-sys 0.52.0", ] @@ -6007,6 +6495,16 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls 0.23.36", + "tokio", +] + [[package]] name = "tokio-serial" version = "5.4.5" @@ -6116,6 +6614,45 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df8b2b54733674ad286d16267dcfc7a71ed5c776e4ac7aa3c3e2561f7c637bf2" +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper 1.0.2", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.11.0", + "bytes", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + [[package]] name = "tower-service" version = "0.3.3" @@ -6310,6 +6847,16 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" +[[package]] +name = "universal-hash" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8326b2c654932e3e4f9196e69d08fdf7cfd718e1dc6f66b347e6024a0c961402" +dependencies = [ + "generic-array", + "subtle", +] + [[package]] name = "universal-hash" version = "0.5.1" @@ -6912,7 +7459,7 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.59.0", ] [[package]] @@ -7651,6 +8198,12 @@ dependencies = [ "flate2", ] +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + [[package]] name = "zune-core" version = "0.4.12" diff --git a/liana-gui/Cargo.toml b/liana-gui/Cargo.toml index c04922f0c..cb06ac351 100644 --- a/liana-gui/Cargo.toml +++ b/liana-gui/Cargo.toml @@ -65,6 +65,9 @@ open = { workspace = true } encrypted_backup = { workspace = true } +# payjoin +payjoin = { git = "https://github.com/payjoin/rust-payjoin.git", rev = "7f8ec333a79aa4c0a6d5be437dd858452d45bd64", features = ["v2", "io"] } + [target.'cfg(windows)'.dependencies] zip = { workspace = true, default-features = false, features = ["bzip2", "deflate"] } diff --git a/liana-gui/src/app/message.rs b/liana-gui/src/app/message.rs index 0568bf504..3e4ddd02d 100644 --- a/liana-gui/src/app/message.rs +++ b/liana-gui/src/app/message.rs @@ -67,6 +67,9 @@ pub enum Message { BroadcastModal(Result, Error>), RbfModal(Box, bool, Result, Error>), Export(ImportExportMessage), + ReceivePayjoin(Result<(Address, ChildNumber, Option), Error>), + PayjoinInitiated(Result), + ActivePayjoinSessions(Result, Error>), } impl From for Message { diff --git a/liana-gui/src/app/state/psbt.rs b/liana-gui/src/app/state/psbt.rs index a6353f512..cad9e0e3d 100644 --- a/liana-gui/src/app/state/psbt.rs +++ b/liana-gui/src/app/state/psbt.rs @@ -57,6 +57,7 @@ pub enum PsbtModal { Save(SaveModal), Sign(SignModal), Broadcast(BroadcastModal), + SendPayjoin(SendPayjoinModal), Delete(DeleteModal), Export(ExportModal), } @@ -67,6 +68,7 @@ impl<'a> AsRef for PsbtModal { Self::Save(a) => a, Self::Sign(a) => a, Self::Broadcast(a) => a, + Self::SendPayjoin(a) => a, Self::Delete(a) => a, Self::Export(a) => a, } @@ -79,6 +81,7 @@ impl<'a> AsMut for PsbtModal { Self::Save(a) => a, Self::Sign(a) => a, Self::Broadcast(a) => a, + Self::SendPayjoin(a) => a, Self::Delete(a) => a, Self::Export(a) => a, } @@ -201,6 +204,16 @@ impl PsbtState { self.modal = Some(PsbtModal::Sign(modal)); return cmd; } + Message::View(view::Message::Spend(view::SpendTxMessage::SendPayjoin)) => { + self.warning = None; + self.modal = Some(PsbtModal::SendPayjoin(SendPayjoinModal::default())); + } + Message::View(view::Message::Spend(view::SpendTxMessage::BroadcastPjFallback)) => { + self.modal = Some(PsbtModal::Broadcast(BroadcastModal { + is_payjoin_fallback: true, + ..Default::default() + })); + } Message::View(view::Message::Spend(view::SpendTxMessage::Broadcast)) => { let outpoints: Vec<_> = self.tx.coins.keys().cloned().collect(); return Task::perform( @@ -264,9 +277,8 @@ impl PsbtState { Message::Export(ImportExportMessage::Progress(Progress::Psbt(psbt))) => { merge_signatures(&mut self.tx.psbt, &psbt); self.tx.sigs = self - .wallet - .main_descriptor - .partial_spend_info(&self.tx.psbt) + .tx + .partial_spend_info(&self.wallet.main_descriptor) .expect("already check in psbt import logic"); } _ => { @@ -357,6 +369,9 @@ pub struct BroadcastModal { error: Option, /// IDs of any directly conflicting transactions. conflicting_txids: HashSet, + /// If true, cancel the payjoin receiver session and broadcast its + /// fallback transaction instead of the stored PSBT. + is_payjoin_fallback: bool, } impl Modal for BroadcastModal { @@ -369,14 +384,19 @@ impl Modal for BroadcastModal { match message { Message::View(view::Message::Spend(view::SpendTxMessage::Confirm)) => { let daemon = daemon.clone(); - let psbt = tx.psbt.clone(); + let txid = tx.psbt.unsigned_tx.compute_txid(); self.error = None; + let is_payjoin_fallback = self.is_payjoin_fallback; return Task::perform( async move { - daemon - .broadcast_spend_tx(&psbt.unsigned_tx.compute_txid()) - .await - .map_err(|e| e.into()) + if is_payjoin_fallback { + daemon + .broadcast_payjoin_fallback(&txid) + .await + .map_err(|e| e.into()) + } else { + daemon.broadcast_spend_tx(&txid).await.map_err(|e| e.into()) + } }, Message::Updated, ); @@ -384,6 +404,9 @@ impl Modal for BroadcastModal { Message::Updated(res) => match res { Ok(()) => { tx.status = SpendStatus::Broadcast; + if self.is_payjoin_fallback { + tx.payjoin_status = Some(lianad::payjoin::types::PayjoinStatus::Failed); + } self.broadcast = true; } Err(e) => self.error = Some(e), @@ -406,6 +429,56 @@ impl Modal for BroadcastModal { } } +#[derive(Default)] +pub struct SendPayjoinModal { + sent: bool, + error: Option, +} + +impl Modal for SendPayjoinModal { + fn update( + &mut self, + daemon: Arc, + message: Message, + tx: &mut SpendTx, + ) -> Task { + match message { + Message::View(view::Message::Spend(view::SpendTxMessage::Confirm)) => { + let daemon = daemon.clone(); + let txid = tx.psbt.unsigned_tx.compute_txid(); + self.error = None; + return Task::perform( + async move { + daemon + .send_payjoin_proposal(&txid) + .await + .map(|_| txid.to_string()) + .map_err(|e| e.into()) + }, + Message::PayjoinInitiated, + ); + } + Message::PayjoinInitiated(res) => match res { + Ok(_) => { + tx.payjoin_status = Some(lianad::payjoin::types::PayjoinStatus::Monitoring); + self.sent = true; + } + Err(e) => self.error = Some(e), + }, + _ => {} + } + Task::none() + } + fn view<'a>(&'a self, content: Element<'a, view::Message>) -> Element<'a, view::Message> { + modal::Modal::new( + content, + view::psbt::send_payjoin_action(self.error.as_ref(), self.sent), + ) + .on_blur(Some(view::Message::Spend(view::SpendTxMessage::Cancel))) + .into() + } +} + #[derive(Default)] pub struct DeleteModal { deleted: bool, @@ -564,7 +637,7 @@ impl Modal for SignModal { } } Message::Updated(res) => match res { - Ok(()) => match self.wallet.main_descriptor.partial_spend_info(&tx.psbt) { + Ok(()) => match tx.partial_spend_info(&self.wallet.main_descriptor) { Ok(sigs) => tx.sigs = sigs, Err(e) => self.error = Some(Error::Unexpected(e.to_string())), }, @@ -749,6 +822,12 @@ mod tests { }]})), ), + ( + Some( + json!({"method": "getpayjoininfo", "params": vec!["4bc07e8fe753f7314b69da02a7cfbedc3e4e0d5fbee316a048240ae87b8aaa58"]}), + ), + Ok(json!({ "Unknown": null})), + ), ( Some(json!({"method": "getlabels", "params": vec![vec![ "4bc07e8fe753f7314b69da02a7cfbedc3e4e0d5fbee316a048240ae87b8aaa58", diff --git a/liana-gui/src/app/state/receive.rs b/liana-gui/src/app/state/receive.rs index 03c746d5a..40418b4c2 100644 --- a/liana-gui/src/app/state/receive.rs +++ b/liana-gui/src/app/state/receive.rs @@ -1,4 +1,5 @@ use std::collections::{HashMap, HashSet}; +use std::str::FromStr; use std::sync::Arc; use iced::{widget::qr_code, Subscription, Task}; @@ -42,12 +43,21 @@ pub struct Addresses { list: Vec
, derivation_indexes: Vec, labels: HashMap, + bip21: HashMap, } impl Addresses { pub fn is_empty(&self) -> bool { self.list.is_empty() && self.derivation_indexes.is_empty() && self.labels.is_empty() } + + pub fn push(&mut self, address: Address, derivation_index: ChildNumber, bip21: Option) { + self.list.push(address); + self.derivation_indexes.push(derivation_index); + if let Some(b) = bip21 { + self.bip21.insert(derivation_index, b); + } + } } impl Labelled for Addresses { @@ -74,6 +84,7 @@ pub struct ReceivePanel { modal: Modal, warning: Option, processing: bool, + active_payjoin_sessions: HashSet, } impl ReceivePanel { @@ -90,6 +101,7 @@ impl ReceivePanel { modal: Modal::None, warning: None, processing: false, + active_payjoin_sessions: HashSet::new(), } } @@ -123,13 +135,18 @@ impl State for ReceivePanel { view::receive::receive( &self.addresses.list, &self.addresses.labels, + &self.addresses.derivation_indexes, + &self.addresses.bip21, &self.prev_addresses.list, + &self.prev_addresses.derivation_indexes, &self.prev_addresses.labels, self.show_prev_addresses, &self.selected, self.labels_edited.cache(), + &self.active_payjoin_sessions, self.prev_continue_from.is_none(), self.processing, + !cache.coins().is_empty(), ), ); @@ -178,8 +195,19 @@ impl State for ReceivePanel { match res { Ok((address, derivation_index)) => { self.warning = None; - self.addresses.list.push(address); - self.addresses.derivation_indexes.push(derivation_index); + self.addresses.push(address, derivation_index, None); + } + Err(e) => self.warning = Some(e), + } + Task::none() + } + Message::ReceivePayjoin(res) => { + self.processing = false; + match res { + Ok((address, derivation_index, bip21)) => { + self.warning = None; + self.addresses + .push(address.clone(), derivation_index, bip21); } Err(e) => self.warning = Some(e), } @@ -216,6 +244,19 @@ impl State for ReceivePanel { Message::ReceiveAddress, ) } + Message::View(view::Message::ReceivePayjoin) => { + let daemon = daemon.clone(); + Task::perform( + async move { + daemon + .receive_payjoin() + .await + .map(|res| (res.address, res.derivation_index, res.bip21)) + .map_err(|e| e.into()) + }, + Message::ReceivePayjoin, + ) + } Message::View(view::Message::ToggleShowPreviousAddresses) => { self.show_prev_addresses = !self.show_prev_addresses; Task::none() @@ -244,8 +285,8 @@ impl State for ReceivePanel { if entry.index == 0.into() { continue; } - self.prev_addresses.list.push(entry.address.clone()); - self.prev_addresses.derivation_indexes.push(entry.index); + self.prev_addresses + .push(entry.address.clone(), entry.index, None); if let Some(label) = &entry.label { self.prev_addresses.labels.insert( LabelItem::from(entry.address.clone()).to_string(), @@ -262,6 +303,20 @@ impl State for ReceivePanel { }; Task::none() } + Message::ActivePayjoinSessions(res) => { + match res { + Ok(indexes) => { + self.active_payjoin_sessions = indexes + .into_iter() + .map(|i| ChildNumber::from_normal_idx(i).unwrap()) + .collect(); + } + Err(e) => { + log::error!("Failed to load active payjoin sessions: {:?}", e); + } + } + Task::none() + } Message::View(view::Message::Next) => { if self.prev_continue_from.is_some() { self.processing = true; @@ -287,9 +342,21 @@ impl State for ReceivePanel { Task::none() } } - Message::View(view::Message::ShowQrCode(i)) => { - if let (Some(address), Some(index)) = (self.address(i), self.derivation_index(i)) { - if let Some(modal) = ShowQrCodeModal::new(address, *index) { + Message::View(view::Message::ShowQrCode(i, bip21)) => { + if let Some(address) = self.address(i) { + if bip21.is_some() { + if let Some(modal) = + ShowQrCodeModal::new(&bip21.clone().unwrap_or(address.to_string())) + { + self.modal = Modal::ShowQrCode(modal); + } else { + tracing::error!( + "Failed to create QR modal for BIP21 '{:?}' (address {})", + bip21, + address + ); + } + } else if let Some(modal) = ShowQrCodeModal::new(&address.to_string()) { self.modal = Modal::ShowQrCode(modal); } } @@ -312,15 +379,28 @@ impl State for ReceivePanel { ) -> Task { let data_dir = self.data_dir.clone(); *self = Self::new(data_dir, wallet); - Task::perform( - async move { - daemon - .list_revealed_addresses(false, true, PREV_ADDRESSES_PAGE_SIZE, None) - .await - .map_err(|e| e.into()) - }, - |res| Message::RevealedAddresses(res, None), - ) + let daemon1 = daemon.clone(); + let daemon2 = daemon.clone(); + Task::batch([ + Task::perform( + async move { + daemon1 + .list_revealed_addresses(false, true, PREV_ADDRESSES_PAGE_SIZE, None) + .await + .map_err(|e| e.into()) + }, + |res| Message::RevealedAddresses(res, None), + ), + Task::perform( + async move { + daemon2 + .get_active_payjoin_receiver_sessions() + .await + .map_err(|e| e.into()) + }, + Message::ActivePayjoinSessions, + ), + ]) } } @@ -421,13 +501,21 @@ pub struct ShowQrCodeModal { } impl ShowQrCodeModal { - pub fn new(address: &Address, index: ChildNumber) -> Option { - qr_code::Data::new(format!("bitcoin:{address}?index={index}")) - .ok() - .map(|qr_code| Self { + pub fn new(address: &str) -> Option { + if Address::from_str(address).is_ok() { + qr_code::Data::new(format!("bitcoin:{address}")) + .ok() + .map(|qr_code| Self { + qr_code, + address: address.to_string(), + }) + } else { + // Already in bip21 format + qr_code::Data::new(address).ok().map(|qr_code| Self { qr_code, address: address.to_string(), }) + } } fn view(&self) -> Element<'_, view::Message> { @@ -481,11 +569,18 @@ mod tests { continue_from: None, })), ), + ( + Some( + json!({"method": "getactivepayjoinreceiversessions", "params": Option::::None}), + ), + Ok(json!(Vec::::new())), + ), ( Some(json!({"method": "getnewaddress", "params": Option::::None})), Ok(json!(GetAddressResult::new( addr.clone(), - ChildNumber::from_normal_idx(0).unwrap() + ChildNumber::from_normal_idx(0).unwrap(), + None ))), ), ]); diff --git a/liana-gui/src/app/state/settings/mod.rs b/liana-gui/src/app/state/settings/mod.rs index 76e309750..7abe6970e 100644 --- a/liana-gui/src/app/state/settings/mod.rs +++ b/liana-gui/src/app/state/settings/mod.rs @@ -1,5 +1,6 @@ mod bitcoind; mod general; +pub mod payjoin; pub mod wallet; use std::convert::From; @@ -10,6 +11,7 @@ use iced::{Subscription, Task}; use liana_ui::{component::form, widget::Element}; use bitcoind::BitcoindSettingsState; +use payjoin::PayjoinSettingsState; use wallet::{update_aliases, WalletSettingsState}; use crate::{ @@ -161,6 +163,14 @@ impl SettingsUI for LianaSettingsUI { .map(|s| s.reload(daemon, wallet)) .unwrap_or_else(Task::none) } + Message::View(view::Message::Settings(view::SettingsMessage::EditPayjoinSettings)) => { + self.setting = Some(PayjoinSettingsState::new(daemon.config().cloned()).into()); + let wallet = self.wallet.clone(); + self.setting + .as_mut() + .map(|s| s.reload(daemon, wallet)) + .unwrap_or_else(Task::none) + } Message::WalletUpdated(Ok(wallet)) => { self.wallet = wallet.clone(); self.setting diff --git a/liana-gui/src/app/state/settings/payjoin.rs b/liana-gui/src/app/state/settings/payjoin.rs new file mode 100644 index 000000000..c82e407f5 --- /dev/null +++ b/liana-gui/src/app/state/settings/payjoin.rs @@ -0,0 +1,170 @@ +use std::sync::Arc; + +use iced::{clipboard, Task}; +use liana_ui::{component::form, widget::Element}; + +use lianad::config::{Config as DaemonConfig, PayjoinConfig}; + +use crate::{ + app::{cache::Cache, error::Error, message::Message, state::settings::State, view}, + daemon::Daemon, + utils::default_payjoin_config, +}; + +#[derive(Debug)] +pub struct PayjoinSettingsState { + warning: Option, + config_updated: bool, + payjoin_settings: PayjoinSettings, +} + +impl PayjoinSettingsState { + pub fn new(config: Option) -> Self { + let payjoin_config = config + .and_then(|c| c.payjoin_config) + .unwrap_or_else(default_payjoin_config); + PayjoinSettingsState { + warning: None, + config_updated: false, + payjoin_settings: PayjoinSettings::new(payjoin_config), + } + } +} + +impl State for PayjoinSettingsState { + fn update( + &mut self, + daemon: Arc, + _cache: &Cache, + message: Message, + ) -> Task { + match message { + Message::DaemonConfigLoaded(res) => match res { + Ok(()) => { + self.config_updated = true; + self.warning = None; + self.payjoin_settings.edited(true); + return Task::perform(async {}, |_| { + Message::View(view::Message::Settings( + view::SettingsMessage::EditPayjoinSettings, + )) + }); + } + Err(e) => { + self.config_updated = false; + self.warning = Some(e); + self.payjoin_settings.edited(false); + } + }, + Message::View(view::Message::Settings(view::SettingsMessage::PayjoinSettings(msg))) => { + return self.payjoin_settings.update(daemon, msg); + } + _ => {} + }; + Task::none() + } + + fn view<'a>(&'a self, cache: &'a Cache) -> Element<'a, view::Message> { + view::settings::payjoin_settings( + cache, + self.warning.as_ref(), + Some(self.payjoin_settings.view()), + ) + } +} + +impl From for Box { + fn from(s: PayjoinSettingsState) -> Box { + Box::new(s) + } +} + +#[derive(Debug)] +pub struct PayjoinSettings { + edit: bool, + processing: bool, + ohttp_relay: form::Value, + payjoin_directory: form::Value, +} + +impl PayjoinSettings { + pub fn new(config: PayjoinConfig) -> Self { + PayjoinSettings { + edit: false, + processing: false, + ohttp_relay: form::Value { + valid: true, + warning: None, + value: config.ohttp_relay, + }, + payjoin_directory: form::Value { + valid: true, + warning: None, + value: config.payjoin_directory, + }, + } + } +} + +impl PayjoinSettings { + fn edited(&mut self, success: bool) { + self.processing = false; + if success { + self.edit = false; + } + } + + fn update( + &mut self, + daemon: Arc, + message: view::SettingsEditMessage, + ) -> Task { + match message { + view::SettingsEditMessage::Select => { + if !self.processing { + self.edit = true; + } + } + view::SettingsEditMessage::Cancel => { + if !self.processing { + self.edit = false; + } + } + view::SettingsEditMessage::FieldEdited(field, value) => { + if !self.processing { + match field { + "ohttp_relay" => self.ohttp_relay.value = value, + "payjoin_directory" => self.payjoin_directory.value = value, + _ => {} + } + } + } + view::SettingsEditMessage::Confirm => { + let mut daemon_config = daemon.config().cloned().unwrap(); + daemon_config.payjoin_config = Some(PayjoinConfig::new( + self.ohttp_relay.value.clone(), + self.payjoin_directory.value.clone(), + )); + self.processing = true; + return Task::perform(async move { daemon_config }, |cfg| { + Message::LoadDaemonConfig(Box::new(cfg)) + }); + } + view::SettingsEditMessage::Clipboard(text) => return clipboard::write(text), + _ => {} + }; + Task::none() + } + + fn view<'a>(&self) -> Element<'a, view::SettingsEditMessage> { + if self.edit { + view::settings::payjoin_edit( + &self.ohttp_relay, + &self.payjoin_directory, + self.processing, + ) + } else { + view::settings::payjoin(&self.ohttp_relay.value, &self.payjoin_directory.value) + } + } +} diff --git a/liana-gui/src/app/state/spend/step.rs b/liana-gui/src/app/state/spend/step.rs index 2b04a101e..e13875f2d 100644 --- a/liana-gui/src/app/state/spend/step.rs +++ b/liana-gui/src/app/state/spend/step.rs @@ -1112,6 +1112,7 @@ impl SaveSpend { impl Step for SaveSpend { fn load(&mut self, _coins: &[Coin], _tip_height: i32, draft: &TransactionDraft) { let (psbt, warnings) = draft.generated.clone().unwrap(); + let mut tx = SpendTx::new( None, psbt, @@ -1119,6 +1120,7 @@ impl Step for SaveSpend { &self.wallet.main_descriptor, &self.curve, draft.network, + None, ); tx.labels.clone_from(&draft.labels); diff --git a/liana-gui/src/app/view/message.rs b/liana-gui/src/app/view/message.rs index f0526f8e4..38af15b90 100644 --- a/liana-gui/src/app/view/message.rs +++ b/liana-gui/src/app/view/message.rs @@ -22,6 +22,7 @@ pub enum Message { SelectPayment(OutPoint), Label(Vec, LabelMessage), NextReceiveAddress, + ReceivePayjoin, ToggleShowPreviousAddresses, SelectAddress(Address), Settings(SettingsMessage), @@ -32,12 +33,13 @@ pub enum Message { Previous, SelectHardwareWallet(usize), CreateRbf(CreateRbfMessage), - ShowQrCode(usize), + ShowQrCode(usize, Option), ImportExport(ImportExportMessage), HideRescanWarning, ExportPsbt, ImportPsbt, OpenUrl(String), + PayjoinInitiate, } impl Close for Message { @@ -66,6 +68,7 @@ pub enum CreateSpendMessage { Generate, SendMaxToRecipient(usize), Clear, + Bip21Edited(usize, String), } #[derive(Debug, Clone)] @@ -80,6 +83,7 @@ pub enum SpendTxMessage { Delete, Sign, Broadcast, + BroadcastPjFallback, Save, Confirm, Cancel, @@ -87,6 +91,8 @@ pub enum SpendTxMessage { EditPsbt, PsbtEdited(String), Next, + SendPayjoin, + PayjoinInitiated, } #[allow(clippy::large_enum_variant)] @@ -114,6 +120,8 @@ pub enum SettingsMessage { Save, GeneralSection, Fiat(FiatMessage), + EditPayjoinSettings, + PayjoinSettings(SettingsEditMessage), } #[derive(Debug, Clone)] diff --git a/liana-gui/src/app/view/psbt.rs b/liana-gui/src/app/view/psbt.rs index d952ab409..6762903f3 100644 --- a/liana-gui/src/app/view/psbt.rs +++ b/liana-gui/src/app/view/psbt.rs @@ -25,6 +25,7 @@ use liana_ui::{ icon, theme, widget::*, }; +use lianad::payjoin::types::PayjoinStatus; use crate::{ app::{ @@ -246,6 +247,41 @@ pub fn broadcast_action<'a>( } } +pub fn send_payjoin_action<'a>(warning: Option<&Error>, sent: bool) -> Element<'a, Message> { + if sent { + card::simple(text("Payjoin proposal sent")) + .width(Length::Fixed(400.0)) + .align_x(iced::alignment::Horizontal::Center) + .into() + } else { + card::simple( + Column::new() + .spacing(10) + .push_maybe(warning.map(|w| warn(Some(w)))) + .push(Container::new(h4_bold("Send payjoin proposal")).width(Length::Fill)) + .push(text( + "This will send the signed payjoin proposal to the sender. \ + The sender can then sign and broadcast the final transaction.", + )) + .push( + Row::new() + .spacing(10) + .push(Space::with_width(Length::Fill)) + .push( + button::secondary(None, "Cancel") + .on_press(Message::Spend(SpendTxMessage::Cancel)), + ) + .push( + button::primary(None, "Send Payjoin") + .on_press(Message::Spend(SpendTxMessage::Confirm)), + ), + ), + ) + .width(Length::Fixed(400.0)) + .into() + } +} + pub fn delete_action<'a>(warning: Option<&Error>, deleted: bool) -> Element<'a, Message> { if deleted { card::simple( @@ -333,6 +369,10 @@ pub fn spend_header<'a>( .into() } +pub fn payjoin_send_success_view<'a>() -> Element<'a, Message> { + card::simple(text("Payjoin sent successfully")).into() +} + pub fn spend_overview_view<'a>( tx: &'a SpendTx, desc_info: &'a LianaPolicy, @@ -412,38 +452,137 @@ pub fn spend_overview_view<'a>( .style(theme::card::simple), ) .push_maybe(if tx.status == SpendStatus::Pending { - Some( - Row::new() - .push(Space::with_width(Length::Fill)) - .push_maybe(if tx.path_ready().is_none() { - Some( - button::primary(None, "Sign") - .on_press(Message::Spend(SpendTxMessage::Sign)) - .width(Length::Fixed(150.0)), - ) - } else { - Some( - button::primary(None, "Broadcast") - .on_press(Message::Spend(SpendTxMessage::Broadcast)) - .width(Length::Fixed(150.0)), - ) - }) - .align_y(Alignment::Center) - .spacing(20), - ) + let is_payjoin = tx + .payjoin_status + .is_some_and(|s| !matches!(s, PayjoinStatus::Unknown)); + Some(if is_payjoin { + payjoin_action_buttons(tx) + } else { + spend_action_buttons(tx) + }) } else { None }) .into() } +fn spend_action_buttons(tx: &SpendTx) -> Element<'_, Message> { + Row::new() + .push(Space::with_width(Length::Fill)) + .push(if tx.path_ready().is_none() { + button::primary(None, "Sign") + .on_press(Message::Spend(SpendTxMessage::Sign)) + .width(Length::Fixed(150.0)) + } else { + button::primary(None, "Broadcast") + .on_press(Message::Spend(SpendTxMessage::Broadcast)) + .width(Length::Fixed(150.0)) + }) + .align_y(Alignment::Center) + .spacing(20) + .into() +} + +fn payjoin_action_buttons(tx: &SpendTx) -> Element<'_, Message> { + let session_open = tx.payjoin_status.is_some_and(|s| { + matches!( + s, + PayjoinStatus::Pending | PayjoinStatus::WaitingToSign | PayjoinStatus::ReadyToSend + ) + }); + let signed = tx.path_ready().is_some(); + let can_send = signed + && matches!( + tx.payjoin_status, + Some(PayjoinStatus::WaitingToSign) | Some(PayjoinStatus::ReadyToSend) + ); + + Row::new() + .push(Space::with_width(Length::Fill)) + .push_maybe(can_send.then(|| { + button::primary(None, "Send Payjoin") + .width(Length::Fixed(150.0)) + .on_press(Message::Spend(SpendTxMessage::SendPayjoin)) + })) + .push_maybe((!signed).then(|| { + button::primary(None, "Sign") + .on_press(Message::Spend(SpendTxMessage::Sign)) + .width(Length::Fixed(150.0)) + })) + .push_maybe(session_open.then(|| { + button::secondary(None, "Broadcast Fallback") + .on_press(Message::Spend(SpendTxMessage::BroadcastPjFallback)) + .width(Length::Fixed(150.0)) + })) + .align_y(Alignment::Center) + .spacing(20) + .into() +} + pub fn signatures<'a>( tx: &'a SpendTx, desc_info: &'a LianaPolicy, keys_aliases: &'a HashMap, ) -> Element<'a, Message> { + let expired_banner = (tx.payjoin_status == Some(PayjoinStatus::Expired)).then(|| { + Container::new( + scrollable( + Row::new() + .spacing(10) + .align_y(Alignment::Center) + .push(p1_bold("Status")) + .push(icon::warning_icon().style(theme::text::warning)) + .push( + text("Payjoin session expired — safe to broadcast fallback or delete") + .bold() + .style(theme::text::warning), + ), + ) + .direction(scrollable::Direction::Horizontal( + scrollable::Scrollbar::new().width(2).scroller_width(2), + )), + ) + .padding(15) + }); Column::new() - .push(if let Some(sigs) = tx.path_ready() { + .push_maybe(expired_banner) + .push(if tx.status == SpendStatus::PayjoinInitiated { + Container::new( + scrollable( + Row::new() + .spacing(5) + .align_y(Alignment::Center) + .spacing(10) + .push(p1_bold("Status")) + .push(icon::circle_check_icon().style(theme::text::payjoin)) + .push(text("Payjoin Initiated").bold().style(theme::text::payjoin)), + ) + .direction(scrollable::Direction::Horizontal( + scrollable::Scrollbar::new().width(2).scroller_width(2), + )), + ) + .padding(15) + } else if tx.status == SpendStatus::PayjoinProposalReady { + Container::new( + scrollable( + Row::new() + .spacing(5) + .align_y(Alignment::Center) + .spacing(10) + .push(p1_bold("Status")) + .push(icon::circle_check_icon().style(theme::text::payjoin)) + .push( + text("Payjoin Proposal Ready For Signing") + .bold() + .style(theme::text::payjoin), + ), + ) + .direction(scrollable::Direction::Horizontal( + scrollable::Scrollbar::new().width(2).scroller_width(2), + )), + ) + .padding(15) + } else if let Some(sigs) = tx.path_ready() { Container::new( scrollable( Row::new() diff --git a/liana-gui/src/app/view/receive.rs b/liana-gui/src/app/view/receive.rs index 2ad403394..e970509ad 100644 --- a/liana-gui/src/app/view/receive.rs +++ b/liana-gui/src/app/view/receive.rs @@ -4,7 +4,7 @@ use iced::{ alignment::Horizontal, widget::{ qr_code::{self, QRCode}, - scrollable, Button, Space, + scrollable, tooltip, Button, Space, }, Alignment, Length, }; @@ -17,7 +17,7 @@ use liana::miniscript::bitcoin::{ use liana_ui::{ component::{ - button, card, form, + badge, button, card, form, text::{self, *}, }, icon, theme, @@ -27,7 +27,6 @@ use liana_ui::{ use crate::{ app::{ error::Error, - menu::Menu, view::{hw, label, warning::warn}, }, hw::HardwareWallet, @@ -38,8 +37,10 @@ use super::message::Message; fn address_card<'a>( row_index: usize, address: &'a bitcoin::Address, + maybe_bip21: Option, labels: &'a HashMap, labels_editing: &'a HashMap>, + is_payjoin: bool, ) -> Container<'a, Message> { let addr = address.to_string(); card::simple( @@ -56,7 +57,16 @@ fn address_card<'a>( scrollable( Column::new() .push(Space::with_height(Length::Fixed(10.0))) - .push(p2_regular(address).small().style(theme::text::secondary)) + .push( + p2_regular( + maybe_bip21 + .clone() + .map(|bip21| bip21.to_string()) + .unwrap_or(addr.clone()), + ) + .small() + .style(theme::text::secondary), + ) // Space between the address and the scrollbar .push(Space::with_height(Length::Fixed(10.0))), ) @@ -68,9 +78,19 @@ fn address_card<'a>( ) .width(Length::Fill), ) + .push(if is_payjoin { + badge::payjoin() + } else { + Container::new(p2_regular("").small()) + }) .push( Button::new(icon::clipboard_icon().style(theme::text::secondary)) - .on_press(Message::Clipboard(addr)) + .on_press(Message::Clipboard( + maybe_bip21 + .clone() + .map(|bip21| bip21.to_string()) + .unwrap_or(addr.clone()), + )) .style(theme::button::transparent_border), ) .align_y(Alignment::Center), @@ -84,7 +104,7 @@ fn address_card<'a>( .push(Space::with_width(Length::Fill)) .push( button::secondary(None, "Show QR Code") - .on_press(Message::ShowQrCode(row_index)), + .on_press(Message::ShowQrCode(row_index, maybe_bip21)), ), ) .spacing(10), @@ -95,13 +115,18 @@ fn address_card<'a>( pub fn receive<'a>( addresses: &'a [bitcoin::Address], labels: &'a HashMap, + derivation_indexes: &'a [liana::miniscript::bitcoin::bip32::ChildNumber], + bip21_map: &'a HashMap, prev_addresses: &'a [bitcoin::Address], + prev_derivation_indexes: &'a [liana::miniscript::bitcoin::bip32::ChildNumber], prev_labels: &'a HashMap, show_prev_addresses: bool, selected: &'a HashSet, labels_editing: &'a HashMap>, + active_payjoin_sessions: &'a HashSet, is_last_page: bool, processing: bool, + has_coins: bool, ) -> Element<'a, Message> { // Number of start and end address characters to show in collapsed view. const NUM_ADDR_CHARS: usize = 16; @@ -110,16 +135,32 @@ pub fn receive<'a>( .push( Row::new() .align_y(Alignment::Center) - .push(Container::new(panel_title(Menu::Receive.title())).width(Length::Fill)) - .push({ - let (icon, label) = (Some(icon::plus_icon()), "Generate address"); - if addresses.is_empty() { - button::primary(icon, label) - } else { - button::secondary(icon, label) - } - .on_press(Message::NextReceiveAddress) - }), + .push(Container::new(h3("Receive")).width(Length::Fill)) + .push( + Row::new() + .spacing(10) + .push({ + let (icon, label) = (Some(icon::plus_icon()), "Generate address"); + if addresses.is_empty() { + button::primary(icon, label) + } else { + button::secondary(icon, label) + } + .on_press(Message::NextReceiveAddress) + }) + .push(if has_coins { + Element::::from( + button::secondary(Some(icon::plus_icon()), "Receive Payjoin") + .on_press(Message::ReceivePayjoin), + ) + } else { + Element::::from(Container::new(tooltip::Tooltip::new( + button::secondary(Some(icon::plus_icon()), "Receive Payjoin"), + "Account balance required to initiate payjoin", + tooltip::Position::Bottom, + ))) + }), + ), ) .push(text("Always generate a new address for each deposit.")) .push( @@ -130,7 +171,22 @@ pub fn receive<'a>( Column::new().spacing(10).width(Length::Fill), |col, (i, address)| { addresses_count += 1; - col.push(address_card(i, address, labels, labels_editing)) + // i is already the correct index since we're iterating forwards then reversing + let is_payjoin = derivation_indexes + .get(i) + .map(|idx| active_payjoin_sessions.contains(idx)) + .unwrap_or(false); + let maybe_bip21 = derivation_indexes + .get(i) + .and_then(|idx| bip21_map.get(idx).cloned()); + col.push(address_card( + i, + address, + maybe_bip21, + labels, + labels_editing, + is_payjoin, + )) }, )), ) @@ -163,35 +219,46 @@ pub fn receive<'a>( // prev addresses are already ordered in descending order Column::new().spacing(10).width(Length::Fill), |col, (i, address)| { + let is_payjoin = prev_derivation_indexes + .get(i) + .map(|idx| active_payjoin_sessions.contains(idx)) + .unwrap_or(false); col.push(if !selected.contains(address) { Button::new( Row::new() .spacing(10) - .push( - { - let addr = address.to_string(); - let addr_len = addr.chars().count(); - Container::new( - p2_regular(if addr_len > 2 * NUM_ADDR_CHARS { - format!( - "{}...{}", - addr.chars() - .take(NUM_ADDR_CHARS) - .collect::(), - addr.chars() - .skip(addr_len - NUM_ADDR_CHARS) - .collect::(), - ) - } else { - addr - }) - .small() - .style(theme::text::secondary), - ) - } - .padding(10) - .width(Length::Fixed(350.0)), - ) + .push({ + let addr = address.to_string(); + let addr_len = addr.chars().count(); + Container::new( + Row::new() + .spacing(5) + .push( + p2_regular(if addr_len > 2 * NUM_ADDR_CHARS { + format!( + "{}...{}", + addr.chars() + .take(NUM_ADDR_CHARS) + .collect::(), + addr.chars() + .skip(addr_len - NUM_ADDR_CHARS) + .collect::(), + ) + } else { + addr + }) + .small() + .style(theme::text::secondary), + ) + .padding(10) + .width(Length::Fixed(350.0)), + ) + }) + .push(if is_payjoin { + badge::payjoin() + } else { + Container::new(p2_regular("").small()) + }) .push( Container::new( scrollable( @@ -227,13 +294,19 @@ pub fn receive<'a>( .style(theme::button::clickable_card) } else { // Continue the row index from those of generated addresses above. + let is_payjoin = prev_derivation_indexes + .get(i) + .map(|idx| active_payjoin_sessions.contains(idx)) + .unwrap_or(false); let addr_str = address.to_string(); let is_editing = labels_editing.contains_key(&addr_str); let btn = Button::new(address_card( addresses_count + i, address, + None, prev_labels, labels_editing, + is_payjoin, )) .padding(0) // so that button & card borders match .style(theme::button::clickable_card); @@ -357,6 +430,7 @@ pub fn verify_address_modal<'a>( } pub fn qr_modal<'a>(qr: &'a qr_code::Data, address: &'a String) -> Element<'a, Message> { + let max_width = if address.len() > 64 { 600 } else { 400 }; Column::new() .push( Row::new() @@ -370,6 +444,6 @@ pub fn qr_modal<'a>(qr: &'a qr_code::Data, address: &'a String) -> Element<'a, M .push(Space::with_height(Length::Fixed(15.0))) .push(Container::new(text(address).size(15)).center_x(Length::Fill)) .width(Length::Fill) - .max_width(400) + .max_width(max_width) .into() } diff --git a/liana-gui/src/app/view/settings/mod.rs b/liana-gui/src/app/view/settings/mod.rs index f6fddb06d..5fa7b1bfd 100644 --- a/liana-gui/src/app/view/settings/mod.rs +++ b/liana-gui/src/app/view/settings/mod.rs @@ -146,6 +146,13 @@ pub fn list(cache: &Cache, is_remote_backend: bool) -> Element<'_, Message> { Message::Settings(SettingsMessage::EditWalletSettings), ); + let payjoin = settings_section( + "Payjoin", + None, + icon::bitcoin_icon(), + Message::Settings(SettingsMessage::EditPayjoinSettings), + ); + let import_export = settings_section( "Import/Export", None, @@ -171,6 +178,7 @@ pub fn list(cache: &Cache, is_remote_backend: bool) -> Element<'_, Message> { .push(general) .push(if !is_remote_backend { node } else { backend }) .push(wallet) + .push(payjoin) .push(import_export) .push(about), ) @@ -206,6 +214,160 @@ pub fn bitcoind_settings<'a>( ) } +pub fn payjoin_settings<'a>( + cache: &'a Cache, + warning: Option<&'a Error>, + settings: Option>, +) -> Element<'a, Message> { + let header = header("Payjoin", SettingsMessage::EditPayjoinSettings); + + dashboard( + &Menu::Settings, + cache, + warning, + Column::new().spacing(20).push(header).push_maybe( + settings.map(|s| s.map(|msg| Message::Settings(SettingsMessage::PayjoinSettings(msg)))), + ), + ) +} + +pub fn payjoin_edit<'a>( + ohttp_relay: &form::Value, + payjoin_directory: &form::Value, + processing: bool, +) -> Element<'a, SettingsEditMessage> { + let mut col = Column::new().spacing(20); + + col = col.push( + Column::new() + .push(text("OHTTP Relay:").bold().small()) + .push( + form::Form::new_trimmed("https://pj.example.com", ohttp_relay, |value| { + SettingsEditMessage::FieldEdited("ohttp_relay", value) + }) + .warning("Please enter a valid URL") + .size(P1_SIZE) + .padding(5), + ) + .spacing(5), + ); + + col = col.push( + Column::new() + .push(text("Payjoin Directory:").bold().small()) + .push( + form::Form::new_trimmed("https://payjo.in", payjoin_directory, |value| { + SettingsEditMessage::FieldEdited("payjoin_directory", value) + }) + .warning("Please enter a valid URL") + .size(P1_SIZE) + .padding(5), + ) + .spacing(5), + ); + + let mut cancel_button = button::transparent(None, " Cancel ").padding(5); + let mut confirm_button = button::secondary(None, " Save ").padding(5); + if !processing { + cancel_button = cancel_button.on_press(SettingsEditMessage::Cancel); + confirm_button = confirm_button.on_press(SettingsEditMessage::Confirm); + } + + card::simple(Container::new( + Column::new() + .push( + Row::new() + .push(badge::badge(icon::bitcoin_icon())) + .push(text("Payjoin").bold()) + .padding(10) + .spacing(20) + .align_y(Alignment::Center) + .width(Length::Fill), + ) + .push(separation().width(Length::Fill)) + .push(col) + .push( + Container::new( + Row::new() + .push(cancel_button) + .push(confirm_button) + .spacing(10) + .align_y(Alignment::Center), + ) + .width(Length::Fill) + .align_x(alignment::Horizontal::Right), + ) + .spacing(20), + )) + .width(Length::Fill) + .into() +} + +pub fn payjoin<'a>(ohttp_relay: &str, payjoin_directory: &str) -> Element<'a, SettingsEditMessage> { + let rows = vec![ + ("OHTTP Relay:", ohttp_relay.to_string()), + ("Payjoin Directory:", payjoin_directory.to_string()), + ]; + + let mut col_fields = Column::new(); + for (k, v) in rows { + let v_clone = v.clone(); + col_fields = col_fields.push( + Row::new() + .push(Container::new(text(k).bold().small()).width(Length::FillPortion(1))) + .push( + Container::new( + scrollable( + Column::new() + .push(Space::with_height(Length::Fixed(10.0))) + .push(text(v).small()) + .push(Space::with_height(Length::Fixed(10.0))), + ) + .direction(scrollable::Direction::Horizontal( + scrollable::Scrollbar::new().width(2).scroller_width(2), + )), + ) + .align_x(alignment::Horizontal::Right) + .padding(10) + .width(Length::FillPortion(3)), + ) + .push(Space::with_width(10)) + .push( + Button::new(icon::clipboard_icon()) + .style(theme::button::transparent_border) + .on_press(SettingsEditMessage::Clipboard(v_clone)), + ) + .align_y(Alignment::Center), + ); + } + + card::simple(Container::new( + Column::new() + .push( + Row::new() + .push( + Row::new() + .push(badge::badge(icon::bitcoin_icon())) + .push(text("Payjoin").bold()) + .spacing(20) + .align_y(Alignment::Center) + .width(Length::Fill), + ) + .push( + Button::new(icon::pencil_icon()) + .style(theme::button::transparent_border) + .on_press(SettingsEditMessage::Select), + ) + .align_y(Alignment::Center), + ) + .push(separation().width(Length::Fill)) + .push(col_fields) + .spacing(20), + )) + .width(Length::Fill) + .into() +} + pub fn import_export<'a>(cache: &'a Cache, warning: Option<&'a Error>) -> Element<'a, Message> { let header = header("Import/Export", SettingsMessage::ImportExportSection); diff --git a/liana-gui/src/app/view/transactions.rs b/liana-gui/src/app/view/transactions.rs index a43129a2b..c89edebee 100644 --- a/liana-gui/src/app/view/transactions.rs +++ b/liana-gui/src/app/view/transactions.rs @@ -142,6 +142,7 @@ fn tx_list_view(i: usize, tx: &HistoryTransaction) -> Element<'_, Message> { } else { None }) + .push_maybe(tx.payjoin_role.map(|_| badge::payjoin())) .push(if tx.is_external() { Row::new() .spacing(5) @@ -423,8 +424,9 @@ pub fn tx_view<'a>( .push( Column::new() .spacing(20) - // We do not need to display inputs for external incoming transactions - .push_maybe(if tx.is_external() { + // We do not need to display inputs for external incoming transactions, + // but for incoming payjoins we contributed inputs that are worth showing. + .push_maybe(if tx.is_external() && tx.coins.is_empty() { None } else { Some(super::psbt::inputs_view( diff --git a/liana-gui/src/daemon/client/mod.rs b/liana-gui/src/daemon/client/mod.rs index 6341477d9..41e74609b 100644 --- a/liana-gui/src/daemon/client/mod.rs +++ b/liana-gui/src/daemon/client/mod.rs @@ -5,6 +5,7 @@ use std::iter::FromIterator; use async_trait::async_trait; use lianad::bip329::Labels; use lianad::commands::{GetLabelsBip329Result, UpdateDerivIndexesResult}; +use lianad::payjoin::types::PayjoinStatus; use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; use serde_json::json; @@ -107,6 +108,30 @@ impl Daemon for Lianad { ) } + async fn receive_payjoin(&self) -> Result { + self.call("receivepayjoin", Option::::None) + } + + async fn get_payjoin_info(&self, txid: &Txid) -> Result { + self.call("getpayjoininfo", Some(vec![txid.to_string()])) + } + + async fn get_active_payjoin_receiver_sessions(&self) -> Result, DaemonError> { + self.call("getactivepayjoinreceiversessions", Option::::None) + } + + async fn send_payjoin_proposal(&self, txid: &Txid) -> Result<(), DaemonError> { + let _res: serde_json::value::Value = + self.call("sendpayjoinproposal", Some(vec![txid.to_string()]))?; + Ok(()) + } + + async fn broadcast_payjoin_fallback(&self, txid: &Txid) -> Result<(), DaemonError> { + let _res: serde_json::value::Value = + self.call("broadcastpayjoinfallback", Some(vec![txid.to_string()]))?; + Ok(()) + } + async fn update_deriv_indexes( &self, receive: Option, diff --git a/liana-gui/src/daemon/embedded.rs b/liana-gui/src/daemon/embedded.rs index 5b867bfdf..3d1ff774b 100644 --- a/liana-gui/src/daemon/embedded.rs +++ b/liana-gui/src/daemon/embedded.rs @@ -1,5 +1,5 @@ -use lianad::bip329::Labels; use lianad::commands::UpdateDerivIndexesResult; +use lianad::{bip329::Labels, payjoin::types::PayjoinStatus}; use std::collections::{HashMap, HashSet}; use tokio::sync::Mutex; @@ -120,6 +120,65 @@ impl Daemon for EmbeddedDaemon { .await } + async fn receive_payjoin(&self) -> Result { + self.command(|daemon| { + daemon + .receive_payjoin() + .map_err(|e| DaemonError::Unexpected(e.to_string())) + }) + .await + } + + async fn get_payjoin_info(&self, txid: &Txid) -> Result { + self.command(|daemon| { + daemon + .get_payjoin_info(txid) + .map_err(|e| DaemonError::Unexpected(e.to_string())) + }) + .await + } + + async fn get_active_payjoin_receiver_sessions(&self) -> Result, DaemonError> { + self.command(|daemon| { + daemon + .get_active_payjoin_receiver_sessions() + .map_err(|e| DaemonError::Unexpected(e.to_string())) + }) + .await + } + + async fn send_payjoin_proposal(&self, txid: &Txid) -> Result<(), DaemonError> { + let control = match self.handle.lock().await.as_ref() { + Some(DaemonHandle::Controller { control, .. }) => control.clone(), + Some(_) => unreachable!("No lianad rpc server must be started"), + None => return Err(DaemonError::DaemonStopped), + }; + let txid = *txid; + tokio::task::spawn_blocking(move || { + control + .send_payjoin_proposal(&txid) + .map_err(|e| DaemonError::Unexpected(e.to_string())) + }) + .await + .map_err(|e| DaemonError::Unexpected(e.to_string()))? + } + + async fn broadcast_payjoin_fallback(&self, txid: &Txid) -> Result<(), DaemonError> { + let control = match self.handle.lock().await.as_ref() { + Some(DaemonHandle::Controller { control, .. }) => control.clone(), + Some(_) => unreachable!("No lianad rpc server must be started"), + None => return Err(DaemonError::DaemonStopped), + }; + let txid = *txid; + tokio::task::spawn_blocking(move || { + control + .broadcast_payjoin_fallback(&txid) + .map_err(|e| DaemonError::Unexpected(e.to_string())) + }) + .await + .map_err(|e| DaemonError::Unexpected(e.to_string()))? + } + async fn update_deriv_indexes( &self, receive: Option, diff --git a/liana-gui/src/daemon/mod.rs b/liana-gui/src/daemon/mod.rs index 0567f3458..cb69e418b 100644 --- a/liana-gui/src/daemon/mod.rs +++ b/liana-gui/src/daemon/mod.rs @@ -18,6 +18,7 @@ use liana::miniscript::bitcoin::{ }; use lianad::bip329::Labels; use lianad::commands::UpdateDerivIndexesResult; +use lianad::payjoin::types::PayjoinStatus; use lianad::{ commands::{CoinStatus, LabelItem, TransactionInfo}, config::Config, @@ -118,6 +119,11 @@ pub trait Daemon: Debug { limit: usize, start_index: Option, ) -> Result; + async fn receive_payjoin(&self) -> Result; + async fn get_payjoin_info(&self, txid: &Txid) -> Result; + async fn get_active_payjoin_receiver_sessions(&self) -> Result, DaemonError>; + async fn send_payjoin_proposal(&self, txid: &Txid) -> Result<(), DaemonError>; + async fn broadcast_payjoin_fallback(&self, txid: &Txid) -> Result<(), DaemonError>; async fn update_deriv_indexes( &self, receive: Option, @@ -226,6 +232,10 @@ pub trait Daemon: Debug { .cloned() .collect(); + let payjoin_status = self + .get_payjoin_info(&tx.psbt.unsigned_tx.compute_txid()) + .await?; + spend_txs.push(model::SpendTx::new( tx.updated_at, tx.psbt, @@ -233,6 +243,7 @@ pub trait Daemon: Debug { &info.descriptors.main, &curve, info.network, + Some(payjoin_status), )); } load_labels(self, &mut spend_txs).await?; @@ -296,6 +307,7 @@ pub trait Daemon: Debug { tx_coins, change_indexes, info.network, + tx.payjoin_role, ) }) .collect(); @@ -374,6 +386,7 @@ pub trait Daemon: Debug { tx_coins, change_indexes, info.network, + tx.payjoin_role, ) }) .collect(); diff --git a/liana-gui/src/daemon/model.rs b/liana-gui/src/daemon/model.rs index 2ee069136..d7f32f023 100644 --- a/liana-gui/src/daemon/model.rs +++ b/liana-gui/src/daemon/model.rs @@ -1,7 +1,7 @@ use std::cmp::Ordering; use std::collections::{HashMap, HashSet}; -use liana::descriptors::LianaDescriptor; +use liana::descriptors::{LianaDescError, LianaDescriptor}; pub use liana::{ descriptors::{LianaPolicy, PartialSpendInfo, PathSpendInfo}, miniscript::bitcoin::{ @@ -15,6 +15,7 @@ pub use lianad::commands::{ ListCoinsResult, ListRevealedAddressesEntry, ListRevealedAddressesResult, ListSpendEntry, ListSpendResult, ListTransactionsResult, TransactionInfo, }; +use lianad::payjoin::types::{PayjoinRole, PayjoinStatus}; pub type Coin = ListCoinsEntry; @@ -49,6 +50,7 @@ pub struct SpendTx { pub sigs: PartialSpendInfo, pub updated_at: Option, pub kind: TransactionKind, + pub payjoin_status: Option, } #[derive(PartialOrd, Ord, Debug, Clone, PartialEq, Eq)] @@ -57,6 +59,40 @@ pub enum SpendStatus { Broadcast, Spent, Deprecated, + PayjoinInitiated, + PayjoinProposalReady, +} + +/// Build a filtered PSBT for use with `partial_spend_info`, stripping any non-Liana inputs +/// added by a payjoin receiver. Returns the original PSBT unchanged for non-payjoin cases. +fn payjoin_filtered_spend_info( + desc: &LianaDescriptor, + psbt: &Psbt, + coins: &HashMap, + payjoin_status: &Option, +) -> Result { + if coins.len() != psbt.inputs.len() && payjoin_status.is_some() { + let liana_indices: Vec = psbt + .unsigned_tx + .input + .iter() + .enumerate() + .filter(|(_, txin)| coins.contains_key(&txin.previous_output)) + .map(|(i, _)| i) + .collect(); + let mut filtered = psbt.clone(); + filtered.inputs = liana_indices + .iter() + .map(|&i| psbt.inputs[i].clone()) + .collect(); + filtered.unsigned_tx.input = liana_indices + .iter() + .map(|&i| psbt.unsigned_tx.input[i].clone()) + .collect(); + desc.partial_spend_info(&filtered) + } else { + desc.partial_spend_info(psbt) + } } impl SpendTx { @@ -67,6 +103,7 @@ impl SpendTx { desc: &LianaDescriptor, secp: &secp256k1::Secp256k1, network: Network, + payjoin_status: Option, ) -> Self { // Use primary path if no inputs are using a relative locktime. let use_primary_path = !psbt @@ -141,12 +178,11 @@ impl SpendTx { }; // One input coin is missing, the psbt is deprecated for now. - if coins_map.len() != psbt.inputs.len() { + if coins_map.len() != psbt.inputs.len() && payjoin_status.is_none() { status = SpendStatus::Deprecated } - let sigs = desc - .partial_spend_info(&psbt) + let sigs = payjoin_filtered_spend_info(desc, &psbt, &coins_map, &payjoin_status) .expect("PSBT must be generated by Liana"); Self { @@ -186,9 +222,19 @@ impl SpendTx { status, sigs, network, + payjoin_status, } } + /// Compute partial spend info for this transaction's PSBT, filtering out any + /// non-Liana inputs added by a payjoin receiver before calling the descriptor. + pub fn partial_spend_info( + &self, + desc: &LianaDescriptor, + ) -> Result { + payjoin_filtered_spend_info(desc, &self.psbt, &self.coins, &self.payjoin_status) + } + /// Returns the path ready if it exists. pub fn path_ready(&self) -> Option<&PathSpendInfo> { let path = self.sigs.primary_path(); @@ -290,6 +336,7 @@ pub struct HistoryTransaction { pub height: Option, pub time: Option, pub kind: TransactionKind, + pub payjoin_role: Option, } impl HistoryTransaction { @@ -300,8 +347,9 @@ impl HistoryTransaction { coins: Vec, change_indexes: Vec, network: Network, + payjoin_role: Option, ) -> Self { - let (incoming_amount, outgoing_amount) = tx.output.iter().enumerate().fold( + let (raw_incoming, raw_outgoing) = tx.output.iter().enumerate().fold( (Amount::from_sat(0), Amount::from_sat(0)), |(change, spend), (i, output)| { if change_indexes.contains(&i) { @@ -312,48 +360,6 @@ impl HistoryTransaction { }, ); - let kind = if coins.is_empty() { - if change_indexes.len() == 1 { - TransactionKind::IncomingSinglePayment(OutPoint { - txid: tx.compute_txid(), - vout: change_indexes[0] as u32, - }) - } else { - TransactionKind::IncomingPaymentBatch( - change_indexes - .iter() - .map(|i| OutPoint { - txid: tx.compute_txid(), - vout: *i as u32, - }) - .collect(), - ) - } - } else if outgoing_amount == Amount::from_sat(0) { - TransactionKind::SendToSelf - } else { - let outpoints: Vec = tx - .output - .iter() - .enumerate() - .filter_map(|(i, _)| { - if !change_indexes.contains(&i) { - Some(OutPoint { - txid: tx.compute_txid(), - vout: i as u32, - }) - } else { - None - } - }) - .collect(); - if outpoints.len() == 1 { - TransactionKind::OutgoingSinglePayment(outpoints[0]) - } else { - TransactionKind::OutgoingPaymentBatch(outpoints) - } - }; - let mut inputs_amount = Amount::from_sat(0); let mut coins_map = HashMap::::with_capacity(coins.len()); for coin in coins { @@ -361,19 +367,94 @@ impl HistoryTransaction { coins_map.insert(coin.outpoint, coin); } + let txid = tx.compute_txid(); + let (kind, incoming_amount, outgoing_amount, fee_amount) = match payjoin_role { + Some(PayjoinRole::Receiver) => { + // Wallet contributed inputs, but the net effect is an inbound deposit. + // All wallet outputs are receive outputs; the displayed amount is the + // net (wallet outputs minus wallet inputs we contributed). + let kind = if change_indexes.len() == 1 { + TransactionKind::IncomingSinglePayment(OutPoint { + txid, + vout: change_indexes[0] as u32, + }) + } else { + TransactionKind::IncomingPaymentBatch( + change_indexes + .iter() + .map(|i| OutPoint { + txid, + vout: *i as u32, + }) + .collect(), + ) + }; + let net = raw_incoming + .checked_sub(inputs_amount) + .unwrap_or(Amount::from_sat(0)); + (kind, net, Amount::from_sat(0), None) + } + _ => { + let kind = if coins_map.is_empty() { + if change_indexes.len() == 1 { + TransactionKind::IncomingSinglePayment(OutPoint { + txid, + vout: change_indexes[0] as u32, + }) + } else { + TransactionKind::IncomingPaymentBatch( + change_indexes + .iter() + .map(|i| OutPoint { + txid, + vout: *i as u32, + }) + .collect(), + ) + } + } else if raw_outgoing == Amount::from_sat(0) { + TransactionKind::SendToSelf + } else { + let outpoints: Vec = tx + .output + .iter() + .enumerate() + .filter_map(|(i, _)| { + if !change_indexes.contains(&i) { + Some(OutPoint { + txid, + vout: i as u32, + }) + } else { + None + } + }) + .collect(); + if outpoints.len() == 1 { + TransactionKind::OutgoingSinglePayment(outpoints[0]) + } else { + TransactionKind::OutgoingPaymentBatch(outpoints) + } + }; + let fee = inputs_amount.checked_sub(raw_outgoing + raw_incoming); + (kind, raw_incoming, raw_outgoing, fee) + } + }; + Self { labels: HashMap::new(), kind, - txid: tx.compute_txid(), + txid, tx, coins: coins_map, change_indexes, outgoing_amount, incoming_amount, - fee_amount: inputs_amount.checked_sub(outgoing_amount + incoming_amount), + fee_amount, height, time, network, + payjoin_role, } } @@ -479,6 +560,35 @@ pub fn payments_from_tx(history_tx: HistoryTransaction) -> Vec { let time = history_tx .time .map(|t| chrono::DateTime::::from_timestamp(t as i64, 0).unwrap()); + // For payjoin receivers we contributed inputs that round-trip through the + // receive output, so the gross output value overstates the deposit. Emit a + // single Payment with the net amount instead. + if matches!(history_tx.payjoin_role, Some(PayjoinRole::Receiver)) { + if let Some(&output_index) = history_tx.change_indexes.first() { + let output = &history_tx.tx.output[output_index]; + let outpoint = OutPoint { + txid: history_tx.tx.compute_txid(), + vout: output_index as u32, + }; + let label = history_tx.labels.get(&outpoint.to_string()).cloned(); + let address = Address::from_script(&output.script_pubkey, history_tx.network) + .ok() + .map(|addr| addr.to_string()); + let address_label = address + .as_ref() + .and_then(|addr| history_tx.labels.get(addr).cloned()); + return vec![Payment { + label, + address, + address_label, + outpoint, + time, + amount: history_tx.incoming_amount, + kind: PaymentKind::Incoming, + }]; + } + return Vec::new(); + } history_tx .tx .output diff --git a/liana-gui/src/installer/step/node/bitcoind.rs b/liana-gui/src/installer/step/node/bitcoind.rs index aa171e561..adda06073 100644 --- a/liana-gui/src/installer/step/node/bitcoind.rs +++ b/liana-gui/src/installer/step/node/bitcoind.rs @@ -248,7 +248,6 @@ fn bitcoind_default_address(network: &Network) -> String { Network::Testnet4 => "127.0.0.1:48332".to_string(), Network::Regtest => "127.0.0.1:18443".to_string(), Network::Signet => "127.0.0.1:38332".to_string(), - _ => "127.0.0.1:8332".to_string(), } } diff --git a/liana-gui/src/installer/step/wallet_alias.rs b/liana-gui/src/installer/step/wallet_alias.rs index 8ce93c1ed..4ce1c6f37 100644 --- a/liana-gui/src/installer/step/wallet_alias.rs +++ b/liana-gui/src/installer/step/wallet_alias.rs @@ -35,7 +35,6 @@ impl Step for WalletAlias { Network::Testnet => "Testnet", Network::Testnet4 => "Testnet4", Network::Regtest => "Regtest", - _ => "", } ); self.wallet_alias.valid = true; diff --git a/liana-gui/src/launcher.rs b/liana-gui/src/launcher.rs index 03fe6c42c..5ae6616ee 100644 --- a/liana-gui/src/launcher.rs +++ b/liana-gui/src/launcher.rs @@ -383,7 +383,6 @@ fn wallets_list_item( Network::Testnet => "Testnet", Network::Testnet4 => "Testnet4", Network::Regtest => "Regtest", - _ => "", } )) }) diff --git a/liana-gui/src/node/bitcoind.rs b/liana-gui/src/node/bitcoind.rs index 8b2e7f12e..11c45221e 100644 --- a/liana-gui/src/node/bitcoind.rs +++ b/liana-gui/src/node/bitcoind.rs @@ -138,7 +138,6 @@ pub fn bitcoind_network_dir(network: &Network) -> Option { Network::Testnet4 => "testnet4", Network::Regtest => "regtest", Network::Signet => "signet", - _ => panic!("Directory required for this network is unknown."), }; Some(dir.to_string()) } diff --git a/liana-gui/src/services/connect/client/backend/mod.rs b/liana-gui/src/services/connect/client/backend/mod.rs index 6b8c5c24c..cee4150a2 100644 --- a/liana-gui/src/services/connect/client/backend/mod.rs +++ b/liana-gui/src/services/connect/client/backend/mod.rs @@ -18,6 +18,7 @@ use lianad::{ bip329::Labels, commands::{CoinStatus, GetInfoDescriptors, LCSpendInfo, LabelItem, UpdateDerivIndexesResult}, config::Config, + payjoin::types::PayjoinStatus, }; use reqwest::{Error, IntoUrl, Method, RequestBuilder}; use tokio::sync::RwLock; @@ -625,6 +626,7 @@ impl Daemon for BackendWalletClient { Ok(GetAddressResult { address: res.address, derivation_index: res.derivation_index, + bip21: None, }) } @@ -666,6 +668,26 @@ impl Daemon for BackendWalletClient { }) } + async fn receive_payjoin(&self) -> Result { + unimplemented!() + } + + async fn get_payjoin_info(&self, _txid: &Txid) -> Result { + unimplemented!() + } + + async fn get_active_payjoin_receiver_sessions(&self) -> Result, DaemonError> { + unimplemented!() + } + + async fn send_payjoin_proposal(&self, _txid: &Txid) -> Result<(), DaemonError> { + unimplemented!() + } + + async fn broadcast_payjoin_fallback(&self, _txid: &Txid) -> Result<(), DaemonError> { + unimplemented!() + } + async fn update_deriv_indexes( &self, _receive: Option, @@ -739,6 +761,7 @@ impl Daemon for BackendWalletClient { tx: tx.raw, height: tx.block_height, time: tx.confirmed_at.map(|t| t as u32), + payjoin_role: None, }) .collect(), }) @@ -758,6 +781,7 @@ impl Daemon for BackendWalletClient { tx: tx.raw, height: tx.block_height, time: tx.confirmed_at.map(|t| t as u32), + payjoin_role: None, }) .collect(), }) @@ -1190,6 +1214,7 @@ fn history_tx_from_api(value: api::Transaction, network: Network) -> HistoryTran coins, changes_indexes, network, + None, ); tx.load_labels(&labels); tx @@ -1246,6 +1271,7 @@ fn spend_tx_from_api( desc, secp, network, + None, ); tx.load_labels(&labels); tx diff --git a/liana-gui/src/utils/mod.rs b/liana-gui/src/utils/mod.rs index d20ec9295..6d44db968 100644 --- a/liana-gui/src/utils/mod.rs +++ b/liana-gui/src/utils/mod.rs @@ -4,6 +4,7 @@ use std::{ }; use liana::miniscript::bitcoin::{self, bip32::DerivationPath, Network}; +use lianad::config::PayjoinConfig; pub mod serde; pub mod subscription; @@ -42,3 +43,10 @@ pub fn default_derivation_path(network: Network) -> DerivationPath { }) .unwrap() } + +pub fn default_payjoin_config() -> PayjoinConfig { + PayjoinConfig { + ohttp_relay: "https://bobspacebkk.com".to_string(), + payjoin_directory: "https://payjo.in".to_string(), + } +} diff --git a/liana-ui/src/color.rs b/liana-ui/src/color.rs index 6c311bd8b..912b28689 100644 --- a/liana-ui/src/color.rs +++ b/liana-ui/src/color.rs @@ -33,6 +33,7 @@ color!(TRANSPARENT_GREEN, 0x00FF66, 0.3); color!(RED, 0xE24E1B); color!(ORANGE, 0xFFA700); color!(BLUE, 0x7DD3FC); +color!(PAYJOIN_PINK, 0xC71585); // BUSINESS color!(BUSINESS_BLUE, 0x00BDFF); diff --git a/liana-ui/src/component/badge.rs b/liana-ui/src/component/badge.rs index 14668f286..987d1756d 100644 --- a/liana-ui/src/component/badge.rs +++ b/liana-ui/src/component/badge.rs @@ -71,6 +71,10 @@ pub fn spent<'a, T: 'a>() -> Container<'a, T> { ) } +pub fn payjoin<'a, T: 'a>() -> Container<'a, T> { + badge_pill(" Payjoin ", "This is a Payjoin address") +} + pub fn badge_pill<'a, T: 'a>(label: &'a str, tooltip: &'a str) -> Container<'a, T> { Container::new({ tooltip::Tooltip::new( diff --git a/liana-ui/src/theme/palette/liana.rs b/liana-ui/src/theme/palette/liana.rs index 10784cb97..86cea9169 100644 --- a/liana-ui/src/theme/palette/liana.rs +++ b/liana-ui/src/theme/palette/liana.rs @@ -17,6 +17,7 @@ impl Palette { success: color::GREEN, error: color::RED, accent: color::BLUE, + payjoin: color::PAYJOIN_PINK, }, buttons: Buttons { border_width: 1.0, diff --git a/liana-ui/src/theme/palette/liana_business.rs b/liana-ui/src/theme/palette/liana_business.rs index d62a5e2e4..46777865e 100644 --- a/liana-ui/src/theme/palette/liana_business.rs +++ b/liana-ui/src/theme/palette/liana_business.rs @@ -62,6 +62,7 @@ impl Palette { success: color::DARK_GREEN, error: color::RED, accent: color::BUSINESS_BLUE_DARK, + payjoin: color::PAYJOIN_PINK, }, buttons: Buttons { border_width: 3.0, diff --git a/liana-ui/src/theme/palette/mod.rs b/liana-ui/src/theme/palette/mod.rs index 5d62c786e..c83ebc55f 100644 --- a/liana-ui/src/theme/palette/mod.rs +++ b/liana-ui/src/theme/palette/mod.rs @@ -33,6 +33,7 @@ pub struct Text { pub success: iced::Color, pub error: iced::Color, pub accent: iced::Color, + pub payjoin: iced::Color, } #[derive(Debug, Copy, Clone, PartialEq)] diff --git a/liana-ui/src/theme/text.rs b/liana-ui/src/theme/text.rs index c21572796..892b4cb35 100644 --- a/liana-ui/src/theme/text.rs +++ b/liana-ui/src/theme/text.rs @@ -63,3 +63,9 @@ pub fn accent(theme: &Theme) -> Style { pub fn custom(color: iced::Color) -> Style { Style { color: Some(color) } } + +pub fn payjoin(theme: &Theme) -> Style { + Style { + color: Some(theme.colors.text.payjoin), + } +} diff --git a/liana/src/signer.rs b/liana/src/signer.rs index 14e9d62f8..3bf262b1c 100644 --- a/liana/src/signer.rs +++ b/liana/src/signer.rs @@ -307,7 +307,9 @@ impl HotSigner { if keypair.x_only_public_key().0 != *int_key { return Err(SignerError::InsanePsbt); } - let keypair = keypair.tap_tweak(secp, psbt_in.tap_merkle_root).to_inner(); + let keypair = keypair + .tap_tweak(secp, psbt_in.tap_merkle_root) + .to_keypair(); let sighash = sighash_cache .taproot_key_spend_signature_hash(input_index, &prevouts, sighash_type) .map_err(|_| SignerError::InsanePsbt)?; diff --git a/liana/src/spend.rs b/liana/src/spend.rs index 1416e0c91..05c9926c0 100644 --- a/liana/src/spend.rs +++ b/liana/src/spend.rs @@ -57,6 +57,7 @@ pub enum InsaneFeeInfo { pub enum SpendCreationError { InvalidFeerate(/* sats/vb */ u64), InvalidOutputValue(bitcoin::Amount), + InvalidBip21, InsaneFees(InsaneFeeInfo), SanityCheckFailure(Psbt), FetchingTransaction(bitcoin::OutPoint), @@ -89,6 +90,7 @@ impl fmt::Display for SpendCreationError { f, "BUG! Please report this. Failed sanity checks for PSBT '{psbt}'.", ), + Self::InvalidBip21 => write!(f, "Invalid BIP21"), } } } diff --git a/lianad/Cargo.toml b/lianad/Cargo.toml index 59e47e885..a163b4928 100644 --- a/lianad/Cargo.toml +++ b/lianad/Cargo.toml @@ -52,3 +52,7 @@ jsonrpc = { workspace = true, features = ["minreq_http"], default-features = fal # import/export labels bip329 = { workspace = true, default-features = false } + +# payjoin +payjoin = { git = "https://github.com/payjoin/rust-payjoin.git", rev = "7f8ec333a79aa4c0a6d5be437dd858452d45bd64", features = ["v2", "io"] } +reqwest = { version = "0.11", default-features=false, features = ["json", "rustls-tls", "stream", "blocking"] } diff --git a/lianad/src/bitcoin/d/mod.rs b/lianad/src/bitcoin/d/mod.rs index 7266d80c4..c7002bb1e 100644 --- a/lianad/src/bitcoin/d/mod.rs +++ b/lianad/src/bitcoin/d/mod.rs @@ -718,7 +718,6 @@ impl BitcoinD { bitcoin::Network::Testnet4 => "testnet4", bitcoin::Network::Regtest => "regtest", bitcoin::Network::Signet => "signet", - _ => "Unknown network, undefined at the time of writing", }; if bitcoind_net != bip70_net { return Err(BitcoindError::NetworkMismatch( @@ -1228,6 +1227,21 @@ impl BitcoinD { .collect() } + /// Test whether raw transactions would be accepted by the mempool. + pub fn test_mempool_accept(&self, rawtxs: Vec) -> Vec { + let hex_txs: Json = rawtxs.into_iter().map(|tx| serde_json::json!(tx)).collect(); + self.make_node_request("testmempoolaccept", params!(hex_txs)) + .as_array() + .expect("Always returns an array") + .iter() + .map(|e| { + e.get("allowed") + .and_then(|v| v.as_bool()) + .expect("Each result must have an 'allowed' boolean") + }) + .collect() + } + /// Stop bitcoind. pub fn stop(&self) { self.make_node_request("stop", None); diff --git a/lianad/src/bitcoin/mod.rs b/lianad/src/bitcoin/mod.rs index 26e755df8..742ff51f6 100644 --- a/lianad/src/bitcoin/mod.rs +++ b/lianad/src/bitcoin/mod.rs @@ -133,6 +133,11 @@ pub trait BitcoinInterface: Send { /// /// Returns `None` if the transaction is not in the mempool. fn mempool_entry(&self, txid: &bitcoin::Txid) -> Option; + + /// Test if given raw txs will be accepted by mempool. + /// + /// Returns `None` if the transaction is not in the mempool. + fn test_mempool_accept(&self, rawtxs: Vec) -> Vec; } impl BitcoinInterface for d::BitcoinD { @@ -402,6 +407,10 @@ impl BitcoinInterface for d::BitcoinD { fn mempool_entry(&self, txid: &bitcoin::Txid) -> Option { self.mempool_entry(txid) } + + fn test_mempool_accept(&self, rawtxs: Vec) -> Vec { + self.test_mempool_accept(rawtxs) + } } impl BitcoinInterface for electrum::Electrum { @@ -589,6 +598,10 @@ impl BitcoinInterface for electrum::Electrum { fn tip_time(&self) -> Option { self.client().tip_time().ok() } + + fn test_mempool_accept(&self, _rawtxs: Vec) -> Vec { + todo!() + } } // FIXME: do we need to repeat the entire trait implementation? Isn't there a nicer way? @@ -694,6 +707,10 @@ impl BitcoinInterface for sync::Arc> fn mempool_entry(&self, txid: &bitcoin::Txid) -> Option { self.lock().unwrap().mempool_entry(txid) } + + fn test_mempool_accept(&self, rawtxs: Vec) -> Vec { + self.lock().unwrap().test_mempool_accept(rawtxs) + } } // FIXME: We could avoid this type (and all the conversions entailing allocations) if bitcoind diff --git a/lianad/src/bitcoin/poller/looper.rs b/lianad/src/bitcoin/poller/looper.rs index 8654a6b49..483a0b283 100644 --- a/lianad/src/bitcoin/poller/looper.rs +++ b/lianad/src/bitcoin/poller/looper.rs @@ -1,6 +1,8 @@ use crate::{ bitcoin::{BitcoinInterface, BlockChainTip, UTxO, UTxOAddress}, + config::{Config, PayjoinConfig}, database::{Coin, DatabaseConnection, DatabaseInterface}, + payjoin::receiver::payjoin_receiver_check, }; use std::{collections::HashSet, convert::TryInto, sync, thread, time}; @@ -28,7 +30,7 @@ fn update_coins( bit: &impl BitcoinInterface, db_conn: &mut Box, previous_tip: &BlockChainTip, - descs: &[descriptors::SinglePathLianaDesc], + desc: &descriptors::LianaDescriptor, secp: &secp256k1::Secp256k1, ) -> UpdatedCoins { let network = db_conn.network(); @@ -36,6 +38,10 @@ fn update_coins( log::debug!("Current coins: {:?}", curr_coins); // Start by fetching newly received coins. + let descs = &[ + desc.receive_descriptor().clone(), + desc.change_descriptor().clone(), + ]; let mut received = Vec::new(); for utxo in bit.received_coins(previous_tip, descs) { let UTxO { @@ -241,7 +247,7 @@ fn new_tip(bit: &impl BitcoinInterface, current_tip: &BlockChainTip) -> TipUpdat fn updates( db_conn: &mut Box, bit: &mut impl BitcoinInterface, - descs: &[descriptors::SinglePathLianaDesc], + desc: &descriptors::LianaDescriptor, secp: &secp256k1::Secp256k1, ) { // Check if there was a new block before we update our state. @@ -264,7 +270,7 @@ fn updates( // between our former chain and the new one, then restart fresh. db_conn.rollback_tip(&new_tip); log::info!("Tip was rolled back to '{}'.", new_tip); - return updates(db_conn, bit, descs, secp); + return updates(db_conn, bit, desc, secp); } } } @@ -285,23 +291,23 @@ fn updates( &reorg_common_ancestor ); } - return updates(db_conn, bit, descs, secp); + return updates(db_conn, bit, desc, secp); } Err(e) => { log::error!("Error syncing wallet: '{}'.", e); thread::sleep(time::Duration::from_secs(2)); - return updates(db_conn, bit, descs, secp); + return updates(db_conn, bit, desc, secp); } }; // Then check the state of our coins. Do it even if the tip did not change since last poll, as // we may have unconfirmed transactions. - let updated_coins = update_coins(bit, db_conn, ¤t_tip, descs, secp); + let updated_coins = update_coins(bit, db_conn, ¤t_tip, desc, secp); // If the tip changed while we were polling our Bitcoin interface, start over. if bit.chain_tip() != latest_tip { log::info!("Chain tip changed while we were updating our state. Starting over."); - return updates(db_conn, bit, descs, secp); + return updates(db_conn, bit, desc, secp); } // Transactions must be added to the DB before coins due to foreign key constraints. @@ -330,7 +336,7 @@ fn updates( fn rescan_check( db_conn: &mut Box, bit: &mut impl BitcoinInterface, - descs: &[descriptors::SinglePathLianaDesc], + desc: &descriptors::LianaDescriptor, secp: &secp256k1::Secp256k1, ) { log::debug!("Checking the state of an ongoing rescan if there is any"); @@ -368,7 +374,7 @@ fn rescan_check( "Rolling back our internal tip to '{}' to update our internal state with past transactions.", rescan_tip ); - updates(db_conn, bit, descs, secp) + updates(db_conn, bit, desc, secp) } else { log::debug!("No ongoing rescan."); } @@ -399,11 +405,16 @@ pub fn poll( bit: &mut sync::Arc>, db: &sync::Arc>, secp: &secp256k1::Secp256k1, - descs: &[descriptors::SinglePathLianaDesc], + desc: &descriptors::LianaDescriptor, + payjoin_config: &Option, ) { let mut db_conn = db.connection(); - updates(&mut db_conn, bit, descs, secp); - rescan_check(&mut db_conn, bit, descs, secp); + updates(&mut db_conn, bit, desc, secp); + rescan_check(&mut db_conn, bit, desc, secp); + let resolved_payjoin_config = payjoin_config + .clone() + .unwrap_or_else(Config::default_payjoin_config); + payjoin_receiver_check(db, bit, desc, secp, &resolved_payjoin_config.ohttp_relay); let now: u32 = time::SystemTime::now() .duration_since(time::UNIX_EPOCH) .expect("current system time must be later than epoch") diff --git a/lianad/src/bitcoin/poller/mod.rs b/lianad/src/bitcoin/poller/mod.rs index bbc11933e..293c6a2e4 100644 --- a/lianad/src/bitcoin/poller/mod.rs +++ b/lianad/src/bitcoin/poller/mod.rs @@ -1,6 +1,6 @@ mod looper; -use crate::{bitcoin::BitcoinInterface, database::DatabaseInterface}; +use crate::{bitcoin::BitcoinInterface, config::PayjoinConfig, database::DatabaseInterface}; use liana::descriptors; use std::{ @@ -23,8 +23,8 @@ pub struct Poller { bit: sync::Arc>, db: sync::Arc>, secp: secp256k1::Secp256k1, - // The receive and change descriptors (in this order). - descs: [descriptors::SinglePathLianaDesc; 2], + desc: descriptors::LianaDescriptor, + payjoin_config: Option, } impl Poller { @@ -32,12 +32,9 @@ impl Poller { bit: sync::Arc>, db: sync::Arc>, desc: descriptors::LianaDescriptor, + payjoin_config: Option, ) -> Poller { let secp = secp256k1::Secp256k1::verification_only(); - let descs = [ - desc.receive_descriptor().clone(), - desc.change_descriptor().clone(), - ]; // On first startup the tip may be NULL. Make sure it's set as the poller relies on it. looper::maybe_initialize_tip(&bit, &db); @@ -46,7 +43,8 @@ impl Poller { bit, db, secp, - descs, + desc, + payjoin_config, } } @@ -108,7 +106,13 @@ impl Poller { // poll too soon. last_poll = Some(time::Instant::now()); if synced { - looper::poll(&mut self.bit, &self.db, &self.secp, &self.descs); + looper::poll( + &mut self.bit, + &self.db, + &self.secp, + &self.desc, + &self.payjoin_config, + ); } else { log::warn!("Skipped poll as block chain is still synchronizing."); } @@ -142,7 +146,13 @@ impl Poller { } } - looper::poll(&mut self.bit, &self.db, &self.secp, &self.descs); + looper::poll( + &mut self.bit, + &self.db, + &self.secp, + &self.desc, + &self.payjoin_config, + ); } } } diff --git a/lianad/src/commands/mod.rs b/lianad/src/commands/mod.rs index 4f88f3ea5..577eee417 100644 --- a/lianad/src/commands/mod.rs +++ b/lianad/src/commands/mod.rs @@ -7,8 +7,17 @@ mod utils; use crate::{ bitcoin::BitcoinInterface, + config::Config, database::{Coin, DatabaseConnection, DatabaseInterface}, miniscript::bitcoin::absolute::LockTime, + payjoin::{ + db::ReceiverPersister, + helpers::{fetch_ohttp_keys, FetchOhttpKeysError}, + receiver::{ + cancel_payjoin_for_session, find_receiver_session_by_txid, send_payjoin_for_session, + }, + types::{PayjoinRole, PayjoinStatus}, + }, poller::PollerMessage, DaemonControl, VERSION, }; @@ -32,7 +41,7 @@ use std::{ collections::{hash_map, HashMap, HashSet}, convert::TryInto, fmt, - sync::{self, mpsc}, + sync::{self, mpsc, Arc}, time::SystemTime, }; @@ -44,6 +53,7 @@ use miniscript::{ }, psbt::PsbtExt, }; +use payjoin::receive::v2::{replay_event_log as replay_receiver_event_log, ReceiverBuilder}; use serde::{Deserialize, Serialize}; #[allow(clippy::large_enum_variant)] @@ -76,6 +86,15 @@ pub enum CommandError { InvalidDerivationIndex, RbfError(RbfErrorInfo), EmptyFilterList, + FailedToFetchOhttpKeys(FetchOhttpKeysError), + // Same FIXME as `SpendFinalization` + FailedToPostOriginalPayjoinProposal(String), + ReplayError(String), + IntoUrlError(String), + NoPayjoinSessionForTxid(bitcoin::Txid), + SendPayjoinFailed(String), + CancelPayjoinFailed(String), + NoPayjoinFallback(bitcoin::Txid), } impl fmt::Display for CommandError { @@ -129,6 +148,31 @@ impl fmt::Display for CommandError { } Self::RbfError(e) => write!(f, "RBF error: '{e}'."), Self::EmptyFilterList => write!(f, "Filter list is empty, should supply None instead."), + Self::FailedToFetchOhttpKeys(e) => write!(f, "Failed to fetch OHTTP keys: '{e}'."), + Self::FailedToPostOriginalPayjoinProposal(e) => { + write!(f, "Failed to post original payjoin proposal: '{e}'.") + } + Self::ReplayError(e) => { + write!(f, "Payjoin replay failed: '{e}'.") + } + Self::IntoUrlError(e) => { + write!(f, "Payjoin into url failed: '{e}'.") + } + Self::NoPayjoinSessionForTxid(txid) => { + write!(f, "No payjoin receiver session found for txid '{txid}'.") + } + Self::SendPayjoinFailed(e) => { + write!(f, "Failed to send payjoin proposal: '{e}'.") + } + Self::CancelPayjoinFailed(e) => { + write!(f, "Failed to cancel payjoin session: '{e}'.") + } + Self::NoPayjoinFallback(txid) => { + write!( + f, + "No fallback transaction available for payjoin session '{txid}'." + ) + } } } } @@ -356,7 +400,127 @@ impl DaemonControl { .receive_descriptor() .derive(new_index, &self.secp) .address(self.config.bitcoin_config.network); - GetAddressResult::new(address, new_index) + GetAddressResult::new(address, new_index, None) + } + + /// Begin receive payjoin flow + pub fn receive_payjoin(&self) -> Result { + let mut db_conn = self.db.connection(); + + let payjoin_config = self + .config + .payjoin_config + .clone() + .unwrap_or_else(Config::default_payjoin_config); + let ohttp_relay_url = payjoin_config.ohttp_relay.clone(); + let payjoin_dir_url = payjoin_config.payjoin_directory.clone(); + + let ohttp_keys = if let Some(entry) = db_conn.payjoin_get_ohttp_keys(&ohttp_relay_url) { + entry.1 + } else { + let ohttp_relay = ohttp_relay_url.clone(); + let payjoin_dir = payjoin_dir_url.clone(); + let ohttp_keys = std::thread::spawn(move || fetch_ohttp_keys(ohttp_relay, payjoin_dir)) + .join() + .unwrap() + .map_err(CommandError::FailedToFetchOhttpKeys)?; + db_conn.payjoin_save_ohttp_keys(&ohttp_relay_url, ohttp_keys.clone()); + ohttp_keys + }; + + let index = db_conn.receive_index(); + let new_index = index + .increment() + .expect("Can't get into hardened territory"); + db_conn.set_receive_index(new_index, &self.secp); + let address = self + .config + .main_descriptor + .receive_descriptor() + .derive(new_index, &self.secp) + .address(self.config.bitcoin_config.network); + + let persister = ReceiverPersister::new(Arc::new(self.db.clone()), new_index.into()); + let session = ReceiverBuilder::new(address.clone(), payjoin_dir_url, ohttp_keys) + .map_err(|e| CommandError::IntoUrlError(e.to_string()))? + .build() + .save(&persister) + .unwrap(); + + let bip21 = session.pj_uri().to_string(); + + Ok(GetAddressResult::new(address, new_index, Some(bip21))) + } + + /// Get receiver session and its sender/receiver status by txid + pub fn get_payjoin_info(&self, txid: &bitcoin::Txid) -> Result { + log::debug!("Getting payjoin info for txid: {:?}", txid); + if let Some((session_id, _)) = find_receiver_session_by_txid(&self.db, txid) { + let persister = + ReceiverPersister::from_id(Arc::new(self.db.clone()), session_id.clone()); + match replay_receiver_event_log(&persister) { + Ok((state, _)) => return Ok(state.into()), + Err(e) => { + let msg = e.to_string(); + if msg.contains("expired") { + log::info!("Payjoin session {:?} expired for tx {:?}", session_id, txid); + return Ok(PayjoinStatus::Expired); + } + return Err(CommandError::ReplayError(format!( + "Receiver replay failed: {e:?}" + ))); + } + } + } + + Ok(PayjoinStatus::Unknown) + } + + /// Send a finalized payjoin proposal for the receiver session associated with the given txid. + pub fn send_payjoin_proposal(&self, txid: &bitcoin::Txid) -> Result<(), CommandError> { + let (session_id, _) = find_receiver_session_by_txid(&self.db, txid) + .ok_or(CommandError::NoPayjoinSessionForTxid(*txid))?; + let payjoin_config = self + .config + .payjoin_config + .clone() + .unwrap_or_else(Config::default_payjoin_config); + send_payjoin_for_session( + &self.db, + session_id, + &self.secp, + &payjoin_config.ohttp_relay, + ) + .map_err(|e| CommandError::SendPayjoinFailed(e.to_string())) + } + + /// Cancel the payjoin receiver session associated with the given txid and immediately + /// broadcast the sender's original (non-payjoin) fallback transaction. + pub fn broadcast_payjoin_fallback(&self, txid: &bitcoin::Txid) -> Result<(), CommandError> { + let (session_id, _) = find_receiver_session_by_txid(&self.db, txid) + .ok_or(CommandError::NoPayjoinSessionForTxid(*txid))?; + let fallback = cancel_payjoin_for_session(&self.db, session_id) + .map_err(|e| CommandError::CancelPayjoinFailed(e.to_string()))? + .ok_or(CommandError::NoPayjoinFallback(*txid))?; + self.bitcoin + .broadcast_tx(&fallback) + .map_err(CommandError::TxBroadcast)?; + + let (tx, rx) = mpsc::sync_channel(0); + if let Err(e) = self.poller_sender.send(PollerMessage::PollNow(tx)) { + log::error!("Error requesting update from poller: {}", e); + } + if let Err(e) = rx.recv() { + log::error!("Error receiving completion signal from poller: {}", e); + } + Ok(()) + } + + /// Get all active payjoin receiver sessions with their derivation indexes + pub fn get_active_payjoin_receiver_sessions(&self) -> Result, CommandError> { + let mut db_conn = self.db.connection(); + let sessions = db_conn.get_active_payjoin_receiver_sessions(); + Ok(sessions.into_iter().map(|(_, idx)| idx).collect()) } /// Update derivation indexes @@ -898,14 +1062,25 @@ impl DaemonControl { let mut spend_psbt = db_conn .spend_tx(txid) .ok_or(CommandError::UnknownSpend(*txid))?; - spend_psbt.finalize_mut(&self.secp).map_err(|e| { - CommandError::SpendFinalization( - e.into_iter() - .next() - .map(|e| e.to_string()) - .unwrap_or_default(), - ) - })?; + for index in 0..spend_psbt.inputs.len() { + match spend_psbt.finalize_inp_mut(&self.secp, index) { + Ok(_) => log::debug!("Finalizing input at: {}", index), + Err(e) => { + // If the input is already finalized (e.g. a payjoin sender input that + // arrived with final_script_witness already set), ignore the error. + // Otherwise, the transaction can't be broadcast — return an error. + let input = &spend_psbt.inputs[index]; + if input.final_script_witness.is_none() && input.final_script_sig.is_none() { + return Err(CommandError::SpendFinalization(e.to_string())); + } + log::debug!( + "Input at index {} already finalized, skipping: {}", + index, + e + ); + } + } + } // Then, broadcast it (or try to, we never know if we are not going to hit an // error at broadcast time). @@ -1234,7 +1409,17 @@ impl DaemonControl { .connection() .list_wallet_transactions(txids) .into_iter() - .map(|(tx, height, time)| TransactionInfo { tx, height, time }) + .map(|(tx, height, time)| { + let txid = tx.compute_txid(); + let payjoin_role = + find_receiver_session_by_txid(&self.db, &txid).map(|_| PayjoinRole::Receiver); + TransactionInfo { + tx, + height, + time, + payjoin_role, + } + }) .collect(); ListTransactionsResult { transactions } } @@ -1369,13 +1554,19 @@ pub struct GetAddressResult { #[serde(deserialize_with = "deser_addr_assume_checked")] pub address: bitcoin::Address, pub derivation_index: bip32::ChildNumber, + pub bip21: Option, } impl GetAddressResult { - pub fn new(address: bitcoin::Address, derivation_index: bip32::ChildNumber) -> Self { + pub fn new( + address: bitcoin::Address, + derivation_index: bip32::ChildNumber, + bip21: Option, + ) -> Self { Self { address, derivation_index, + bip21, } } } @@ -1517,6 +1708,8 @@ pub struct TransactionInfo { pub tx: bitcoin::Transaction, pub height: Option, pub time: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub payjoin_role: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] diff --git a/lianad/src/config.rs b/lianad/src/config.rs index 5756188f3..a400fc66a 100644 --- a/lianad/src/config.rs +++ b/lianad/src/config.rs @@ -138,6 +138,21 @@ fn default_validate_domain() -> bool { true } +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +pub struct PayjoinConfig { + pub ohttp_relay: String, + pub payjoin_directory: String, +} + +impl PayjoinConfig { + pub fn new(ohttp_relay: String, payjoin_directory: String) -> Self { + Self { + ohttp_relay, + payjoin_directory, + } + } +} + #[derive(Debug, Clone, Deserialize, Serialize)] pub struct BitcoinConfig { /// The network we are operating on, one of "bitcoin", "testnet", "testnet4", "regtest", "signet" @@ -176,6 +191,9 @@ pub struct Config { /// Settings specific to the Bitcoin backend. #[serde(flatten)] pub bitcoin_backend: Option, + /// Settings for Payjoin. + #[serde(default)] + pub payjoin_config: Option, } impl Config { @@ -193,6 +211,14 @@ impl Config { main_descriptor, data_directory: Some(data_directory.path().to_path_buf()), data_dir: None, + payjoin_config: None, + } + } + + pub fn default_payjoin_config() -> PayjoinConfig { + PayjoinConfig { + ohttp_relay: "https://pj.bobspacebkk.com".to_string(), + payjoin_directory: "https://payjo.in".to_string(), } } diff --git a/lianad/src/database/mod.rs b/lianad/src/database/mod.rs index fedc827d1..07b0b6c52 100644 --- a/lianad/src/database/mod.rs +++ b/lianad/src/database/mod.rs @@ -10,6 +10,7 @@ use crate::{ schema::{DbBlockInfo, DbCoin, DbTip}, SqliteConn, SqliteDb, }, + payjoin::db::SessionId, }; use std::{ @@ -22,6 +23,7 @@ use std::{ use bip329::Labels; use miniscript::bitcoin::{self, bip32, psbt::Psbt, secp256k1, Address, Network, OutPoint, Txid}; +use payjoin::OhttpKeys; /// Information about the wallet. /// @@ -194,6 +196,37 @@ pub trait DatabaseConnection { /// Dump all labels fn get_labels_bip329(&mut self, offset: u32, limit: u32) -> Labels; + + /// Get OhttpKeys + fn payjoin_get_ohttp_keys(&mut self, ohttp_relay: &str) -> Option<(u32, OhttpKeys)>; + + /// Save OHttpKeys + fn payjoin_save_ohttp_keys(&mut self, ohttp_relay: &str, ohttp_keys: OhttpKeys); + + /// Save Receiver Session + fn save_new_payjoin_receiver_session(&mut self, derivation_index: u32) -> i64; + + /// Get active payjoin sessions with their derivation indexes + fn get_active_payjoin_receiver_sessions(&mut self) -> Vec<(SessionId, u32)>; + + /// Get all Receiver Sessions + fn get_all_active_receiver_session_ids(&mut self) -> Vec; + + /// Get every Receiver Session (active and closed) with its derivation index + fn get_all_receiver_sessions(&mut self) -> Vec<(SessionId, u32)>; + + /// Save a Receiver Session Event + fn save_receiver_session_event(&mut self, session_id: &SessionId, event: Vec); + + /// Update completed at timestamp for a Receiver Session + /// Sets completed_at to current timestamp + fn update_receiver_session_completed_at(&mut self, session_id: &SessionId); + + /// Load all receiver session events for a particular session id + fn load_receiver_session_events(&mut self, session_id: &SessionId) -> Vec>; + + /// Check if input has been seen before and then add it to the input_seen table + fn insert_input_seen_before(&mut self, outpoint: &bitcoin::OutPoint) -> bool; } impl DatabaseConnection for SqliteConn { @@ -416,6 +449,46 @@ impl DatabaseConnection for SqliteConn { }) .collect() } + + fn insert_input_seen_before(&mut self, outpoint: &bitcoin::OutPoint) -> bool { + self.insert_outpoint_seen_before(outpoint) + } + + fn payjoin_get_ohttp_keys(&mut self, ohttp_relay: &str) -> Option<(u32, OhttpKeys)> { + self.payjoin_get_ohttp_keys(ohttp_relay) + } + + fn payjoin_save_ohttp_keys(&mut self, ohttp_relay: &str, ohttp_keys: OhttpKeys) { + self.payjoin_save_ohttp_keys(ohttp_relay, ohttp_keys) + } + + fn save_new_payjoin_receiver_session(&mut self, derivation_index: u32) -> i64 { + self.save_new_payjoin_receiver_session(derivation_index) + } + + fn get_active_payjoin_receiver_sessions(&mut self) -> Vec<(SessionId, u32)> { + self.get_active_payjoin_receiver_sessions() + } + + fn get_all_active_receiver_session_ids(&mut self) -> Vec { + self.get_all_active_receiver_session_ids() + } + + fn get_all_receiver_sessions(&mut self) -> Vec<(SessionId, u32)> { + self.get_all_receiver_sessions() + } + + fn save_receiver_session_event(&mut self, session_id: &SessionId, event: Vec) { + self.save_receiver_session_event(session_id, event) + } + + fn update_receiver_session_completed_at(&mut self, session_id: &SessionId) { + self.update_receiver_session_completed_at(session_id) + } + + fn load_receiver_session_events(&mut self, session_id: &SessionId) -> Vec> { + self.load_receiver_session_events(session_id) + } } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] diff --git a/lianad/src/database/sqlite/mod.rs b/lianad/src/database/sqlite/mod.rs index 661d94c12..4ebfe100b 100644 --- a/lianad/src/database/sqlite/mod.rs +++ b/lianad/src/database/sqlite/mod.rs @@ -25,8 +25,11 @@ use crate::{ }, Coin, CoinStatus, LabelItem, }, + payjoin::db::SessionId, }; use liana::descriptors::LianaDescriptor; +use payjoin::{bitcoin::consensus::Encodable, OhttpKeys}; +use serde_json; use std::{ cmp, @@ -479,6 +482,29 @@ impl SqliteConn { .expect("Database must be available") } + pub fn insert_outpoint_seen_before(&mut self, outpoint: &bitcoin::OutPoint) -> bool { + let mut is_duplicate = false; + db_exec(&mut self.conn, |db_tx| { + let mut buf = Vec::new(); + outpoint + .consensus_encode(&mut buf) + .expect("Outpoint must encode"); + let affected = db_tx.execute( + "INSERT OR IGNORE INTO payjoin_outpoints (outpoint, created_at) \ + VALUES (?1, ?2)", + rusqlite::params![buf, curr_timestamp()], + )?; + + if affected == 0 { + is_duplicate = true + } + Ok(()) + }) + .expect("database must be available"); + + is_duplicate + } + /// Remove a set of coins from the database. pub fn remove_coins(&mut self, outpoints: &[bitcoin::OutPoint]) { db_exec(&mut self.conn, |db_tx| { @@ -963,6 +989,170 @@ impl SqliteConn { }) .expect("Db must not fail"); } + + /// Fetch Payjoin OHttpKeys and their timestamp + pub fn payjoin_get_ohttp_keys(&mut self, relay_url: &str) -> Option<(u32, OhttpKeys)> { + let entries = db_query( + &mut self.conn, + "SELECT timestamp, key FROM payjoin_ohttp_keys WHERE relay_url = ?1 ORDER BY timestamp DESC LIMIT 1", + rusqlite::params![relay_url], + |row| { + let timestamp: u32 = row.get(0)?; + let ohttp_keys_ser: Vec = row.get(1)?; + let ohttp_keys = OhttpKeys::decode(&ohttp_keys_ser).unwrap(); + Ok((timestamp, ohttp_keys)) + }, + ) + .expect("Db must not fail"); + + // Check timestamp (7-days) + if let Some(entry) = entries.first().cloned() { + let now = curr_timestamp(); + let seven_days_ago = now.saturating_sub(7 * 24 * 60 * 60); + if entry.0 < seven_days_ago { + // Delete entry + db_exec(&mut self.conn, |db_tx| { + db_tx.execute( + "DELETE FROM payjoin_ohttp_keys WHERE relay_url = ?1", + rusqlite::params![relay_url], + )?; + Ok(()) + }) + .expect("Db must not fail"); + return None; + } else { + return Some(entry); + } + } + None + } + + /// Store new OHttpKeys with timestamp + pub fn payjoin_save_ohttp_keys(&mut self, relay_url: &str, ohttp_keys: OhttpKeys) { + let ohttp_keys_ser = ohttp_keys.encode().unwrap(); + db_exec(&mut self.conn, |db_tx| { + db_tx.execute( + "INSERT INTO payjoin_ohttp_keys (relay_url, timestamp, key) VALUES (?1, ?2, ?3)", + rusqlite::params![relay_url, curr_timestamp(), ohttp_keys_ser], + )?; + Ok(()) + }) + .expect("Db must not fail"); + } + + /// Get all active receiver session ids + pub fn get_all_active_receiver_session_ids(&mut self) -> Vec { + db_query( + &mut self.conn, + "SELECT id FROM payjoin_receivers WHERE completed_at IS NULL", + rusqlite::params![], + |row| { + let id: i64 = row.get(0)?; + Ok(SessionId::new(id)) + }, + ) + .expect("Db must not fail") + } + + /// Save a Receiver Session Event + pub fn save_receiver_session_event(&mut self, session_id: &SessionId, event: Vec) { + db_exec(&mut self.conn, |db_tx| { + let events: Vec> = db_tx + .query_row( + "SELECT events FROM payjoin_receivers WHERE id = ?1", + rusqlite::params![session_id.0], + |row| { + let events_json: String = row.get(0)?; + Ok(serde_json::from_str(&events_json).unwrap_or_default()) + }, + ) + .unwrap_or_default(); + let mut events = events; + events.push(event); + let events_json = serde_json::to_string(&events).unwrap(); + db_tx.execute( + "UPDATE payjoin_receivers SET events = ?1 WHERE id = ?2", + rusqlite::params![events_json, session_id.0], + )?; + Ok(()) + }) + .expect("Db must not fail"); + } + + /// Update completed at timestamp for a Receiver Session + pub fn update_receiver_session_completed_at(&mut self, session_id: &SessionId) { + db_exec(&mut self.conn, |db_tx| { + db_tx.execute( + "UPDATE payjoin_receivers SET completed_at = ?1 WHERE id = ?2", + rusqlite::params![curr_timestamp(), session_id.0], + )?; + Ok(()) + }) + .expect("Db must not fail"); + } + + /// Load all receiver session events for a particular session id + pub fn load_receiver_session_events(&mut self, session_id: &SessionId) -> Vec> { + db_query( + &mut self.conn, + "SELECT events FROM payjoin_receivers WHERE id = ?1", + rusqlite::params![session_id.0], + |row| { + let events_json: String = row.get(0)?; + Ok(serde_json::from_str(&events_json).unwrap_or_default()) + }, + ) + .expect("Db must not fail") + .into_iter() + .next() + .unwrap_or_default() + } + + /// Get every receiver session (both active and closed) with its derivation index. + pub fn get_all_receiver_sessions(&mut self) -> Vec<(SessionId, u32)> { + db_query( + &mut self.conn, + "SELECT id, derivation_index FROM payjoin_receivers WHERE derivation_index IS NOT NULL", + rusqlite::params![], + |row| { + let id: i64 = row.get(0)?; + let derivation_index: u32 = row.get(1)?; + Ok((SessionId::new(id), derivation_index)) + }, + ) + .expect("Db must not fail") + } + + /// Create new Receiver Session + pub fn save_new_payjoin_receiver_session(&mut self, derivation_index: u32) -> i64 { + let mut id = 0i64; + db_exec(&mut self.conn, |db_tx| { + db_tx.execute( + "INSERT INTO payjoin_receivers (derivation_index, created_at) VALUES (?1, ?2)", + rusqlite::params![derivation_index, curr_timestamp()], + )?; + + id = db_tx.last_insert_rowid(); + Ok(()) + }) + .expect("Db must not fail"); + id + } + + /// Get all active receiver session ids with their derivation indexes + pub fn get_active_payjoin_receiver_sessions(&mut self) -> Vec<(SessionId, u32)> { + db_query( + &mut self.conn, + "SELECT id, derivation_index FROM payjoin_receivers WHERE completed_at IS NULL AND derivation_index IS NOT NULL", + rusqlite::params![], + |row| { + let id: i64 = row.get(0)?; + let derivation_index: u32 = row.get(1)?; + Ok((SessionId::new(id), derivation_index)) + }, + ) + .expect("Db must not fail") + } } #[cfg(test)] diff --git a/lianad/src/database/sqlite/schema.rs b/lianad/src/database/sqlite/schema.rs index c80beb8e4..495a65abd 100644 --- a/lianad/src/database/sqlite/schema.rs +++ b/lianad/src/database/sqlite/schema.rs @@ -1,5 +1,6 @@ use bip329::Label; use liana::descriptors::LianaDescriptor; +use payjoin::bitcoin::{consensus::Decodable, io::Cursor}; use std::{convert::TryFrom, str::FromStr}; @@ -87,6 +88,16 @@ CREATE TABLE coins ( ON DELETE RESTRICT ); +/* Seen Payjoin outpoints + * + * The 'created_at' field is simply the time that this outpoint is added to the table for + * tracking. + */ +CREATE TABLE payjoin_outpoints ( + outpoint BLOB NOT NULL PRIMARY KEY, + created_at INTEGER NOT NULL +); + /* A mapping from descriptor address to derivation index. Necessary until * we can get the derivation index from the parent descriptor from bitcoind. */ @@ -122,6 +133,24 @@ CREATE TABLE labels ( item TEXT UNIQUE NOT NULL, value TEXT NOT NULL ); + +/* Payjoin OHttpKeys */ +CREATE TABLE payjoin_ohttp_keys ( + id INTEGER PRIMARY KEY NOT NULL, + relay_url TEXT UNIQUE NOT NULL, + timestamp INTEGER NOT NULL, + key BLOB NOT NULL +); + +/* Payjoin receivers */ +CREATE TABLE payjoin_receivers ( + id INTEGER PRIMARY KEY NOT NULL, + derivation_index INTEGER NOT NULL, + created_at INTEGER NOT NULL, + completed_at INTEGER, + events TEXT NOT NULL DEFAULT '[]', + FOREIGN KEY (derivation_index) REFERENCES addresses (derivation_index) +); "; /// A row in the "tip" table. @@ -456,3 +485,27 @@ impl TryFrom<&rusqlite::Row<'_>> for DbWalletTransaction { }) } } + +/// An outpoint we have seen before in payjoin transactions +#[derive(Clone, Debug, PartialEq)] +pub struct DbPayjoinOutpoint { + pub outpoint: bitcoin::OutPoint, + pub created_at: Option, +} + +impl TryFrom<&rusqlite::Row<'_>> for DbPayjoinOutpoint { + type Error = rusqlite::Error; + + fn try_from(row: &rusqlite::Row) -> Result { + let outpoint: Vec = row.get(0)?; + let outpoint = bitcoin::OutPoint::consensus_decode(&mut Cursor::new(outpoint)) + .expect("Outpoint should be decodable"); + + let created_at = row.get(1)?; + + Ok(DbPayjoinOutpoint { + outpoint, + created_at, + }) + } +} diff --git a/lianad/src/jsonrpc/api.rs b/lianad/src/jsonrpc/api.rs index 06288faf4..c87a2319a 100644 --- a/lianad/src/jsonrpc/api.rs +++ b/lianad/src/jsonrpc/api.rs @@ -482,6 +482,60 @@ fn get_labels_bip329(control: &DaemonControl, params: Params) -> Result Result { + let res = control.receive_payjoin()?; + Ok(serde_json::json!(&res)) +} + +fn get_payjoin_info(control: &DaemonControl, params: Params) -> Result { + let txid = params + .get(0, "txid") + .ok_or_else(|| Error::invalid_params("Missing 'txid' parameter."))? + .as_str() + .ok_or_else(|| Error::invalid_params("Invalid 'txid' parameter."))?; + let txid = bitcoin::Txid::from_str(txid) + .map_err(|_| Error::invalid_params("Invalid 'txid' parameter."))?; + let res = control.get_payjoin_info(&txid)?; + Ok(serde_json::json!(&res)) +} + +fn get_active_payjoin_receiver_sessions( + control: &DaemonControl, +) -> Result { + let res = control.get_active_payjoin_receiver_sessions()?; + Ok(serde_json::json!(&res)) +} + +fn send_payjoin_proposal( + control: &DaemonControl, + params: Params, +) -> Result { + let txid = params + .get(0, "txid") + .ok_or_else(|| Error::invalid_params("Missing 'txid' parameter."))? + .as_str() + .ok_or_else(|| Error::invalid_params("Invalid 'txid' parameter."))?; + let txid = bitcoin::Txid::from_str(txid) + .map_err(|_| Error::invalid_params("Invalid 'txid' parameter."))?; + control.send_payjoin_proposal(&txid)?; + Ok(serde_json::json!({})) +} + +fn broadcast_payjoin_fallback( + control: &DaemonControl, + params: Params, +) -> Result { + let txid = params + .get(0, "txid") + .ok_or_else(|| Error::invalid_params("Missing 'txid' parameter."))? + .as_str() + .ok_or_else(|| Error::invalid_params("Invalid 'txid' parameter."))?; + let txid = bitcoin::Txid::from_str(txid) + .map_err(|_| Error::invalid_params("Invalid 'txid' parameter."))?; + control.broadcast_payjoin_fallback(&txid)?; + Ok(serde_json::json!({})) +} + /// Handle an incoming JSONRPC2 request. pub fn handle_request(control: &mut DaemonControl, req: Request) -> Result { let result = match req.method.as_str() { @@ -589,6 +643,26 @@ pub fn handle_request(control: &mut DaemonControl, req: Request) -> Result receive_payjoin(control)?, + "getpayjoininfo" => { + let params = req + .params + .ok_or_else(|| Error::invalid_params("Missing 'txid' parameter."))?; + get_payjoin_info(control, params)? + } + "getactivepayjoinreceiversessions" => get_active_payjoin_receiver_sessions(control)?, + "sendpayjoinproposal" => { + let params = req + .params + .ok_or_else(|| Error::invalid_params("Missing 'txid' parameter."))?; + send_payjoin_proposal(control, params)? + } + "broadcastpayjoinfallback" => { + let params = req + .params + .ok_or_else(|| Error::invalid_params("Missing 'txid' parameter."))?; + broadcast_payjoin_fallback(control, params)? + } _ => { return Err(Error::method_not_found()); } diff --git a/lianad/src/jsonrpc/rpc.rs b/lianad/src/jsonrpc/rpc.rs index 90d307db6..2dcf92c09 100644 --- a/lianad/src/jsonrpc/rpc.rs +++ b/lianad/src/jsonrpc/rpc.rs @@ -50,6 +50,8 @@ pub struct Request { /// A failure to broadcast a transaction to the P2P network. const BROADCAST_ERROR: i64 = 1_000; +const REPLAY_ERROR: i64 = 1_001; +const INTO_URL_ERROR: i64 = 1_002; /// JSONRPC2 error codes. See https://www.jsonrpc.org/specification#error_object. #[derive(Debug, PartialEq, Eq, Clone)] @@ -164,7 +166,8 @@ impl From for Error { | commands::CommandError::RbfError(..) | commands::CommandError::EmptyFilterList | commands::CommandError::RecoveryNotAvailable - | commands::CommandError::OutpointNotRecoverable(..) => { + | commands::CommandError::OutpointNotRecoverable(..) + | commands::CommandError::FailedToFetchOhttpKeys(..) => { Error::new(ErrorCode::InvalidParams, e.to_string()) } commands::CommandError::RescanTrigger(..) => { @@ -173,6 +176,27 @@ impl From for Error { commands::CommandError::TxBroadcast(_) => { Error::new(ErrorCode::ServerError(BROADCAST_ERROR), e.to_string()) } + commands::CommandError::FailedToPostOriginalPayjoinProposal(_) => { + Error::new(ErrorCode::ServerError(BROADCAST_ERROR), e.to_string()) + } + commands::CommandError::ReplayError(_) => { + Error::new(ErrorCode::ServerError(REPLAY_ERROR), e.to_string()) + } + commands::CommandError::IntoUrlError(_) => { + Error::new(ErrorCode::ServerError(INTO_URL_ERROR), e.to_string()) + } + commands::CommandError::NoPayjoinSessionForTxid(_) => { + Error::new(ErrorCode::InvalidParams, e.to_string()) + } + commands::CommandError::SendPayjoinFailed(_) => { + Error::new(ErrorCode::ServerError(BROADCAST_ERROR), e.to_string()) + } + commands::CommandError::CancelPayjoinFailed(_) => { + Error::new(ErrorCode::ServerError(BROADCAST_ERROR), e.to_string()) + } + commands::CommandError::NoPayjoinFallback(_) => { + Error::new(ErrorCode::InvalidParams, e.to_string()) + } } } } diff --git a/lianad/src/lib.rs b/lianad/src/lib.rs index af8881aef..a5f92591a 100644 --- a/lianad/src/lib.rs +++ b/lianad/src/lib.rs @@ -4,6 +4,7 @@ pub mod config; mod database; pub mod datadir; mod jsonrpc; +pub mod payjoin; #[cfg(test)] mod testutils; @@ -442,9 +443,13 @@ impl DaemonHandle { // Start the poller thread. Keep the thread handle to be able to check if it crashed. Store // an atomic to be able to stop it. - let mut bitcoin_poller = - poller::Poller::new(bit.clone(), db.clone(), config.main_descriptor.clone()); - let (poller_sender, poller_receiver) = mpsc::sync_channel(1); + let mut bitcoin_poller = poller::Poller::new( + bit.clone(), + db.clone(), + config.main_descriptor.clone(), + config.payjoin_config.clone(), + ); + let (poller_sender, poller_receiver) = mpsc::sync_channel(0); let poller_handle = thread::Builder::new() .name("Bitcoin Network poller".to_string()) .spawn({ diff --git a/lianad/src/payjoin/db.rs b/lianad/src/payjoin/db.rs new file mode 100644 index 000000000..e4566eb18 --- /dev/null +++ b/lianad/src/payjoin/db.rs @@ -0,0 +1,91 @@ +use payjoin::persist::SessionPersister; +use payjoin::receive::v2::SessionEvent as ReceiverSessionEvent; +use serde::{Deserialize, Serialize}; +use std::fmt::{self, Display, Formatter}; +use std::sync::Arc; + +use crate::database::DatabaseInterface; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SessionId(pub i64); + +impl SessionId { + pub fn new(id: i64) -> Self { + Self(id) + } +} + +#[derive(Debug)] +pub(crate) enum PersisterError { + Serialize(serde_json::Error), +} + +impl Display for PersisterError { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + match self { + PersisterError::Serialize(e) => write!(f, "Serialization failed: {e}"), + } + } +} + +impl std::error::Error for PersisterError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + PersisterError::Serialize(e) => Some(e), + } + } +} + +#[derive(Clone)] +pub(crate) struct ReceiverPersister { + db: Arc, + pub session_id: SessionId, +} + +impl ReceiverPersister { + pub fn new(db: Arc, derivation_index: u32) -> Self { + let mut db_conn = db.connection(); + let session_id = db_conn.save_new_payjoin_receiver_session(derivation_index); + Self { + db, + session_id: SessionId(session_id), + } + } + + pub fn from_id(db: Arc, id: SessionId) -> Self { + Self { db, session_id: id } + } +} + +impl SessionPersister for ReceiverPersister { + type SessionEvent = ReceiverSessionEvent; + type InternalStorageError = PersisterError; + + fn save_event( + &self, + event: Self::SessionEvent, + ) -> std::result::Result<(), Self::InternalStorageError> { + let mut db_conn = self.db.connection(); + let event_ser = serde_json::to_vec(&event).map_err(PersisterError::Serialize)?; + db_conn.save_receiver_session_event(&self.session_id, event_ser); + Ok(()) + } + + fn load( + &self, + ) -> std::result::Result>, Self::InternalStorageError> + { + let mut db_conn = self.db.connection(); + let events = db_conn.load_receiver_session_events(&self.session_id); + let iter = events + .into_iter() + .map(|event| serde_json::from_slice(&event).expect("Event to be serialized correctly")); + Ok(Box::new(iter)) + } + + fn close(&self) -> std::result::Result<(), Self::InternalStorageError> { + let mut db_conn = self.db.connection(); + db_conn.update_receiver_session_completed_at(&self.session_id); + Ok(()) + } +} diff --git a/lianad/src/payjoin/helpers.rs b/lianad/src/payjoin/helpers.rs new file mode 100644 index 000000000..5a8b81449 --- /dev/null +++ b/lianad/src/payjoin/helpers.rs @@ -0,0 +1,129 @@ +use std::time::Duration; + +use miniscript::{ + bitcoin::{secp256k1, Psbt, ScriptBuf, TxOut}, + psbt::PsbtExt, +}; + +use payjoin::{bitcoin::Amount, IntoUrl, OhttpKeys}; +use reqwest::{header::ACCEPT, Proxy}; + +pub(crate) fn http_agent() -> reqwest::blocking::Client { + reqwest::blocking::Client::new() +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FetchOhttpKeysError { + Reqwest(String), + InvalidOhttpKeys(String), + InvalidUrl(String), + UrlParseError, + UnexpectedStatusCode(reqwest::StatusCode), +} + +impl std::error::Error for FetchOhttpKeysError {} +impl std::fmt::Display for FetchOhttpKeysError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{self:?}") + } +} + +pub(crate) fn fetch_ohttp_keys( + ohttp_relay: impl IntoUrl, + payjoin_directory: impl IntoUrl, +) -> Result { + let payjoin_directory_str = payjoin_directory.as_str().to_string(); + let payjoin_directory_url = payjoin_directory + .into_url() + .map_err(|_| FetchOhttpKeysError::InvalidUrl(payjoin_directory_str.clone()))? + .join("/.well-known/ohttp-gateway") + .map_err(|_| FetchOhttpKeysError::UrlParseError)? + .to_string(); + + let ohttp_relay_str = ohttp_relay.as_str().to_string(); + let proxy = Proxy::all( + ohttp_relay + .into_url() + .map_err(|_| FetchOhttpKeysError::InvalidUrl(ohttp_relay_str.clone()))? + .as_str(), + ) + .map_err(|e| FetchOhttpKeysError::Reqwest(e.to_string()))?; + let client = reqwest::blocking::Client::builder() + .proxy(proxy) + .timeout(Duration::from_secs(15)) + .build() + .map_err(|e| FetchOhttpKeysError::Reqwest(e.to_string()))?; + let res = client + .get(payjoin_directory_url) + .header(ACCEPT, "application/ohttp-keys") + .send() + .map_err(|e| FetchOhttpKeysError::Reqwest(e.to_string()))?; + validate_ohttp_keys_response(res) +} + +fn validate_ohttp_keys_response( + res: reqwest::blocking::Response, +) -> Result { + if !res.status().is_success() { + return Err(FetchOhttpKeysError::UnexpectedStatusCode(res.status())); + } + + let body = res.bytes().unwrap().to_vec(); + match OhttpKeys::decode(&body) { + Ok(ohttp_keys) => Ok(ohttp_keys), + Err(err) => Err(FetchOhttpKeysError::InvalidOhttpKeys(err.to_string())), + } +} + +pub(crate) fn post_request( + req: payjoin::Request, +) -> Result { + let http = http_agent(); + http.post(req.url.to_string()) + .header("Content-Type", req.content_type) + .body(req.body) + .timeout(Duration::from_secs(10)) + .send() +} + +/// Optimistically attempt to create witness for all inputs. +/// This method will not fail even if some inputs are not finalized or include invalid partial signatures. +pub(crate) fn finalize_psbt(psbt: &mut Psbt, secp: &secp256k1::Secp256k1) { + let mut witness_utxo_to_clean = vec![]; + let mut inputs_to_finalize = vec![]; + for (index, input) in psbt.inputs.iter_mut().enumerate() { + if input.witness_utxo.is_none() { + // Sender's wallet cleans this up (from original PSBT) but we need it to finalize_inp_mut() below + input.witness_utxo = Some(TxOut { + value: Amount::ZERO, + script_pubkey: ScriptBuf::default(), + }); + + input.final_script_sig = None; + input.final_script_witness = None; + + witness_utxo_to_clean.push(index); + continue; + } + if input.final_script_sig.is_some() + || input.final_script_witness.is_some() + || input.partial_sigs.is_empty() + { + input.final_script_sig = None; + input.final_script_witness = None; + continue; + } + inputs_to_finalize.push(index); + } + + for index in &inputs_to_finalize { + match psbt.finalize_inp_mut(secp, *index) { + Ok(_) => log::info!("Finalizing input at: {}", index), + Err(e) => log::warn!("Failed to finalize input at: {} | {}", index, e), + } + } + + for index in witness_utxo_to_clean { + psbt.inputs[index].witness_utxo = None; + } +} diff --git a/lianad/src/payjoin/mod.rs b/lianad/src/payjoin/mod.rs new file mode 100644 index 000000000..41224358a --- /dev/null +++ b/lianad/src/payjoin/mod.rs @@ -0,0 +1,4 @@ +pub(crate) mod db; +pub(crate) mod helpers; +pub(crate) mod receiver; +pub mod types; diff --git a/lianad/src/payjoin/receiver.rs b/lianad/src/payjoin/receiver.rs new file mode 100644 index 000000000..ad7d0a968 --- /dev/null +++ b/lianad/src/payjoin/receiver.rs @@ -0,0 +1,551 @@ +use std::{ + collections::HashMap, + error::Error, + sync::{self, Arc}, +}; + +use liana::{descriptors, spend::AddrInfo}; + +use payjoin::{ + bitcoin::{ + self, consensus::encode::serialize_hex, psbt::Input, secp256k1, OutPoint, Sequence, TxIn, + Weight, + }, + persist::{OptionalTransitionOutcome, SessionPersister}, + receive::{ + v2::{ + replay_event_log, Initialized, MaybeInputsOwned, MaybeInputsSeen, OutputsUnknown, + PayjoinProposal, ProvisionalProposal, ReceiveSession, Receiver, SessionEvent, + UncheckedOriginalPayload, WantsFeeRange, WantsInputs, WantsOutputs, + }, + InputPair, + }, + ImplementationError, +}; + +use crate::{ + bitcoin::BitcoinInterface, + database::{Coin, CoinStatus, DatabaseConnection, DatabaseInterface}, + payjoin::helpers::{finalize_psbt, post_request}, +}; + +use super::db::{ReceiverPersister, SessionId}; + +fn read_from_directory( + receiver: Receiver, + persister: &ReceiverPersister, + db_conn: &mut Box, + bit: &mut sync::Arc>, + desc: &descriptors::LianaDescriptor, + secp: &secp256k1::Secp256k1, + ohttp_relay: &str, +) -> Result<(), Box> { + let (req, context) = receiver + .create_poll_request(ohttp_relay) + .map_err(|e| format!("Failed to extract request: {e:?}"))?; + + let proposal = match post_request(req.clone()) { + Ok(ohttp_response) => { + let response_bytes = ohttp_response.bytes()?; + let state_transition = receiver + .process_response(response_bytes.as_ref(), context) + .save(persister); + match state_transition { + Ok(OptionalTransitionOutcome::Progress(next_state)) => next_state, + Ok(OptionalTransitionOutcome::Stasis(_current_state)) => { + return Err("NoResults".into()) + } + Err(e) => return Err(e.into()), + } + } + Err(e) => return Err(Box::new(e)), + }; + check_proposal(proposal, persister, db_conn, bit, desc, secp) +} + +fn check_proposal( + proposal: Receiver, + persister: &ReceiverPersister, + db_conn: &mut Box, + bit: &mut sync::Arc>, + desc: &descriptors::LianaDescriptor, + secp: &secp256k1::Secp256k1, +) -> Result<(), Box> { + // Receive Check 1: Can Broadcast + let proposal = proposal + .check_broadcast_suitability(None, |tx| { + let result = bit.test_mempool_accept(vec![serialize_hex(tx)]); + match result.first().cloned() { + Some(can_broadcast) => Ok(can_broadcast), + None => Ok(false), + } + }) + .save(persister)?; + check_inputs_not_owned(proposal, persister, db_conn, desc, secp) +} + +fn check_inputs_not_owned( + proposal: Receiver, + persister: &ReceiverPersister, + db_conn: &mut Box, + desc: &descriptors::LianaDescriptor, + secp: &secp256k1::Secp256k1, +) -> Result<(), Box> { + let proposal = proposal + .check_inputs_not_owned(&mut |script| { + let address = + bitcoin::Address::from_script(script, db_conn.network()).map_err(|e| { + ImplementationError::from(Box::new(e) as Box) + })?; + Ok(db_conn + .derivation_index_by_address(&address) + .map(|(index, is_change)| AddrInfo { index, is_change }) + .is_some()) + }) + .save(persister)?; + check_no_inputs_seen_before(proposal, persister, db_conn, desc, secp) +} + +fn check_no_inputs_seen_before( + proposal: Receiver, + persister: &ReceiverPersister, + db_conn: &mut Box, + desc: &descriptors::LianaDescriptor, + secp: &secp256k1::Secp256k1, +) -> Result<(), Box> { + let proposal = proposal + .check_no_inputs_seen_before(&mut |outpoint| { + let seen = db_conn.insert_input_seen_before(outpoint); + Ok(seen) + }) + .save(persister)?; + identify_receiver_outputs(proposal, persister, db_conn, desc, secp) +} + +fn identify_receiver_outputs( + proposal: Receiver, + persister: &ReceiverPersister, + db_conn: &mut Box, + desc: &descriptors::LianaDescriptor, + secp: &secp256k1::Secp256k1, +) -> Result<(), Box> { + log::debug!("[Payjoin] receiver outputs"); + let proposal = proposal + .identify_receiver_outputs(&mut |script| { + let address = + bitcoin::Address::from_script(script, db_conn.network()).map_err(|e| { + ImplementationError::from(Box::new(e) as Box) + })?; + Ok(db_conn + .derivation_index_by_address(&address) + .map(|(index, is_change)| AddrInfo { index, is_change }) + .is_some()) + }) + .save(persister)?; + commit_outputs(proposal, persister, db_conn, desc, secp) +} + +fn commit_outputs( + proposal: Receiver, + persister: &ReceiverPersister, + db_conn: &mut Box, + desc: &descriptors::LianaDescriptor, + secp: &secp256k1::Secp256k1, +) -> Result<(), Box> { + let proposal = proposal.commit_outputs().save(persister)?; + contribute_inputs(proposal, persister, db_conn, desc, secp) +} + +fn contribute_inputs( + proposal: Receiver, + persister: &ReceiverPersister, + db_conn: &mut Box, + desc: &descriptors::LianaDescriptor, + secp: &secp256k1::Secp256k1, +) -> Result<(), Box> { + let coins = db_conn.coins(&[CoinStatus::Confirmed], &[]); + + let mut candidate_inputs_map = HashMap::::new(); + for (outpoint, coin) in coins.iter() { + let txs = db_conn.list_wallet_transactions(&[outpoint.txid]); + let (db_tx, _, _) = txs + .first() + .expect("There should be at least tx in the wallet"); + + let tx = db_tx.clone(); + + let txout = tx.tx_out(outpoint.vout as usize)?.clone(); + + let derived_desc = if coin.is_change { + desc.change_descriptor().derive(coin.derivation_index, secp) + } else { + desc.receive_descriptor() + .derive(coin.derivation_index, secp) + }; + + let txin = TxIn { + previous_output: *outpoint, + sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, + ..Default::default() + }; + + let mut psbtin = Input { + non_witness_utxo: Some(tx.clone()), + witness_utxo: Some(txout.clone()), + ..Default::default() + }; + + derived_desc.update_psbt_in(&mut psbtin); + // TODO: revisit using primary path boolean. Perphaps we should use both paths and take the max. + let worse_case_weight = Weight::from_wu_usize(desc.max_sat_weight(true)) + // Segwit marker + + Weight::from_wu(2) + // Non-witness data size + + Weight::from_non_witness_data_size(txin.base_size() as u64); + + candidate_inputs_map.insert(*outpoint, (*coin, txin, psbtin, worse_case_weight)); + } + + let mut candidate_inputs = candidate_inputs_map + .values() + .map(|(_, txin, psbtin, weight)| { + InputPair::new(txin.clone(), psbtin.clone(), Some(*weight)).unwrap() + }); + log::info!("[Payjoin] Candidate inputs: {:?}", candidate_inputs); + + if candidate_inputs.len() == 0 { + return Err("No candidate inputs".into()); + } + + let selected_input = proposal + .try_preserving_privacy(candidate_inputs.clone()) + .unwrap_or( + candidate_inputs + .next() + .expect("Should have at least one input") + .clone(), + ); + + let proposal = proposal + .contribute_inputs(vec![selected_input])? + .commit_inputs() + .save(persister)?; + + apply_fee_range(proposal, persister, db_conn, secp)?; + Ok(()) +} + +fn apply_fee_range( + proposal: Receiver, + persister: &ReceiverPersister, + db_conn: &mut Box, + secp: &secp256k1::Secp256k1, +) -> Result<(), Box> { + let proposal = proposal.apply_fee_range(None, None).save(persister)?; + let psbt = proposal.psbt_to_sign(); + + db_conn.store_spend(&psbt); + log::info!("[Payjoin] PSBT in the DB..."); + + finalize_proposal(proposal, persister, db_conn, secp)?; + Ok(()) +} + +fn finalize_proposal( + proposal: Receiver, + persister: &ReceiverPersister, + db_conn: &mut Box, + secp: &secp256k1::Secp256k1, +) -> Result<(), Box> { + let psbt = proposal.psbt_to_sign(); + + let txid = psbt.unsigned_tx.compute_txid(); + if let Some(psbt) = db_conn.spend_tx(&txid) { + let mut is_signed = false; + for psbtin in &psbt.inputs { + if !psbtin.partial_sigs.is_empty() { + log::debug!("[Payjoin] PSBT is signed!"); + is_signed = true; + break; + } + } + + if is_signed { + proposal + .finalize_proposal(|_| { + let mut psbt = psbt.clone(); + finalize_psbt(&mut psbt, secp); + Ok(psbt) + }) + .save(persister)?; + } + } + Ok(()) +} + +fn send_payjoin_proposal( + proposal: Receiver, + persister: &ReceiverPersister, + ohttp_relay: &str, +) -> Result<(), Box> { + let (req, ctx) = proposal + .create_post_request(ohttp_relay) + .map_err(|e| format!("Failed to extract request: {e:?}"))?; + + log::info!("[Payjoin] Receiver responding to sender..."); + let resp = post_request(req)?; + proposal + .process_response(resp.bytes()?.as_ref(), ctx) + .save(persister)?; + Ok(()) +} + +/// Extract the payjoin PSBT's txid from a `SessionEvent`, if the event carries one. +/// Covers `AppliedFeeRange` (ProvisionalProposal-era) and `FinalizedProposal` +/// (PayjoinProposal-era / Monitor-era) — these together span every state in +/// which a payjoin PSBT exists, so closed, expired, and monitoring sessions +/// all match by txid. +fn payjoin_txid_from_event(event: &SessionEvent) -> Option { + match event { + SessionEvent::FinalizedProposal(psbt) => Some(psbt.unsigned_tx.compute_txid()), + SessionEvent::AppliedFeeRange(ctx) => { + // PsbtContext::payjoin_psbt is private; round-trip via the + // crate's own serde to extract the field by name. + let v = serde_json::to_value(ctx).ok()?; + let psbt: bitcoin::psbt::Psbt = + serde_json::from_value(v.get("payjoin_psbt")?.clone()).ok()?; + Some(psbt.unsigned_tx.compute_txid()) + } + _ => None, + } +} + +/// Find the receiver session whose payjoin PSBT has the given txid, returning +/// the session id along with its receive-address derivation index. +/// First attempts replay and inspects the current state; if replay fails +/// (e.g. expired) or yields a state whose PSBT isn't directly accessible +/// (Monitor's `psbt_context` is private upstream), falls back to scanning +/// the typed event log so monitoring, closed, and expired sessions still +/// match. +pub(crate) fn find_receiver_session_by_txid( + db: &sync::Arc>, + txid: &bitcoin::Txid, +) -> Option<(SessionId, u32)> { + let sessions = { + let mut db_conn = db.connection(); + db_conn.get_all_receiver_sessions() + }; + for (session_id, derivation_index) in sessions { + let persister = ReceiverPersister::from_id(Arc::new(db.clone()), session_id.clone()); + if let Ok((state, _)) = replay_event_log(&persister) { + let psbt_txid = match &state { + ReceiveSession::ProvisionalProposal(r) => { + Some(r.psbt_to_sign().unsigned_tx.compute_txid()) + } + ReceiveSession::PayjoinProposal(r) => Some(r.psbt().unsigned_tx.compute_txid()), + _ => None, + }; + if psbt_txid.as_ref() == Some(txid) { + return Some((session_id, derivation_index)); + } + } + if let Ok(events) = persister.load() { + if events + .filter_map(|ev| payjoin_txid_from_event(&ev)) + .any(|t| t == *txid) + { + return Some((session_id, derivation_index)); + } + } + } + None +} + +/// Cancel the payjoin receiver session associated with the given txid and return the +/// sender's original (non-payjoin) transaction, if one was ever received. Closes the +/// session via the persister as a terminal transition. +pub(crate) fn cancel_payjoin_for_session( + db: &sync::Arc>, + session_id: SessionId, +) -> Result, Box> { + let persister = ReceiverPersister::from_id(Arc::new(db.clone()), session_id.clone()); + let (state, _) = match replay_event_log(&persister) { + Ok(v) => v, + Err(e) => { + let msg = e.to_string(); + if msg.contains("expired") { + log::info!( + "Payjoin session {:?} expired during cancel, marking closed", + session_id + ); + let _ = persister.close(); + return Err("Payjoin session expired.".into()); + } + return Err(format!("Failed to replay receiver event log: {e:?}").into()); + } + }; + let fallback = match state { + ReceiveSession::Initialized(r) => r.cancel().save(&persister)?, + ReceiveSession::UncheckedOriginalPayload(r) => r.cancel().save(&persister)?, + ReceiveSession::MaybeInputsOwned(r) => r.cancel().save(&persister)?, + ReceiveSession::MaybeInputsSeen(r) => r.cancel().save(&persister)?, + ReceiveSession::OutputsUnknown(r) => r.cancel().save(&persister)?, + ReceiveSession::WantsOutputs(r) => r.cancel().save(&persister)?, + ReceiveSession::WantsInputs(r) => r.cancel().save(&persister)?, + ReceiveSession::WantsFeeRange(r) => r.cancel().save(&persister)?, + ReceiveSession::ProvisionalProposal(r) => r.cancel().save(&persister)?, + ReceiveSession::PayjoinProposal(r) => r.cancel().save(&persister)?, + ReceiveSession::HasReplyableError(r) => r.cancel().save(&persister)?, + ReceiveSession::Monitor(r) => r.cancel().save(&persister)?, + ReceiveSession::Closed(_) => return Err("Payjoin session already closed.".into()), + }; + Ok(fallback) +} + +/// Manually send the payjoin proposal for a given session. If the session is still in the +/// `ProvisionalProposal` state and the stored PSBT is signed, it is finalized first. +pub(crate) fn send_payjoin_for_session( + db: &sync::Arc>, + session_id: SessionId, + secp: &secp256k1::Secp256k1, + ohttp_relay: &str, +) -> Result<(), Box> { + let persister = ReceiverPersister::from_id(Arc::new(db.clone()), session_id.clone()); + let (state, _) = match replay_event_log(&persister) { + Ok(v) => v, + Err(e) => { + let msg = e.to_string(); + if msg.contains("expired") { + log::info!( + "Payjoin session {:?} expired during manual send, marking closed", + session_id + ); + let _ = persister.close(); + return Err("Payjoin session expired.".into()); + } + return Err(format!("Failed to replay receiver event log: {e:?}").into()); + } + }; + let proposal = match state { + ReceiveSession::PayjoinProposal(proposal) => proposal, + ReceiveSession::ProvisionalProposal(proposal) => { + let mut db_conn = db.connection(); + finalize_proposal(proposal, &persister, &mut db_conn, secp)?; + let (state, _) = replay_event_log(&persister) + .map_err(|e| format!("Failed to replay receiver event log: {e:?}"))?; + match state { + ReceiveSession::PayjoinProposal(proposal) => proposal, + _ => return Err("PSBT must be signed before sending the payjoin proposal.".into()), + } + } + _ => return Err("Payjoin session is not ready to send.".into()), + }; + send_payjoin_proposal(proposal, &persister, ohttp_relay) +} + +fn process_receiver_session( + db_conn: &mut Box, + bit: &mut sync::Arc>, + desc: &descriptors::LianaDescriptor, + secp: &secp256k1::Secp256k1, + persister: ReceiverPersister, + ohttp_relay: &str, +) -> Result<(), Box> { + let (state, _) = replay_event_log(&persister) + .map_err(|e| format!("Failed to replay receiver event log: {e:?}"))?; + + match state { + ReceiveSession::Initialized(context) => { + read_from_directory(context, &persister, db_conn, bit, desc, secp, ohttp_relay)?; + } + ReceiveSession::UncheckedOriginalPayload(proposal) => { + check_proposal(proposal, &persister, db_conn, bit, desc, secp)?; + } + ReceiveSession::MaybeInputsOwned(proposal) => { + check_inputs_not_owned(proposal, &persister, db_conn, desc, secp)?; + } + ReceiveSession::MaybeInputsSeen(proposal) => { + check_no_inputs_seen_before(proposal, &persister, db_conn, desc, secp)?; + } + ReceiveSession::OutputsUnknown(proposal) => { + identify_receiver_outputs(proposal, &persister, db_conn, desc, secp)?; + } + ReceiveSession::WantsOutputs(proposal) => { + commit_outputs(proposal, &persister, db_conn, desc, secp)?; + } + ReceiveSession::WantsInputs(proposal) => { + contribute_inputs(proposal, &persister, db_conn, desc, secp)? + } + ReceiveSession::WantsFeeRange(proposal) => { + apply_fee_range(proposal, &persister, db_conn, secp)?; + } + ReceiveSession::ProvisionalProposal(proposal) => { + finalize_proposal(proposal, &persister, db_conn, secp)? + } + ReceiveSession::PayjoinProposal(_) => { + log::debug!("[Payjoin] Payjoin proposal ready; awaiting manual send"); + } + ReceiveSession::Closed(_) | ReceiveSession::HasReplyableError(_) => { + log::info!("Payjoin session completed or expired, marking as closed"); + persister.close()?; + } + ReceiveSession::Monitor(monitor) => { + let bit = bit.clone(); + monitor + .check_payment(|txid| { + let tx_opt = bit + .lock() + .expect("BitcoinInterface mutex poisoned") + .wallet_transaction(&txid) + .map(|(tx, _)| tx); + Ok(tx_opt) + }) + .save(&persister)?; + } + } + Ok(()) +} + +pub(crate) fn payjoin_receiver_check( + db: &sync::Arc>, + bit: &mut sync::Arc>, + desc: &descriptors::LianaDescriptor, + secp: &secp256k1::Secp256k1, + ohttp_relay: &str, +) { + let mut db_conn = db.connection(); + + for session_id in db_conn.get_all_active_receiver_session_ids() { + let persister = ReceiverPersister::from_id(Arc::new(db.clone()), session_id.clone()); + + match replay_event_log(&persister) { + Ok(_) => match process_receiver_session( + &mut db_conn, + bit, + desc, + secp, + persister, + ohttp_relay, + ) { + Ok(_) => (), + Err(e) => { + log::warn!("process_receiver_session(): {}", e); + } + }, + Err(e) => { + let error_str = e.to_string(); + if error_str.contains("expired") { + log::info!( + "Payjoin session {:?} expired, marking as closed", + session_id + ); + if let Err(close_err) = persister.close() { + log::warn!("Failed to close expired payjoin session: {}", close_err); + } + continue; + } + log::warn!("Failed to replay payjoin session {:?}: {}", session_id, e); + } + } + } +} diff --git a/lianad/src/payjoin/types.rs b/lianad/src/payjoin/types.rs new file mode 100644 index 000000000..1d907fa43 --- /dev/null +++ b/lianad/src/payjoin/types.rs @@ -0,0 +1,49 @@ +use payjoin::{receive::v2::ReceiveSession, receive::v2::SessionOutcome as ReceiveSessionOutcome}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +pub enum PayjoinStatus { + Pending, + WaitingToSign, + ReadyToSend, + Monitoring, + Success, + Failed, + Expired, + Unknown, +} + +impl From for PayjoinStatus { + fn from(session: ReceiveSession) -> Self { + match session { + ReceiveSession::Initialized(_) + | ReceiveSession::UncheckedOriginalPayload(_) + | ReceiveSession::MaybeInputsOwned(_) + | ReceiveSession::MaybeInputsSeen(_) + | ReceiveSession::OutputsUnknown(_) + | ReceiveSession::WantsOutputs(_) + | ReceiveSession::WantsInputs(_) + | ReceiveSession::WantsFeeRange(_) => PayjoinStatus::Pending, + ReceiveSession::ProvisionalProposal(_) => PayjoinStatus::WaitingToSign, + ReceiveSession::PayjoinProposal(_) => PayjoinStatus::ReadyToSend, + ReceiveSession::HasReplyableError(_) => PayjoinStatus::Failed, + ReceiveSession::Closed(outcome) => match outcome { + ReceiveSessionOutcome::Success(_) => PayjoinStatus::Success, + _ => PayjoinStatus::Failed, + }, + ReceiveSession::Monitor(_) => PayjoinStatus::Monitoring, + } + } +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct PayjoinInfo { + pub status: PayjoinStatus, + pub bip21: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum PayjoinRole { + Receiver, + Sender, +} diff --git a/lianad/src/testutils.rs b/lianad/src/testutils.rs index da03225a5..993b856e1 100644 --- a/lianad/src/testutils.rs +++ b/lianad/src/testutils.rs @@ -5,9 +5,11 @@ use crate::{ BlockInfo, Coin, CoinStatus, DatabaseConnection, DatabaseInterface, LabelItem, Wallet, }, datadir::DataDirectory, + payjoin::db::SessionId, DaemonControl, DaemonHandle, }; use liana::descriptors; +use payjoin::OhttpKeys; use std::convert::TryInto; use std::{ @@ -143,6 +145,10 @@ impl BitcoinInterface for DummyBitcoind { fn mempool_entry(&self, _: &bitcoin::Txid) -> Option { None } + + fn test_mempool_accept(&self, _rawtxs: Vec) -> Vec { + todo!() + } } struct DummyDbState { @@ -156,6 +162,10 @@ struct DummyDbState { timestamp: u32, rescan_timestamp: Option, last_poll_timestamp: Option, + ohttp_keys: HashMap, + payjoin_receiver_sessions: HashMap)>, + payjoin_sessions_by_derivation: HashMap, + receiver_session_events: HashMap>>, } pub struct DummyDatabase { @@ -191,6 +201,10 @@ impl DummyDatabase { timestamp: now, rescan_timestamp: None, last_poll_timestamp: None, + ohttp_keys: HashMap::new(), + payjoin_receiver_sessions: HashMap::new(), + payjoin_sessions_by_derivation: HashMap::new(), + receiver_session_events: HashMap::new(), })), } } @@ -550,6 +564,86 @@ impl DatabaseConnection for DummyDatabase { fn get_labels_bip329(&mut self, _offset: u32, _limit: u32) -> bip329::Labels { todo!() } + + fn payjoin_get_ohttp_keys(&mut self, ohttp_relay: &str) -> Option<(u32, OhttpKeys)> { + self.db.read().unwrap().ohttp_keys.get(ohttp_relay).cloned() + } + + fn payjoin_save_ohttp_keys(&mut self, ohttp_relay: &str, ohttp_keys: OhttpKeys) { + self.db + .write() + .unwrap() + .ohttp_keys + .insert(ohttp_relay.to_string(), (0, ohttp_keys)); + } + + fn insert_input_seen_before(&mut self, _outpoint: &bitcoin::OutPoint) -> bool { + false + } + + fn get_active_payjoin_receiver_sessions(&mut self) -> Vec<(SessionId, u32)> { + Vec::new() + } + + fn save_receiver_session_event(&mut self, session_id: &SessionId, event: Vec) { + let mut db = self.db.write().unwrap(); + let session_events = db.receiver_session_events.entry(session_id.0).or_default(); + session_events.push(event); + } + + fn load_receiver_session_events(&mut self, session_id: &SessionId) -> Vec> { + self.db + .read() + .unwrap() + .receiver_session_events + .get(&session_id.0) + .cloned() + .unwrap_or_default() + } + + fn save_new_payjoin_receiver_session(&mut self, derivation_index: u32) -> i64 { + let mut db = self.db.write().unwrap(); + let session_id = (db.payjoin_receiver_sessions.len() + 1) as i64; + db.payjoin_receiver_sessions + .insert(session_id, (derivation_index, None)); + db.payjoin_sessions_by_derivation + .insert(derivation_index, session_id); + session_id + } + + fn get_all_active_receiver_session_ids(&mut self) -> Vec { + self.db + .read() + .unwrap() + .payjoin_receiver_sessions + .iter() + .filter(|(_, (_, completed_at))| completed_at.is_none()) + .map(|(id, _)| SessionId::new(*id)) + .collect() + } + + fn get_all_receiver_sessions(&mut self) -> Vec<(SessionId, u32)> { + self.db + .read() + .unwrap() + .payjoin_receiver_sessions + .iter() + .map(|(id, (derivation_index, _))| (SessionId::new(*id), *derivation_index)) + .collect() + } + + fn update_receiver_session_completed_at(&mut self, session_id: &SessionId) { + let mut db = self.db.write().unwrap(); + if let Some(session) = db.payjoin_receiver_sessions.get_mut(&session_id.0) { + let now: u32 = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() + .try_into() + .unwrap(); + session.1 = Some(now); + } + } } pub struct DummyLiana { diff --git a/tests/test_payjoin.py b/tests/test_payjoin.py new file mode 100644 index 000000000..8ec74362f --- /dev/null +++ b/tests/test_payjoin.py @@ -0,0 +1,250 @@ +"""Integration test for the Payjoin v2 receiver flow. + +Drives lianad as the receiver and the upstream `payjoin-cli` binary as the +sender. The test talks to the *production* payjoin directory and OHTTP relay +(the same defaults lianad uses in production: `payjo.in` and the configured +ohttp relay). Network access is therefore required and the test is skipped +when `PAYJOIN_CLI_PATH` is not set. + +Run with: + PAYJOIN_CLI_PATH=$(which payjoin-cli) pytest tests/test_payjoin.py +""" + +import logging +import os +import subprocess + +import pytest + +from bip32.utils import coincurve + +from fixtures import * +from test_framework.bitcoind import BitcoindRpcInterface +from test_framework.serializations import ( + PSBT, + PSBT_IN_BIP32_DERIVATION, + PSBT_IN_PARTIAL_SIG, + PSBT_IN_WITNESS_SCRIPT, + sighash_all_witness, +) +from test_framework.utils import USE_TAPROOT, wait_for + + +PAYJOIN_CLI_PATH = os.getenv("PAYJOIN_CLI_PATH") +# Long enough to cover the v2 directory poll cadence + OHTTP roundtrips. +PAYJOIN_TIMEOUT = int(os.getenv("PAYJOIN_TIMEOUT", 60)) +SENDER_WALLET = "payjoin-sender" + + +pytestmark = [ + pytest.mark.skipif( + PAYJOIN_CLI_PATH is None, + reason="payjoin-cli not configured (set PAYJOIN_CLI_PATH)", + ), + pytest.mark.skipif( + USE_TAPROOT, + reason="payjoin integration test only covers wsh descriptors for now", + ), +] + + +def _sign_receiver_inputs(psbt, hd): + """Sign in place the PSBT inputs the receiver owns. + + The payjoin PSBT contains the sender's external input(s) which we cannot + (and must not) sign. We must sign in place on the full PSBT — segwit + sighashes commit to hashPrevouts / hashSequence / hashOutputs over the + full transaction, so signing a stripped copy would yield invalid sigs. + Receiver-owned inputs are the ones lianad populated via `update_psbt_in` + (witness script + our BIP32 derivation present). + """ + signed_any = False + for i, psbt_in in enumerate(psbt.i): + if PSBT_IN_WITNESS_SCRIPT not in psbt_in.map: + continue + if PSBT_IN_BIP32_DERIVATION not in psbt_in.map: + continue + + fing_der = next(iter(psbt_in.map[PSBT_IN_BIP32_DERIVATION].values())) + raw_der_path = fing_der[4:] + der_path = [ + int.from_bytes(raw_der_path[j : j + 4], byteorder="little", signed=True) + for j in range(0, len(raw_der_path), 4) + ] + script_code = psbt_in.map[PSBT_IN_WITNESS_SCRIPT] + sighash = sighash_all_witness(script_code, psbt, i) + privkey = coincurve.PrivateKey(hd.get_privkey_from_path(der_path)) + pubkey = privkey.public_key.format() + if pubkey not in psbt_in.map[PSBT_IN_BIP32_DERIVATION]: + # Not one of our keys — leave it alone. + continue + sig = privkey.sign(sighash, hasher=None) + b"\x01" + psbt_in.map.setdefault(PSBT_IN_PARTIAL_SIG, {})[pubkey] = sig + signed_any = True + + assert signed_any, "no receiver-owned PSBT input found to sign" + return psbt + + +def _payjoin_cli_args(datadir, bitcoind, wallet): + """Build the global payjoin-cli flags (everything before the subcommand). + + payjoin-cli has no config file: bitcoind RPC, db path, OHTTP relay and + pj directory are all passed as CLI flags. Match the lianad defaults so + this test exercises the same prod payjoin infra. + """ + cookie = os.path.join(bitcoind.bitcoin_dir, "regtest", ".cookie") + db_path = os.path.join(datadir, "payjoin.sqlite") + rpchost = f"http://127.0.0.1:{bitcoind.rpcport}/wallet/{wallet}" + return [ + "--bip77", + "-r", + rpchost, + "-c", + cookie, + "-d", + db_path, + "--pj-directory", + "https://payjo.in", + "--ohttp-relays", + "https://pj.bobspacebkk.com", + ] + + +def _fund_lianad(lianad, bitcoind, amount_btc=0.5): + addr = lianad.rpc.getnewaddress()["address"] + txid = bitcoind.rpc.sendtoaddress(addr, amount_btc) + bitcoind.generate_block(1, wait_for_mempool=txid) + wait_for(lambda: len(lianad.rpc.listcoins(["confirmed"])["coins"]) >= 1) + + +def _fund_sender(bitcoind, wallet_rpc, amount_btc=1.0): + addr = wallet_rpc.getnewaddress() + txid = bitcoind.rpc.sendtoaddress(addr, amount_btc) + bitcoind.generate_block(1, wait_for_mempool=txid) + wait_for(lambda: wallet_rpc.getbalance() >= amount_btc) + + +def _new_sender_wallet(bitcoind): + bitcoind.node_rpc.createwallet(SENDER_WALLET, False, False, "", False, True, True) + return BitcoindRpcInterface( + bitcoind.bitcoin_dir, "regtest", bitcoind.rpcport, wallet=SENDER_WALLET + ) + + +def test_payjoin_receive(lianad, bitcoind, directory): + """End-to-end payjoin receive against the public directory + ohttp relay.""" + + # 1. Fund both wallets so each side can contribute an input. + _fund_lianad(lianad, bitcoind) + sender_rpc = _new_sender_wallet(bitcoind) + _fund_sender(bitcoind, sender_rpc, amount_btc=1.0) + + # 2. Open a receiver session and grab the BIP21 to hand to the sender. + res = lianad.rpc.receivepayjoin() + bip21 = res["bip21"] + assert bip21 and bip21.lower().startswith("bitcoin:") + receiver_address = res["address"] + receiver_derivation_index = res["derivation_index"] + + # The receiver-side library does not embed an amount in the BIP21 URI; the + # sender (`payjoin-cli send`) requires one to build the original PSBT. Tack + # it on as an extra query parameter — the URI always already has a `?pj=` + # part so we use `&` here. + send_amount_btc = 0.0001 + bip21 = f"{bip21}&amount={send_amount_btc}" + + # 3. Spawn payjoin-cli as the sender. `send` polls the directory until the + # receiver returns a finalized proposal, then broadcasts. + cli_datadir = os.path.join(directory, "payjoin-cli") + os.makedirs(cli_datadir, exist_ok=True) + cli_args = _payjoin_cli_args(cli_datadir, bitcoind, SENDER_WALLET) + + log_path = os.path.join(cli_datadir, "payjoin-cli.log") + log_file = open(log_path, "w") + cli = subprocess.Popen( + [PAYJOIN_CLI_PATH, *cli_args, "send", bip21, "--fee-rate", "2"], + cwd=cli_datadir, + stdout=log_file, + stderr=subprocess.STDOUT, + ) + + try: + # 4. Wait for lianad to ingest the original payload, build the payjoin + # PSBT and store it in its spend DB. + def _payjoin_psbt(): + spends = lianad.rpc.listspendtxs().get("spend_txs", []) + return spends[0] if spends else None + + wait_for(lambda: _payjoin_psbt() is not None, timeout=PAYJOIN_TIMEOUT) + spend_entry = _payjoin_psbt() + psbt = PSBT.from_base64(spend_entry["psbt"]) + txid = psbt.tx.txid().hex() + + # Status should be `WaitingToSign` once the proposal is in DB. + wait_for( + lambda: lianad.rpc.getpayjoininfo(txid) == "WaitingToSign", + timeout=PAYJOIN_TIMEOUT, + ) + + # 5. Sign the receiver's input(s) and persist the signed PSBT. + signed_psbt = _sign_receiver_inputs(psbt, lianad.signer.primary_hd) + lianad.rpc.updatespend(signed_psbt.to_base64()) + + # 6. Send the proposal back to the sender via the directory. + lianad.rpc.sendpayjoinproposal(txid) + + # 7. The sender should now broadcast the payjoin tx; wait for the + # mempool to see it. + wait_for( + lambda: txid in bitcoind.rpc.getrawmempool(), + timeout=PAYJOIN_TIMEOUT, + debug_fn=lambda: f"waiting for {txid} in mempool, have {bitcoind.rpc.getrawmempool()}", + ) + bitcoind.generate_block(1, wait_for_mempool=txid) + + # 8. Receiver should now hold a confirmed coin from the payjoin tx. + # Match by outpoint.txid rather than address text to avoid any + # encoding mismatch, and assert the derivation index lines up with + # the receiver session. + def _payjoin_coin(): + for c in lianad.rpc.listcoins(["confirmed"])["coins"]: + outpoint_txid = c["outpoint"].split(":")[0] + if outpoint_txid == txid and c["block_height"] is not None: + return c + return None + + wait_for( + lambda: _payjoin_coin() is not None, + timeout=PAYJOIN_TIMEOUT, + debug_fn=lambda: f"all coins: {lianad.rpc.listcoins()['coins']}", + ) + coin = _payjoin_coin() + assert coin["derivation_index"] == receiver_derivation_index, ( + coin["derivation_index"], + receiver_derivation_index, + ) + assert coin["address"] == receiver_address, (coin["address"], receiver_address) + + # 9. After `sendpayjoinproposal` the session is in the payjoin + # crate's `Monitor` state; the bitcoin poller drives + # `check_payment`, which walks Monitor -> Closed(Success) once + # the payjoin tx is visible to the wallet (it is, since step 8 + # asserted the receiver-owned coin from this txid is confirmed). + wait_for( + lambda: lianad.rpc.getpayjoininfo(txid) + not in ("Pending", "WaitingToSign", "ReadyToSend"), + timeout=PAYJOIN_TIMEOUT, + debug_fn=lambda: f"payjoin status: {lianad.rpc.getpayjoininfo(txid)}", + ) + finally: + if cli.poll() is None: + cli.terminate() + try: + cli.wait(timeout=10) + except subprocess.TimeoutExpired: + cli.kill() + log_file.close() + if cli.returncode not in (0, None): + with open(log_path) as f: + logging.error("payjoin-cli output:\n%s", f.read())