diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index be80130dbe..2b0d836608 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -162,6 +162,8 @@ jobs: continue-on-error: true - name: Build nextest archive run: nix build -L .#itest-archive + - name: Build Payjoin nextest archive + run: nix build -L .#itest-archive-payjoin - name: Build harness binaries (warm cache) run: | nix build -L .#start-fake-mint @@ -315,7 +317,7 @@ jobs: continue-on-error: true - name: Resolve nextest archive path id: archive - run: echo "path=$(nix build .#itest-archive --print-out-paths --no-link)/itest-archive.tar.zst" >> $GITHUB_OUTPUT + run: echo "path=$(nix build .#itest-archive-payjoin --print-out-paths --no-link)/itest-archive.tar.zst" >> $GITHUB_OUTPUT - name: Test run: nix develop -i -L .#regtest --command bash -c "export TMPDIR=$(mktemp -d -p $PWD) && export CDK_ITEST_ARCHIVE='${{ steps.archive.outputs.path }}' && just itest-onchain ${{ matrix.database }}" diff --git a/Cargo.lock b/Cargo.lock index e3c93b1cbb..57a41a4113 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,16 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[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" @@ -27,6 +37,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" @@ -39,17 +61,31 @@ dependencies = [ "zeroize", ] +[[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]] name = "aes-gcm" version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" dependencies = [ - "aead", - "aes", + "aead 0.5.2", + "aes 0.8.4", "cipher 0.4.4", - "ctr", - "ghash", + "ctr 0.9.2", + "ghash 0.5.1", "subtle", ] @@ -818,6 +854,25 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f" +[[package]] +name = "bhttp" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ef06386f8f092c3419e153a657396e53cafbb901de445a5c54d96ab2ff8c7b2" +dependencies = [ + "thiserror 1.0.69", + "url", +] + +[[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 = "bincode" version = "2.0.1" @@ -884,6 +939,25 @@ dependencies = [ "serde", ] +[[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 0.29.1", + "sha2 0.10.9", + "subtle", + "zeroize", +] + [[package]] name = "bitcoin-internals" version = "0.2.1" @@ -899,6 +973,29 @@ version = "0.1.100" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "11301df0b06f22dea7bb1916403fdd88a371031e495c49b8f96931b28189e175" +[[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.6", + "serde", + "serde_derive", + "sha2 0.9.9", + "thiserror 1.0.69", + "toml 0.5.11", +] + [[package]] name = "bitcoin-payment-instructions" version = "0.7.0" @@ -940,6 +1037,16 @@ dependencies = [ "serde", ] +[[package]] +name = "bitcoin_uri" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0a228e083d1702f83389b0ac71eb70078dc8d7fcbb6cde864d1cbca145f5cc" +dependencies = [ + "bitcoin 0.32.100", + "percent-encoding-rfc3986", +] + [[package]] name = "bitcoincore-rpc" version = "0.19.0" @@ -972,9 +1079,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.11.1" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" dependencies = [ "serde_core", ] @@ -1002,6 +1109,15 @@ dependencies = [ "syn 2.0.117", ] +[[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" @@ -1289,7 +1405,7 @@ dependencies = [ "maud", "moka", "paste", - "redis", + "redis 0.31.0", "serde", "serde_json", "sha2 0.10.9", @@ -1309,6 +1425,8 @@ dependencies = [ "cdk-common", "cdk-sqlite", "futures", + "payjoin 0.25.0", + "reqwest", "serde", "serde_json", "tempfile", @@ -1317,6 +1435,7 @@ dependencies = [ "tokio-stream", "tokio-util", "tracing", + "url", "uuid", ] @@ -1536,8 +1655,14 @@ dependencies = [ "ldk-node", "lightning", "lightning-invoice", + "ohttp-relay", "once_cell", + "payjoin 0.25.0", + "payjoin-directory", "rand 0.9.4", + "rcgen", + "reqwest", + "rustls 0.22.4", "serde", "serde_json", "tokio", @@ -1547,6 +1672,7 @@ dependencies = [ "tower-service", "tracing", "tracing-subscriber", + "url", "uuid", "web-time", ] @@ -1607,7 +1733,7 @@ dependencies = [ "hyper 1.10.1", "hyper-rustls 0.27.9", "hyper-util", - "prost 0.14.3", + "prost 0.14.4", "rustls 0.23.40", "rustls-pemfile 2.2.0", "serde_json", @@ -1632,7 +1758,7 @@ dependencies = [ "cdk-sqlite", "clap", "home", - "prost 0.14.3", + "prost 0.14.4", "rustls 0.23.40", "serde", "serde_json", @@ -1727,7 +1853,7 @@ dependencies = [ "hex", "lightning", "lightning-invoice", - "prost 0.14.3", + "prost 0.14.4", "rand 0.9.4", "serde", "serde_json", @@ -1816,7 +1942,7 @@ dependencies = [ "clap", "getrandom 0.2.17", "home", - "prost 0.14.3", + "prost 0.14.4", "rustls 0.23.40", "thiserror 2.0.18", "tokio", @@ -1870,7 +1996,7 @@ dependencies = [ name = "cdk-supabase" version = "0.17.0" dependencies = [ - "aes-gcm", + "aes-gcm 0.10.3", "async-trait", "bitcoin 0.32.100", "cdk-common", @@ -1902,6 +2028,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" @@ -1930,24 +2068,37 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b4b0fc281743d80256607bd65e8beedc42cb0787ea119c85b81b4c0eab85e5f" +[[package]] +name = "chacha20poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1580317203210c517b6d44794abfbe600698276db18127e37ad3e69bf5e848e5" +dependencies = [ + "aead 0.4.3", + "chacha20 0.7.1", + "cipher 0.3.0", + "poly1305 0.7.2", + "zeroize", +] + [[package]] name = "chacha20poly1305" version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" dependencies = [ - "aead", + "aead 0.5.2", "chacha20 0.9.1", "cipher 0.4.4", - "poly1305", + "poly1305 0.8.0", "zeroize", ] [[package]] name = "chrono" -version = "0.4.44" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", "js-sys", @@ -1990,6 +2141,15 @@ dependencies = [ "half", ] +[[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" @@ -2268,6 +2428,15 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[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" @@ -2421,6 +2590,25 @@ dependencies = [ "hybrid-array", ] +[[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 = "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" @@ -2661,6 +2849,15 @@ dependencies = [ "syn 2.0.117", ] +[[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" @@ -3373,6 +3570,16 @@ dependencies = [ "wasip3", ] +[[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" @@ -3380,7 +3587,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" dependencies = [ "opaque-debug", - "polyval", + "polyval 0.6.2", ] [[package]] @@ -3541,9 +3748,9 @@ dependencies = [ [[package]] name = "hashlink" -version = "0.11.0" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea0b22561a9c04a7cb1a302c013e0259cd3b4bb619f145b32f72b8b4bcbed230" +checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f" dependencies = [ "hashbrown 0.16.1", ] @@ -3603,7 +3810,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8a6fe56c0038198998a6f217ca4e7ef3a5e51f46163bd6dd60b5c71ca6c6502" dependencies = [ "async-trait", - "bitflags 2.11.1", + "bitflags 2.13.0", "cfg-if", "data-encoding", "enum-as-inner", @@ -3645,6 +3852,16 @@ dependencies = [ "tracing", ] +[[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" @@ -3654,6 +3871,16 @@ 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" @@ -3854,6 +4081,26 @@ dependencies = [ "tokio-rustls 0.24.1", ] +[[package]] +name = "hyper-rustls" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0bea761b46ae2b24eb4aef630d8d1c398157b6fc29e6350ecf090a0b70c952c" +dependencies = [ + "futures-util", + "http 1.4.1", + "hyper 1.10.1", + "hyper-util", + "log", + "rustls 0.22.4", + "rustls-native-certs 0.7.3", + "rustls-pki-types", + "tokio", + "tokio-rustls 0.25.0", + "tower-service", + "webpki-roots 0.26.11", +] + [[package]] name = "hyper-rustls" version = "0.27.9" @@ -3864,7 +4111,7 @@ dependencies = [ "hyper 1.10.1", "hyper-util", "rustls 0.23.40", - "rustls-native-certs", + "rustls-native-certs 0.8.4", "tokio", "tokio-rustls 0.26.4", "tower-service", @@ -3896,6 +4143,21 @@ dependencies = [ "tower-service", ] +[[package]] +name = "hyper-tungstenite" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a343d17fe7885302ed7252767dc7bb83609a874b6ff581142241ec4b73957ad" +dependencies = [ + "http-body-util", + "hyper 1.10.1", + "hyper-util", + "pin-project-lite", + "tokio", + "tokio-tungstenite 0.21.0", + "tungstenite 0.21.0", +] + [[package]] name = "hyper-util" version = "0.1.20" @@ -4572,9 +4834,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.30" +version = "0.4.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" +checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" [[package]] name = "lru" @@ -4840,14 +5102,14 @@ version = "0.44.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08d8f0fe13526800300a36bf3b7c5f752e62e32ab81c74a8e5caa2865708625a" dependencies = [ - "aes", + "aes 0.8.4", "base64 0.22.1", "bech32 0.11.1", "bip39", "bitcoin_hashes 0.14.100", "cbc", "chacha20 0.9.1", - "chacha20poly1305", + "chacha20poly1305 0.10.1", "getrandom 0.2.17", "hex", "instant", @@ -5031,7 +5293,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", ] [[package]] @@ -5052,6 +5314,29 @@ dependencies = [ "memchr", ] +[[package]] +name = "ohttp-relay" +version = "0.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e766a4a358e64f0ea35b79eae3333052b8164ffc20f538ffb7c6861250bbb101" +dependencies = [ + "byteorder", + "bytes", + "futures", + "http 1.4.1", + "http-body-util", + "hyper 1.10.1", + "hyper-rustls 0.26.0", + "hyper-tungstenite", + "hyper-util", + "rustls 0.22.4", + "tokio", + "tokio-tungstenite 0.21.0", + "tokio-util", + "tracing", + "tracing-subscriber", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -5086,7 +5371,7 @@ version = "0.10.80" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "cfg-if", "foreign-types", "libc", @@ -5244,6 +5529,61 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" +[[package]] +name = "payjoin" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "764bdfb07333876342956b942c3b61214e9cca29bc17bf6895bb023763927ac2" +dependencies = [ + "bitcoin 0.32.100", + "log", +] + +[[package]] +name = "payjoin" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "844f2404db4ae16c4062a53da2c73812c7a130abf612149c06650a36e34d0a35" +dependencies = [ + "bhttp 0.6.1", + "bitcoin 0.32.100", + "bitcoin-hpke", + "bitcoin-ohttp", + "bitcoin_uri", + "http 1.4.1", + "reqwest", + "rustls 0.23.40", + "serde", + "serde_json", + "tracing", + "url", + "web-time", +] + +[[package]] +name = "payjoin-directory" +version = "0.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80ff7730a05bacb74c1e0b98e037f67161ddcb869c654bdc18bd0fc57a699f50" +dependencies = [ + "anyhow", + "bhttp 0.5.1", + "bitcoin 0.32.100", + "bitcoin-ohttp", + "futures", + "http-body-util", + "hyper 1.10.1", + "hyper-rustls 0.26.0", + "hyper-util", + "payjoin 0.24.0", + "redis 0.23.5", + "rustls 0.22.4", + "tokio", + "tokio-rustls 0.25.0", + "tracing", + "tracing-subscriber", +] + [[package]] name = "pbkdf2" version = "0.12.2" @@ -5518,6 +5858,17 @@ dependencies = [ "plotters-backend", ] +[[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" @@ -5526,7 +5877,19 @@ checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" dependencies = [ "cpufeatures 0.2.17", "opaque-debug", - "universal-hash", + "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 0.4.0", ] [[package]] @@ -5538,7 +5901,7 @@ dependencies = [ "cfg-if", "cpufeatures 0.2.17", "opaque-debug", - "universal-hash", + "universal-hash 0.5.1", ] [[package]] @@ -5753,12 +6116,12 @@ dependencies = [ [[package]] name = "prost" -version = "0.14.3" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568" +checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" dependencies = [ "bytes", - "prost-derive 0.14.3", + "prost-derive 0.14.4", ] [[package]] @@ -5806,9 +6169,9 @@ dependencies = [ [[package]] name = "prost-build" -version = "0.14.3" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "343d3bd7056eda839b03204e68deff7d1b13aba7af2b2fd16890697274262ee7" +checksum = "03da047801ff44bb6a4d407d4860c05fd70bb81714e6b2f3812603d5b145b042" dependencies = [ "heck 0.5.0", "itertools 0.14.0", @@ -5816,8 +6179,8 @@ dependencies = [ "multimap 0.10.1", "petgraph 0.8.3", "prettyplease 0.2.37", - "prost 0.14.3", - "prost-types 0.14.3", + "prost 0.14.4", + "prost-types 0.14.4", "pulldown-cmark", "pulldown-cmark-to-cmark", "regex", @@ -5853,9 +6216,9 @@ dependencies = [ [[package]] name = "prost-derive" -version = "0.14.3" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" +checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" dependencies = [ "anyhow", "itertools 0.14.0", @@ -5884,11 +6247,11 @@ dependencies = [ [[package]] name = "prost-types" -version = "0.14.3" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8991c4cbdb8bc5b11f0b074ffe286c30e523de90fee5ba8132f1399f23cb3dd7" +checksum = "f94967dc7688f3054c7fac87473ffae4cc4c3904800e2d9f5b857246d8963b0a" dependencies = [ - "prost 0.14.3", + "prost 0.14.4", ] [[package]] @@ -5903,7 +6266,7 @@ version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "memchr", "unicase", ] @@ -6113,6 +6476,18 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "rcgen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52c4f3084aa3bc7dfbba4eff4fab2a54db4324965d8872ab933565e6fbd83bc6" +dependencies = [ + "pem 3.0.6", + "ring 0.16.20", + "time", + "yasna", +] + [[package]] name = "redb" version = "3.1.3" @@ -6122,6 +6497,27 @@ dependencies = [ "libc", ] +[[package]] +name = "redis" +version = "0.23.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44e3fd704e6060c496523638d371b2db66d07d5f9692d7ce244b39723491ebad" +dependencies = [ + "async-trait", + "bytes", + "combine", + "futures-util", + "itoa", + "percent-encoding", + "pin-project-lite", + "ryu", + "sha1_smol", + "socket2 0.4.10", + "tokio", + "tokio-util", + "url", +] + [[package]] name = "redis" version = "0.31.0" @@ -6144,7 +6540,7 @@ dependencies = [ "pin-project-lite", "rand 0.9.4", "rustls 0.23.40", - "rustls-native-certs", + "rustls-native-certs 0.8.4", "ryu", "socket2 0.5.10", "tokio", @@ -6159,7 +6555,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", ] [[package]] @@ -6262,7 +6658,7 @@ dependencies = [ "pin-project-lite", "quinn", "rustls 0.23.40", - "rustls-native-certs", + "rustls-native-certs 0.8.4", "rustls-pki-types", "serde", "serde_json", @@ -6337,7 +6733,7 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4147b952f3f819eca0e99527022f7d6a8d05f111aeb0a62960c74eb283bec8fc" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "once_cell", "serde", "serde_derive", @@ -6372,7 +6768,7 @@ version = "0.31.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "fallible-iterator 0.3.0", "fallible-streaming-iterator", "hashlink 0.9.1", @@ -6452,7 +6848,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "errno", "libc", "linux-raw-sys 0.4.15", @@ -6465,7 +6861,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "errno", "libc", "linux-raw-sys 0.12.1", @@ -6484,6 +6880,20 @@ dependencies = [ "sct", ] +[[package]] +name = "rustls" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf4ef73721ac7bcd79b2b315da7779d8fc09718c6b3d2d1b2d94850eb8c18432" +dependencies = [ + "log", + "ring 0.17.14", + "rustls-pki-types", + "rustls-webpki 0.102.8", + "subtle", + "zeroize", +] + [[package]] name = "rustls" version = "0.23.40" @@ -6499,6 +6909,19 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5bfb394eeed242e909609f56089eecfe5fda225042e8b171791b9c95f5931e5" +dependencies = [ + "openssl-probe 0.1.6", + "rustls-pemfile 2.2.0", + "rustls-pki-types", + "schannel", + "security-framework 2.11.1", +] + [[package]] name = "rustls-native-certs" version = "0.8.4" @@ -6549,6 +6972,17 @@ dependencies = [ "untrusted 0.9.0", ] +[[package]] +name = "rustls-webpki" +version = "0.102.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9" +dependencies = [ + "ring 0.17.14", + "rustls-pki-types", + "untrusted 0.9.0", +] + [[package]] name = "rustls-webpki" version = "0.103.13" @@ -6777,7 +7211,7 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "core-foundation 0.9.4", "core-foundation-sys", "libc", @@ -6790,7 +7224,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -6941,9 +7375,9 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.20.0" +version = "3.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e72c1c2cb7b223fafb600a619537a871c2818583d619401b785e7c0b746ccde2" +checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" dependencies = [ "base64 0.22.1", "bs58", @@ -6961,9 +7395,9 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.20.0" +version = "3.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b90c488738ecb4fb0262f41f43bc40efc5868d9fb744319ddf5f5317f417bfac" +checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" dependencies = [ "darling 0.23.0", "proc-macro2", @@ -6982,6 +7416,25 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + +[[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]] name = "sha2" version = "0.10.9" @@ -7121,6 +7574,16 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c" +[[package]] +name = "socket2" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7916fc008ca5542385b89a3d3ce689953c143e9304a9bf8beec1de48994c0d" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "socket2" version = "0.5.10" @@ -7705,6 +8168,17 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-rustls" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "775e0c0f0adb3a2f22a00c4745d728b479985fc15ee7ca6a2608388c5569860f" +dependencies = [ + "rustls 0.22.4", + "rustls-pki-types", + "tokio", +] + [[package]] name = "tokio-rustls" version = "0.26.4" @@ -7739,6 +8213,18 @@ dependencies = [ "tokio-util", ] +[[package]] +name = "tokio-tungstenite" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c83b561d025642014097b66e6c1bb422783339e0909e4429cde4749d1990bc38" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite 0.21.0", +] + [[package]] name = "tokio-tungstenite" version = "0.26.2" @@ -7748,7 +8234,7 @@ dependencies = [ "futures-util", "log", "rustls 0.23.40", - "rustls-native-certs", + "rustls-native-certs 0.8.4", "rustls-pki-types", "tokio", "tokio-rustls 0.26.4", @@ -7782,6 +8268,15 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234" +dependencies = [ + "serde", +] + [[package]] name = "toml" version = "0.8.23" @@ -7999,7 +8494,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0" dependencies = [ "bytes", - "prost 0.14.3", + "prost 0.14.4", "tonic 0.14.6", ] @@ -8011,8 +8506,8 @@ checksum = "654e5643eff75d7f8c99197ce1440ed19a3474eada74c12bbac488b2cafdae27" dependencies = [ "prettyplease 0.2.37", "proc-macro2", - "prost-build 0.14.3", - "prost-types 0.14.3", + "prost-build 0.14.4", + "prost-types 0.14.4", "quote", "syn 2.0.117", "tempfile", @@ -8068,7 +8563,7 @@ version = "0.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38e51af4f36e96fe61833a4d94cf50c252a9958c162c0db2fa7a92ee2f0985c3" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "bytes", "caret", "derive_more", @@ -8456,9 +8951,9 @@ version = "0.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a050d7145f3d1209d579f2d62d0da3eeb25e269d5a98ec1ef3c11c72d8eb174" dependencies = [ - "aes", + "aes 0.8.4", "base64ct", - "ctr", + "ctr 0.9.2", "curve25519-dalek", "derive_more", "digest 0.10.7", @@ -8503,7 +8998,7 @@ version = "0.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "228eeb225054455c0545a4d5e06d188790e5bd85129eefb9b24c86cb18f22ce2" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "derive_more", "futures", "humantime 2.3.0", @@ -8533,7 +9028,7 @@ checksum = "8b34733b319ff6aa7a146973647c00120d111aa77da221d28309bacf144e3239" dependencies = [ "amplify", "base64ct", - "bitflags 2.11.1", + "bitflags 2.13.0", "cipher 0.4.4", "derive_builder_fork_arti", "derive_more", @@ -8602,7 +9097,7 @@ dependencies = [ "digest 0.10.7", "educe", "futures", - "hkdf", + "hkdf 0.12.4", "hmac 0.12.1", "pin-project", "rand 0.8.6", @@ -8778,7 +9273,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ "async-compression", - "bitflags 2.11.1", + "bitflags 2.13.0", "bytes", "futures-core", "futures-util", @@ -8914,6 +9409,25 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tungstenite" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ef1a641ea34f399a848dea702823bbecfb4c486f911735368f1f137cb8257e1" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http 1.4.1", + "httparse", + "log", + "rand 0.8.6", + "sha1", + "thiserror 1.0.69", + "url", + "utf-8", +] + [[package]] name = "tungstenite" version = "0.26.2" @@ -9200,6 +9714,16 @@ dependencies = [ "weedle2", ] +[[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" @@ -9559,7 +10083,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "hashbrown 0.15.5", "indexmap 2.14.0", "semver", @@ -10141,7 +10665,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags 2.11.1", + "bitflags 2.13.0", "indexmap 2.14.0", "log", "serde", @@ -10216,14 +10740,23 @@ checksum = "631a50d867fafb7093e709d75aaee9e0e0d5deb934021fcea25ac2fe09edc51e" dependencies = [ "arraydeque", "encoding_rs", - "hashlink 0.11.0", + "hashlink 0.11.1", +] + +[[package]] +name = "yasna" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" +dependencies = [ + "time", ] [[package]] name = "yoke" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" dependencies = [ "stable_deref_trait", "yoke-derive", diff --git a/Cargo.lock.msrv b/Cargo.lock.msrv index 41ebba377f..dab2c79b29 100644 --- a/Cargo.lock.msrv +++ b/Cargo.lock.msrv @@ -17,6 +17,16 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[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" @@ -27,6 +37,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" @@ -39,17 +61,31 @@ dependencies = [ "zeroize", ] +[[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]] name = "aes-gcm" version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" dependencies = [ - "aead", - "aes", + "aead 0.5.2", + "aes 0.8.4", "cipher 0.4.4", - "ctr", - "ghash", + "ctr 0.9.2", + "ghash 0.5.1", "subtle", ] @@ -818,6 +854,25 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f" +[[package]] +name = "bhttp" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ef06386f8f092c3419e153a657396e53cafbb901de445a5c54d96ab2ff8c7b2" +dependencies = [ + "thiserror 1.0.69", + "url", +] + +[[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 = "bincode" version = "2.0.1" @@ -884,6 +939,25 @@ dependencies = [ "serde", ] +[[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 0.29.1", + "sha2 0.10.9", + "subtle", + "zeroize", +] + [[package]] name = "bitcoin-internals" version = "0.2.1" @@ -899,6 +973,29 @@ version = "0.1.100" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "11301df0b06f22dea7bb1916403fdd88a371031e495c49b8f96931b28189e175" +[[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.6", + "serde", + "serde_derive", + "sha2 0.9.9", + "thiserror 1.0.69", + "toml 0.5.11", +] + [[package]] name = "bitcoin-payment-instructions" version = "0.7.0" @@ -940,6 +1037,16 @@ dependencies = [ "serde", ] +[[package]] +name = "bitcoin_uri" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0a228e083d1702f83389b0ac71eb70078dc8d7fcbb6cde864d1cbca145f5cc" +dependencies = [ + "bitcoin 0.32.100", + "percent-encoding-rfc3986", +] + [[package]] name = "bitcoincore-rpc" version = "0.19.0" @@ -972,9 +1079,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.11.1" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" dependencies = [ "serde_core", ] @@ -1002,6 +1109,15 @@ dependencies = [ "syn 2.0.117", ] +[[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" @@ -1289,7 +1405,7 @@ dependencies = [ "maud", "moka", "paste", - "redis", + "redis 0.31.0", "serde", "serde_json", "sha2 0.10.9", @@ -1309,6 +1425,8 @@ dependencies = [ "cdk-common", "cdk-sqlite", "futures", + "payjoin 0.25.0", + "reqwest", "serde", "serde_json", "tempfile", @@ -1317,6 +1435,7 @@ dependencies = [ "tokio-stream", "tokio-util", "tracing", + "url", "uuid", ] @@ -1536,8 +1655,14 @@ dependencies = [ "ldk-node", "lightning", "lightning-invoice", + "ohttp-relay", "once_cell", + "payjoin 0.25.0", + "payjoin-directory", "rand 0.9.4", + "rcgen", + "reqwest", + "rustls 0.22.4", "serde", "serde_json", "tokio", @@ -1547,6 +1672,7 @@ dependencies = [ "tower-service", "tracing", "tracing-subscriber", + "url", "uuid", "web-time", ] @@ -1607,7 +1733,7 @@ dependencies = [ "hyper 1.10.1", "hyper-rustls 0.27.9", "hyper-util", - "prost 0.14.3", + "prost 0.14.4", "rustls 0.23.40", "rustls-pemfile 2.2.0", "serde_json", @@ -1632,7 +1758,7 @@ dependencies = [ "cdk-sqlite", "clap", "home", - "prost 0.14.3", + "prost 0.14.4", "rustls 0.23.40", "serde", "serde_json", @@ -1727,7 +1853,7 @@ dependencies = [ "hex", "lightning", "lightning-invoice", - "prost 0.14.3", + "prost 0.14.4", "rand 0.9.4", "serde", "serde_json", @@ -1816,7 +1942,7 @@ dependencies = [ "clap", "getrandom 0.2.17", "home", - "prost 0.14.3", + "prost 0.14.4", "rustls 0.23.40", "thiserror 2.0.18", "tokio", @@ -1870,7 +1996,7 @@ dependencies = [ name = "cdk-supabase" version = "0.17.0" dependencies = [ - "aes-gcm", + "aes-gcm 0.10.3", "async-trait", "bitcoin 0.32.100", "cdk-common", @@ -1902,6 +2028,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" @@ -1930,24 +2068,37 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b4b0fc281743d80256607bd65e8beedc42cb0787ea119c85b81b4c0eab85e5f" +[[package]] +name = "chacha20poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1580317203210c517b6d44794abfbe600698276db18127e37ad3e69bf5e848e5" +dependencies = [ + "aead 0.4.3", + "chacha20 0.7.1", + "cipher 0.3.0", + "poly1305 0.7.2", + "zeroize", +] + [[package]] name = "chacha20poly1305" version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" dependencies = [ - "aead", + "aead 0.5.2", "chacha20 0.9.1", "cipher 0.4.4", - "poly1305", + "poly1305 0.8.0", "zeroize", ] [[package]] name = "chrono" -version = "0.4.44" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", "js-sys", @@ -1990,6 +2141,15 @@ dependencies = [ "half", ] +[[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" @@ -2268,6 +2428,15 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[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" @@ -2421,6 +2590,25 @@ dependencies = [ "hybrid-array", ] +[[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 = "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" @@ -2662,6 +2850,15 @@ dependencies = [ "syn 2.0.117", ] +[[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" @@ -3374,6 +3571,16 @@ dependencies = [ "wasip3", ] +[[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" @@ -3381,7 +3588,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" dependencies = [ "opaque-debug", - "polyval", + "polyval 0.6.2", ] [[package]] @@ -3542,9 +3749,9 @@ dependencies = [ [[package]] name = "hashlink" -version = "0.11.0" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea0b22561a9c04a7cb1a302c013e0259cd3b4bb619f145b32f72b8b4bcbed230" +checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f" dependencies = [ "hashbrown 0.16.1", ] @@ -3604,7 +3811,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8a6fe56c0038198998a6f217ca4e7ef3a5e51f46163bd6dd60b5c71ca6c6502" dependencies = [ "async-trait", - "bitflags 2.11.1", + "bitflags 2.13.0", "cfg-if", "data-encoding", "enum-as-inner", @@ -3646,6 +3853,16 @@ dependencies = [ "tracing", ] +[[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" @@ -3655,6 +3872,16 @@ 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" @@ -3812,7 +4039,7 @@ dependencies = [ "httpdate", "itoa", "pin-project-lite", - "socket2 0.5.10", + "socket2 0.4.10", "tokio", "tower-service", "tracing", @@ -3855,6 +4082,26 @@ dependencies = [ "tokio-rustls 0.24.1", ] +[[package]] +name = "hyper-rustls" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0bea761b46ae2b24eb4aef630d8d1c398157b6fc29e6350ecf090a0b70c952c" +dependencies = [ + "futures-util", + "http 1.4.1", + "hyper 1.10.1", + "hyper-util", + "log", + "rustls 0.22.4", + "rustls-native-certs 0.7.3", + "rustls-pki-types", + "tokio", + "tokio-rustls 0.25.0", + "tower-service", + "webpki-roots 0.26.11", +] + [[package]] name = "hyper-rustls" version = "0.27.9" @@ -3865,7 +4112,7 @@ dependencies = [ "hyper 1.10.1", "hyper-util", "rustls 0.23.40", - "rustls-native-certs", + "rustls-native-certs 0.8.4", "tokio", "tokio-rustls 0.26.4", "tower-service", @@ -3897,6 +4144,21 @@ dependencies = [ "tower-service", ] +[[package]] +name = "hyper-tungstenite" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a343d17fe7885302ed7252767dc7bb83609a874b6ff581142241ec4b73957ad" +dependencies = [ + "http-body-util", + "hyper 1.10.1", + "hyper-util", + "pin-project-lite", + "tokio", + "tokio-tungstenite 0.21.0", + "tungstenite 0.21.0", +] + [[package]] name = "hyper-util" version = "0.1.20" @@ -4569,9 +4831,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.30" +version = "0.4.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" +checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" [[package]] name = "lru" @@ -4831,14 +5093,14 @@ version = "0.44.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08d8f0fe13526800300a36bf3b7c5f752e62e32ab81c74a8e5caa2865708625a" dependencies = [ - "aes", + "aes 0.8.4", "base64 0.22.1", "bech32 0.11.1", "bip39", "bitcoin_hashes 0.14.100", "cbc", "chacha20 0.9.1", - "chacha20poly1305", + "chacha20poly1305 0.10.1", "getrandom 0.2.17", "hex", "instant", @@ -5022,7 +5284,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", ] [[package]] @@ -5043,6 +5305,29 @@ dependencies = [ "memchr", ] +[[package]] +name = "ohttp-relay" +version = "0.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e766a4a358e64f0ea35b79eae3333052b8164ffc20f538ffb7c6861250bbb101" +dependencies = [ + "byteorder", + "bytes", + "futures", + "http 1.4.1", + "http-body-util", + "hyper 1.10.1", + "hyper-rustls 0.26.0", + "hyper-tungstenite", + "hyper-util", + "rustls 0.22.4", + "tokio", + "tokio-tungstenite 0.21.0", + "tokio-util", + "tracing", + "tracing-subscriber", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -5077,7 +5362,7 @@ version = "0.10.80" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "cfg-if", "foreign-types", "libc", @@ -5235,6 +5520,61 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" +[[package]] +name = "payjoin" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "764bdfb07333876342956b942c3b61214e9cca29bc17bf6895bb023763927ac2" +dependencies = [ + "bitcoin 0.32.100", + "log", +] + +[[package]] +name = "payjoin" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "844f2404db4ae16c4062a53da2c73812c7a130abf612149c06650a36e34d0a35" +dependencies = [ + "bhttp 0.6.1", + "bitcoin 0.32.100", + "bitcoin-hpke", + "bitcoin-ohttp", + "bitcoin_uri", + "http 1.4.1", + "reqwest", + "rustls 0.23.40", + "serde", + "serde_json", + "tracing", + "url", + "web-time", +] + +[[package]] +name = "payjoin-directory" +version = "0.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80ff7730a05bacb74c1e0b98e037f67161ddcb869c654bdc18bd0fc57a699f50" +dependencies = [ + "anyhow", + "bhttp 0.5.1", + "bitcoin 0.32.100", + "bitcoin-ohttp", + "futures", + "http-body-util", + "hyper 1.10.1", + "hyper-rustls 0.26.0", + "hyper-util", + "payjoin 0.24.0", + "redis 0.23.5", + "rustls 0.22.4", + "tokio", + "tokio-rustls 0.25.0", + "tracing", + "tracing-subscriber", +] + [[package]] name = "pbkdf2" version = "0.12.2" @@ -5509,6 +5849,17 @@ dependencies = [ "plotters-backend", ] +[[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" @@ -5517,7 +5868,19 @@ checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" dependencies = [ "cpufeatures 0.2.17", "opaque-debug", - "universal-hash", + "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 0.4.0", ] [[package]] @@ -5529,7 +5892,7 @@ dependencies = [ "cfg-if", "cpufeatures 0.2.17", "opaque-debug", - "universal-hash", + "universal-hash 0.5.1", ] [[package]] @@ -5744,12 +6107,12 @@ dependencies = [ [[package]] name = "prost" -version = "0.14.3" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568" +checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" dependencies = [ "bytes", - "prost-derive 0.14.3", + "prost-derive 0.14.4", ] [[package]] @@ -5797,9 +6160,9 @@ dependencies = [ [[package]] name = "prost-build" -version = "0.14.3" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "343d3bd7056eda839b03204e68deff7d1b13aba7af2b2fd16890697274262ee7" +checksum = "03da047801ff44bb6a4d407d4860c05fd70bb81714e6b2f3812603d5b145b042" dependencies = [ "heck 0.5.0", "itertools 0.13.0", @@ -5807,8 +6170,8 @@ dependencies = [ "multimap", "petgraph 0.8.3", "prettyplease 0.2.37", - "prost 0.14.3", - "prost-types 0.14.3", + "prost 0.14.4", + "prost-types 0.14.4", "pulldown-cmark", "pulldown-cmark-to-cmark", "regex", @@ -5844,9 +6207,9 @@ dependencies = [ [[package]] name = "prost-derive" -version = "0.14.3" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" +checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" dependencies = [ "anyhow", "itertools 0.13.0", @@ -5875,11 +6238,11 @@ dependencies = [ [[package]] name = "prost-types" -version = "0.14.3" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8991c4cbdb8bc5b11f0b074ffe286c30e523de90fee5ba8132f1399f23cb3dd7" +checksum = "f94967dc7688f3054c7fac87473ffae4cc4c3904800e2d9f5b857246d8963b0a" dependencies = [ - "prost 0.14.3", + "prost 0.14.4", ] [[package]] @@ -5894,7 +6257,7 @@ version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "memchr", "unicase", ] @@ -6104,6 +6467,18 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "rcgen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52c4f3084aa3bc7dfbba4eff4fab2a54db4324965d8872ab933565e6fbd83bc6" +dependencies = [ + "pem 3.0.6", + "ring 0.16.20", + "time", + "yasna", +] + [[package]] name = "redb" version = "3.1.3" @@ -6113,6 +6488,27 @@ dependencies = [ "libc", ] +[[package]] +name = "redis" +version = "0.23.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44e3fd704e6060c496523638d371b2db66d07d5f9692d7ce244b39723491ebad" +dependencies = [ + "async-trait", + "bytes", + "combine", + "futures-util", + "itoa", + "percent-encoding", + "pin-project-lite", + "ryu", + "sha1_smol", + "socket2 0.4.10", + "tokio", + "tokio-util", + "url", +] + [[package]] name = "redis" version = "0.31.0" @@ -6135,7 +6531,7 @@ dependencies = [ "pin-project-lite", "rand 0.9.4", "rustls 0.23.40", - "rustls-native-certs", + "rustls-native-certs 0.8.4", "ryu", "socket2 0.5.10", "tokio", @@ -6150,7 +6546,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", ] [[package]] @@ -6253,7 +6649,7 @@ dependencies = [ "pin-project-lite", "quinn", "rustls 0.23.40", - "rustls-native-certs", + "rustls-native-certs 0.8.4", "rustls-pki-types", "serde", "serde_json", @@ -6328,7 +6724,7 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4147b952f3f819eca0e99527022f7d6a8d05f111aeb0a62960c74eb283bec8fc" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "once_cell", "serde", "serde_derive", @@ -6363,7 +6759,7 @@ version = "0.31.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "fallible-iterator 0.3.0", "fallible-streaming-iterator", "hashlink 0.9.1", @@ -6443,7 +6839,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "errno", "libc", "linux-raw-sys 0.4.15", @@ -6456,7 +6852,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "errno", "libc", "linux-raw-sys 0.12.1", @@ -6475,6 +6871,20 @@ dependencies = [ "sct", ] +[[package]] +name = "rustls" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf4ef73721ac7bcd79b2b315da7779d8fc09718c6b3d2d1b2d94850eb8c18432" +dependencies = [ + "log", + "ring 0.17.14", + "rustls-pki-types", + "rustls-webpki 0.102.8", + "subtle", + "zeroize", +] + [[package]] name = "rustls" version = "0.23.40" @@ -6490,6 +6900,19 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5bfb394eeed242e909609f56089eecfe5fda225042e8b171791b9c95f5931e5" +dependencies = [ + "openssl-probe 0.1.6", + "rustls-pemfile 2.2.0", + "rustls-pki-types", + "schannel", + "security-framework 2.11.1", +] + [[package]] name = "rustls-native-certs" version = "0.8.4" @@ -6540,6 +6963,17 @@ dependencies = [ "untrusted 0.9.0", ] +[[package]] +name = "rustls-webpki" +version = "0.102.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9" +dependencies = [ + "ring 0.17.14", + "rustls-pki-types", + "untrusted 0.9.0", +] + [[package]] name = "rustls-webpki" version = "0.103.13" @@ -6768,7 +7202,7 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "core-foundation 0.9.4", "core-foundation-sys", "libc", @@ -6781,7 +7215,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -6972,6 +7406,25 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + +[[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]] name = "sha2" version = "0.10.9" @@ -7111,6 +7564,16 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c" +[[package]] +name = "socket2" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7916fc008ca5542385b89a3d3ce689953c143e9304a9bf8beec1de48994c0d" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "socket2" version = "0.5.10" @@ -7696,6 +8159,17 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-rustls" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "775e0c0f0adb3a2f22a00c4745d728b479985fc15ee7ca6a2608388c5569860f" +dependencies = [ + "rustls 0.22.4", + "rustls-pki-types", + "tokio", +] + [[package]] name = "tokio-rustls" version = "0.26.4" @@ -7730,6 +8204,18 @@ dependencies = [ "tokio-util", ] +[[package]] +name = "tokio-tungstenite" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c83b561d025642014097b66e6c1bb422783339e0909e4429cde4749d1990bc38" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite 0.21.0", +] + [[package]] name = "tokio-tungstenite" version = "0.26.2" @@ -7739,7 +8225,7 @@ dependencies = [ "futures-util", "log", "rustls 0.23.40", - "rustls-native-certs", + "rustls-native-certs 0.8.4", "rustls-pki-types", "tokio", "tokio-rustls 0.26.4", @@ -7773,6 +8259,15 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234" +dependencies = [ + "serde", +] + [[package]] name = "toml" version = "0.8.23" @@ -7990,7 +8485,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a55376a0bbaa4975a3f10d009ad763d8f4108f067c7c2e74f3001fb49778d309" dependencies = [ "bytes", - "prost 0.14.3", + "prost 0.14.4", "tonic 0.14.5", ] @@ -8002,8 +8497,8 @@ checksum = "f3144df636917574672e93d0f56d7edec49f90305749c668df5101751bb8f95a" dependencies = [ "prettyplease 0.2.37", "proc-macro2", - "prost-build 0.14.3", - "prost-types 0.14.3", + "prost-build 0.14.4", + "prost-types 0.14.4", "quote", "syn 2.0.117", "tempfile", @@ -8059,7 +8554,7 @@ version = "0.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38e51af4f36e96fe61833a4d94cf50c252a9958c162c0db2fa7a92ee2f0985c3" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "bytes", "caret", "derive_more", @@ -8447,9 +8942,9 @@ version = "0.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a050d7145f3d1209d579f2d62d0da3eeb25e269d5a98ec1ef3c11c72d8eb174" dependencies = [ - "aes", + "aes 0.8.4", "base64ct", - "ctr", + "ctr 0.9.2", "curve25519-dalek", "derive_more", "digest 0.10.7", @@ -8494,7 +8989,7 @@ version = "0.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "228eeb225054455c0545a4d5e06d188790e5bd85129eefb9b24c86cb18f22ce2" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "derive_more", "futures", "humantime 2.3.0", @@ -8524,7 +9019,7 @@ checksum = "8b34733b319ff6aa7a146973647c00120d111aa77da221d28309bacf144e3239" dependencies = [ "amplify", "base64ct", - "bitflags 2.11.1", + "bitflags 2.13.0", "cipher 0.4.4", "derive_builder_fork_arti", "derive_more", @@ -8593,7 +9088,7 @@ dependencies = [ "digest 0.10.7", "educe", "futures", - "hkdf", + "hkdf 0.12.4", "hmac 0.12.1", "pin-project", "rand 0.8.6", @@ -8769,7 +9264,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ "async-compression", - "bitflags 2.11.1", + "bitflags 2.13.0", "bytes", "futures-core", "futures-util", @@ -8905,6 +9400,25 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tungstenite" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ef1a641ea34f399a848dea702823bbecfb4c486f911735368f1f137cb8257e1" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http 1.4.1", + "httparse", + "log", + "rand 0.8.6", + "sha1", + "thiserror 1.0.69", + "url", + "utf-8", +] + [[package]] name = "tungstenite" version = "0.26.2" @@ -9191,6 +9705,16 @@ dependencies = [ "weedle2", ] +[[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" @@ -9550,7 +10074,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "hashbrown 0.15.5", "indexmap 2.14.0", "semver", @@ -10023,7 +10547,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags 2.11.1", + "bitflags 2.13.0", "indexmap 2.14.0", "log", "serde", @@ -10098,14 +10622,23 @@ checksum = "631a50d867fafb7093e709d75aaee9e0e0d5deb934021fcea25ac2fe09edc51e" dependencies = [ "arraydeque", "encoding_rs", - "hashlink 0.11.0", + "hashlink 0.11.1", +] + +[[package]] +name = "yasna" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" +dependencies = [ + "time", ] [[package]] name = "yoke" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" dependencies = [ "stable_deref_trait", "yoke-derive", diff --git a/crates/cashu/src/nuts/mod.rs b/crates/cashu/src/nuts/mod.rs index 54a185ac03..efb08b7535 100644 --- a/crates/cashu/src/nuts/mod.rs +++ b/crates/cashu/src/nuts/mod.rs @@ -32,6 +32,7 @@ pub mod nut27; pub mod nut28; pub mod nut29; pub mod nut30; +pub mod nut31; mod auth; @@ -93,3 +94,4 @@ pub use nut30::{ MeltOnchainRequest, MeltQuoteOnchainRequest, MeltQuoteOnchainResponse, MintQuoteOnchainRequest, MintQuoteOnchainResponse, }; +pub use nut31::PayjoinV2; diff --git a/crates/cashu/src/nuts/nut17/mod.rs b/crates/cashu/src/nuts/nut17/mod.rs index 206b93b8a1..da6f7bb3e5 100644 --- a/crates/cashu/src/nuts/nut17/mod.rs +++ b/crates/cashu/src/nuts/nut17/mod.rs @@ -1,5 +1,5 @@ //! Specific Subscription for the cdk crate -use serde::de::DeserializeOwned; +use serde::de::{DeserializeOwned, Error as DeError}; use serde::{Deserialize, Serialize}; use super::PublicKey; @@ -184,19 +184,10 @@ where } } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(bound = "T: Serialize + DeserializeOwned")] +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(bound(serialize = "T: Serialize + DeserializeOwned"))] #[serde(untagged)] /// Subscription response -/// -/// Note on variant ordering: serde `untagged` deserialization tries variants -/// in declaration order and selects the first that matches. The Onchain -/// variants are declared before the Bolt11/Bolt12 variants because the -/// Onchain response structs use `#[serde(deny_unknown_fields)]`, which makes -/// them reject Bolt11/Bolt12 payloads cleanly. Placing them first ensures -/// onchain payloads are classified correctly without being consumed by the -/// more permissive Bolt12 variant (which carries a superset of Onchain's -/// field names). pub enum NotificationPayload where T: Clone, @@ -204,14 +195,8 @@ where /// Proof State ProofState(ProofState), /// Mint Quote Onchain Response - /// - /// Declared before `MintQuoteBolt12Response` to ensure untagged - /// discrimination picks the onchain variant for onchain payloads. MintQuoteOnchainResponse(MintQuoteOnchainResponse), /// Melt Quote Onchain Response - /// - /// Declared before `MeltQuoteBolt11Response`/`MeltQuoteBolt12Response` - /// for the same reason. MeltQuoteOnchainResponse(MeltQuoteOnchainResponse), /// Melt Quote Bolt11 Response MeltQuoteBolt11Response(MeltQuoteBolt11Response), @@ -227,6 +212,76 @@ where CustomMeltQuoteResponse(String, MeltQuoteCustomResponse), } +fn deserialize_payload(value: serde_json::Value) -> Result, E> +where + T: Clone + Serialize + DeserializeOwned, + E: DeError, +{ + fn from_value(value: serde_json::Value) -> Result + where + V: DeserializeOwned, + E: DeError, + { + serde_json::from_value(value).map_err(E::custom) + } + + match &value { + serde_json::Value::Object(fields) => { + if fields.contains_key("Y") { + return from_value(value).map(NotificationPayload::ProofState); + } + + if fields.contains_key("fee_options") { + return from_value(value).map(NotificationPayload::MeltQuoteOnchainResponse); + } + + if fields.contains_key("fee_reserve") { + return from_value(value).map(NotificationPayload::MeltQuoteBolt11Response); + } + + if fields.contains_key("state") { + return from_value(value).map(NotificationPayload::MintQuoteBolt11Response); + } + + if fields.contains_key("amount") { + return from_value(value).map(NotificationPayload::MintQuoteBolt12Response); + } + + from_value(value).map(NotificationPayload::MintQuoteOnchainResponse) + } + serde_json::Value::Array(items) if items.len() == 2 => { + let response = items + .get(1) + .ok_or_else(|| E::custom("custom notification payload is missing response"))?; + match response.as_object() { + Some(fields) if fields.contains_key("state") => { + from_value(value).map(|(method, response)| { + NotificationPayload::CustomMeltQuoteResponse(method, response) + }) + } + Some(_) => from_value(value).map(|(method, response)| { + NotificationPayload::CustomMintQuoteResponse(method, response) + }), + None => Err(E::custom("custom notification response must be an object")), + } + } + _ => Err(E::custom("invalid notification payload")), + } +} + +impl<'de, T> Deserialize<'de> for NotificationPayload +where + T: Clone + Serialize + DeserializeOwned, +{ + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let value = serde_json::Value::deserialize(deserializer)?; + deserialize_payload(value) + } +} + #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Deserialize, Hash, Serialize)] #[serde(bound = "T: Serialize + DeserializeOwned")] /// A parsed notification @@ -333,101 +388,72 @@ pub enum Error { #[cfg(test)] mod tests { + use serde_json::json; + use super::*; - use crate::nuts::nut00::CurrencyUnit; - use crate::nuts::nut01::PublicKey; - use crate::nuts::MeltQuoteState; - use crate::Amount; #[test] - fn notification_payload_onchain_mint_roundtrip() { - let resp: MintQuoteOnchainResponse = MintQuoteOnchainResponse { - quote: "abc".to_string(), - request: "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh".to_string(), - unit: CurrencyUnit::Sat, - expiry: Some(1701704757), - pubkey: PublicKey::from_hex( - "03d56ce4e446a85bbdaa547b4ec2b073d40ff802831352b8272b7dd7a4de5a7cac", - ) - .unwrap(), - amount_paid: Amount::from(100_000), - amount_issued: Amount::from(0), - }; - let payload: NotificationPayload = - NotificationPayload::MintQuoteOnchainResponse(resp.clone()); - - let encoded = serde_json::to_string(&payload).unwrap(); - let decoded: NotificationPayload = serde_json::from_str(&encoded).unwrap(); - - match decoded { - NotificationPayload::MintQuoteOnchainResponse(r) => { - assert_eq!(r, resp); - } - other => panic!("expected MintQuoteOnchainResponse, got {:?}", other), - } + fn notification_payload_deserializes_melt_quote() { + let payload = json!({ + "quote": "melt-quote", + "amount": 21, + "fee_reserve": 1, + "state": "PAID", + "expiry": 1234, + "payment_preimage": null, + "change": null, + "request": "lnbc1...", + "unit": "sat" + }); + + let decoded: NotificationPayload = serde_json::from_value(payload).unwrap(); + + assert!(matches!( + decoded, + NotificationPayload::MeltQuoteBolt11Response(_) + )); } #[test] - fn notification_payload_bolt12_mint_roundtrip() { - // Ensure a Bolt12 payload (with `amount`) still decodes as Bolt12 after - // the Onchain variant was moved ahead of it and marked - // `deny_unknown_fields`. - let resp: MintQuoteBolt12Response = MintQuoteBolt12Response { - quote: "abc".to_string(), - request: "lno1...".to_string(), - amount: Some(Amount::from(100_000)), - unit: CurrencyUnit::Sat, - expiry: Some(1701704757), - pubkey: PublicKey::from_hex( - "03d56ce4e446a85bbdaa547b4ec2b073d40ff802831352b8272b7dd7a4de5a7cac", - ) - .unwrap(), - amount_paid: Amount::from(0), - amount_issued: Amount::from(0), - }; - let payload: NotificationPayload = - NotificationPayload::MintQuoteBolt12Response(resp.clone()); - - let encoded = serde_json::to_string(&payload).unwrap(); - let decoded: NotificationPayload = serde_json::from_str(&encoded).unwrap(); - - match decoded { - NotificationPayload::MintQuoteBolt12Response(r) => { - assert_eq!(r, resp); - } - other => panic!("expected MintQuoteBolt12Response, got {:?}", other), - } + fn notification_payload_keeps_stateful_mint_quote_as_bolt11() { + let payload = json!({ + "quote": "mint-quote", + "request": "lnbc1...", + "amount": 21, + "unit": "sat", + "state": "PAID", + "expiry": 1234, + "pubkey": "02194603ffa062682c4f10e2dfe8f53e17d5d0329db51c8d3935cc74a4c0e0d4cb" + }); + + let decoded: NotificationPayload = serde_json::from_value(payload).unwrap(); + + assert!(matches!( + decoded, + NotificationPayload::MintQuoteBolt11Response(_) + )); } #[test] - fn notification_payload_onchain_melt_roundtrip() { - let resp: MeltQuoteOnchainResponse = MeltQuoteOnchainResponse { - quote: "abc".to_string(), - amount: Amount::from(100_000), - unit: CurrencyUnit::Sat, - state: MeltQuoteState::Pending, - expiry: 1701704757, - request: "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh".to_string(), - fee_options: vec![crate::nut30::MeltQuoteOnchainFeeOption { - fee_index: 0, - fee_reserve: Amount::from(5_000), - estimated_blocks: 1, - }], - selected_fee_index: Some(0), - outpoint: Some("3b7f3b85:2".to_string()), - change: None, - }; - let payload: NotificationPayload = - NotificationPayload::MeltQuoteOnchainResponse(resp.clone()); + fn notification_payload_deserializes_onchain_mint_quote_with_unknown_fields() { + let payload = json!({ + "quote": "onchain-quote", + "request": "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh", + "unit": "sat", + "expiry": 1234, + "pubkey": "02194603ffa062682c4f10e2dfe8f53e17d5d0329db51c8d3935cc74a4c0e0d4cb", + "amount_paid": 0, + "amount_issued": 0, + "future_onchain_extension": { + "enabled": true + } + }); - let encoded = serde_json::to_string(&payload).unwrap(); - let decoded: NotificationPayload = serde_json::from_str(&encoded).unwrap(); + let decoded: NotificationPayload = serde_json::from_value(payload).unwrap(); - match decoded { - NotificationPayload::MeltQuoteOnchainResponse(r) => { - assert_eq!(r, resp); - } - other => panic!("expected MeltQuoteOnchainResponse, got {:?}", other), - } + assert!(matches!( + decoded, + NotificationPayload::MintQuoteOnchainResponse(_) + )); } } diff --git a/crates/cashu/src/nuts/nut17/ws.rs b/crates/cashu/src/nuts/nut17/ws.rs index 3cbcf8319c..07c1f352e6 100644 --- a/crates/cashu/src/nuts/nut17/ws.rs +++ b/crates/cashu/src/nuts/nut17/ws.rs @@ -34,8 +34,8 @@ pub struct WsUnsubscribeResponse { /// /// This is the notification that is sent to the client when an event matches a /// subscription -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(bound = "T: Serialize + DeserializeOwned, I: Serialize + DeserializeOwned")] +#[derive(Debug, Clone, Serialize)] +#[serde(bound(serialize = "T: Serialize + DeserializeOwned, I: Serialize + DeserializeOwned"))] pub struct NotificationInner where T: Clone, @@ -158,8 +158,8 @@ pub struct WsErrorResponse { } /// Message from the server to the client -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(bound = "I: Serialize + DeserializeOwned")] +#[derive(Debug, Clone, Serialize)] +#[serde(bound(serialize = "I: Serialize + DeserializeOwned"))] #[serde(untagged)] pub enum WsMessageOrResponse { /// A response to a request diff --git a/crates/cashu/src/nuts/nut30.rs b/crates/cashu/src/nuts/nut30.rs index 1af19eea4c..5ca4e8bb3c 100644 --- a/crates/cashu/src/nuts/nut30.rs +++ b/crates/cashu/src/nuts/nut30.rs @@ -6,6 +6,7 @@ use serde::{Deserialize, Serialize}; use super::nut00::{BlindSignature, BlindedMessage, CurrencyUnit}; use super::nut01::PublicKey; use super::nut05::MeltRequest; +use super::nut31::PayjoinV2; use super::MeltQuoteState; #[cfg(feature = "mint")] use crate::quote_id::QuoteId; @@ -27,14 +28,10 @@ pub struct MintQuoteOnchainRequest { /// /// Response containing the onchain quote details. /// -/// `deny_unknown_fields` is intentional: the `NotificationPayload` enum is -/// `#[serde(untagged)]` and several quote-response variants share a large -/// overlap of field names. Rejecting unknown fields ensures an onchain payload -/// cannot silently deserialize as another method (for example `MintQuoteBolt12Response` -/// which carries an `amount` field Onchain does not have). +/// Unknown fields are accepted to preserve forward compatibility when mints +/// add optional onchain extensions. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(bound = "Q: Serialize + DeserializeOwned")] -#[serde(deny_unknown_fields)] pub struct MintQuoteOnchainResponse { /// Quote Id pub quote: Q, @@ -52,6 +49,9 @@ pub struct MintQuoteOnchainResponse { /// Amount of ecash that has been issued for the given mint quote #[serde(default)] pub amount_issued: Amount, + /// Optional Payjoin instructions. `request` remains the fallback address. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub payjoin: Option, } impl MintQuoteOnchainResponse { @@ -65,6 +65,7 @@ impl MintQuoteOnchainResponse { pubkey: self.pubkey, amount_paid: self.amount_paid, amount_issued: self.amount_issued, + payjoin: self.payjoin.clone(), } } } @@ -80,6 +81,7 @@ impl From> for MintQuoteOnchainResponse, } /// Melt onchain request @@ -152,14 +157,10 @@ pub struct MeltQuoteOnchainFeeOption { /// The `POST /v1/melt/quote/onchain` endpoint returns one quote with one or /// more `fee_options`. The wallet chooses one option when executing the quote. /// -/// `deny_unknown_fields` is intentional: the `NotificationPayload` enum is -/// `#[serde(untagged)]` and melt-quote responses for different methods share -/// many field names. Rejecting unknown fields ensures an onchain payload cannot -/// silently deserialize as `MeltQuoteBolt11Response` (which carries `fee_reserve` -/// at the top level, while onchain carries it inside `fee_options`). +/// Unknown fields are accepted to preserve forward compatibility when mints +/// add optional onchain extensions. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(bound = "Q: Serialize + DeserializeOwned")] -#[serde(deny_unknown_fields)] pub struct MeltQuoteOnchainResponse { /// Quote Id pub quote: Q, @@ -188,6 +189,9 @@ pub struct MeltQuoteOnchainResponse { /// Blind signatures for overpaid onchain fee reserve #[serde(default, skip_serializing_if = "Option::is_none")] pub change: Option>, + /// Optional confirmation that Payjoin v2 was accepted for this quote. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub payjoin: Option, } impl MeltQuoteOnchainResponse { @@ -204,6 +208,7 @@ impl MeltQuoteOnchainResponse { selected_fee_index: self.selected_fee_index, outpoint: self.outpoint.clone(), change: self.change.clone(), + payjoin: self.payjoin.clone(), } } } @@ -222,6 +227,7 @@ impl From> for MeltQuoteOnchainResponse = + serde_json::from_str(response_json).unwrap(); + + assert_eq!(response.quote, "DSGLX9kevM..."); + assert_eq!(response.payjoin, None); + } + + #[test] + fn test_onchain_payjoin_response_serialization() { + let payjoin = PayjoinV2::new( + "https://payjoin.example/pj".to_string(), + "QYPFLM8XL59R0XV4VGPLS7FRDSSM4TUXL07TXCWC4S0GLVLNK2SE4NQ", + "QV6WSX0UQPAEA0RH54430D0UVZWS8CZ6FEGZF4RGFCDKJLPGMYEJG", + 1701704757, + ) + .expect("valid Payjoin keys"); + + let response: MintQuoteOnchainResponse = MintQuoteOnchainResponse { + quote: "DSGLX9kevM...".to_string(), + request: "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh".to_string(), + unit: CurrencyUnit::Sat, + expiry: Some(1701704757), + pubkey: PublicKey::from_hex( + "03d56ce4e446a85bbdaa547b4ec2b073d40ff802831352b8272b7dd7a4de5a7cac", + ) + .unwrap(), + amount_paid: Amount::from(0), + amount_issued: Amount::from(0), + payjoin: Some(payjoin.clone()), + }; + + let serialized = serde_json::to_string(&response).unwrap(); + assert!( + serialized.contains(r#""expires_at":1701704757"#), + "Cashu JSON must serialize expires_at as a Unix timestamp number" + ); + assert!( + !serialized.contains("EX1"), + "BIP77 EX1 expiry encoding must not appear in Cashu JSON" + ); + let deserialized: MintQuoteOnchainResponse = + serde_json::from_str(&serialized).unwrap(); + + assert_eq!(deserialized.payjoin, Some(payjoin)); + } } diff --git a/crates/cashu/src/nuts/nut31.rs b/crates/cashu/src/nuts/nut31.rs new file mode 100644 index 0000000000..aebdc0f3c5 --- /dev/null +++ b/crates/cashu/src/nuts/nut31.rs @@ -0,0 +1,288 @@ +//! Payjoin for onchain payment method + +use core::fmt; +use core::str::FromStr; + +use bitcoin::bech32::primitives::decode::{ + CharError, CheckedHrpstring, CheckedHrpstringError, UncheckedHrpstringError, +}; +use bitcoin::bech32::{self, Hrp, NoChecksum}; +use bitcoin::secp256k1; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +const OHTTP_KEYS_PREFIX: &str = "OH1"; +const OHTTP_KEYS_HRP: &str = "OH"; +const OHTTP_KEYS_BYTES: usize = 34; +const RECEIVER_KEY_PREFIX: &str = "RK1"; +const RECEIVER_KEY_HRP: &str = "RK"; +const RECEIVER_KEY_BYTES: usize = 33; + +/// Encoded OHTTP key material needed by the sender. +/// +/// The wire representation is the BIP77 `OH` fragment value without the +/// `OH1` prefix. Internally this stores the decoded key identifier and +/// compressed secp256k1 public key bytes. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct PayjoinOhttpKeys([u8; OHTTP_KEYS_BYTES]); + +impl PayjoinOhttpKeys { + /// Return decoded OHTTP key bytes. + pub fn as_bytes(&self) -> &[u8; OHTTP_KEYS_BYTES] { + &self.0 + } +} + +impl fmt::Display for PayjoinOhttpKeys { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write_prefixless_key(f, OHTTP_KEYS_HRP, OHTTP_KEYS_PREFIX, &self.0) + } +} + +impl FromStr for PayjoinOhttpKeys { + type Err = PayjoinV2KeyError; + + fn from_str(value: &str) -> Result { + let bytes = decode_prefixless_key( + value, + "ohttp_keys", + OHTTP_KEYS_HRP, + OHTTP_KEYS_PREFIX, + OHTTP_KEYS_BYTES, + )?; + validate_compressed_pubkey("ohttp_keys", &bytes[1..])?; + Ok(Self(bytes)) + } +} + +impl Serialize for PayjoinOhttpKeys { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(&self.to_string()) + } +} + +impl<'de> Deserialize<'de> for PayjoinOhttpKeys { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + value.parse().map_err(serde::de::Error::custom) + } +} + +/// Encoded receiver session key. +/// +/// The wire representation is the BIP77 `RK` fragment value without the +/// `RK1` prefix. Internally this stores the decoded compressed secp256k1 +/// public key bytes. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct PayjoinReceiverKey([u8; RECEIVER_KEY_BYTES]); + +impl PayjoinReceiverKey { + /// Return decoded receiver public key bytes. + pub fn as_bytes(&self) -> &[u8; RECEIVER_KEY_BYTES] { + &self.0 + } +} + +impl fmt::Display for PayjoinReceiverKey { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write_prefixless_key(f, RECEIVER_KEY_HRP, RECEIVER_KEY_PREFIX, &self.0) + } +} + +impl FromStr for PayjoinReceiverKey { + type Err = PayjoinV2KeyError; + + fn from_str(value: &str) -> Result { + let bytes = decode_prefixless_key( + value, + "receiver_key", + RECEIVER_KEY_HRP, + RECEIVER_KEY_PREFIX, + RECEIVER_KEY_BYTES, + )?; + validate_compressed_pubkey("receiver_key", &bytes)?; + Ok(Self(bytes)) + } +} + +impl Serialize for PayjoinReceiverKey { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(&self.to_string()) + } +} + +impl<'de> Deserialize<'de> for PayjoinReceiverKey { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + value.parse().map_err(serde::de::Error::custom) + } +} + +/// Errors for Payjoin v2 key encoding. +#[derive(Debug, Error)] +pub enum PayjoinV2KeyError { + /// Key string included the BIP77 fragment prefix. + #[error("{field} must not include the {prefix} prefix")] + HasPrefix { + /// Field name. + field: &'static str, + /// Disallowed prefix. + prefix: &'static str, + }, + /// Key string has an invalid bech32 prefix. + #[error("{field} has invalid bech32 prefix")] + InvalidPrefix { + /// Field name. + field: &'static str, + }, + /// Key string has an invalid bech32 character. + #[error("{field} has invalid bech32 character: {character}")] + InvalidCharacter { + /// Field name. + field: &'static str, + /// Invalid character. + character: char, + }, + /// Key string decodes to the wrong byte length. + #[error("{field} has invalid decoded length: {actual}, expected {expected}")] + InvalidLength { + /// Field name. + field: &'static str, + /// Actual decoded byte length. + actual: usize, + /// Expected decoded byte length. + expected: usize, + }, + /// Key string contains non-zero padding bits. + #[error("{field} has invalid bech32 padding")] + InvalidPadding { + /// Field name. + field: &'static str, + }, + /// Key string does not contain a valid compressed secp256k1 public key. + #[error("{field} does not contain a valid compressed secp256k1 public key")] + InvalidPublicKey { + /// Field name. + field: &'static str, + }, +} + +/// Payjoin v2 parameters for an onchain payment. +/// +/// Cashu uses Unix timestamp; BIP77 URI fragments use encoded `EX1`. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct PayjoinV2 { + /// BIP77 mailbox endpoint URL without receiver fragment parameters. + /// + /// When assembled into a `pj` URI parameter, the endpoint value must be + /// encoded according to BIP77. + pub endpoint: String, + /// Encoded OHTTP key material needed by the sender, without the `OH1` prefix. + pub ohttp_keys: PayjoinOhttpKeys, + /// Encoded receiver session key, without the `RK1` prefix. + pub receiver_key: PayjoinReceiverKey, + /// Unix timestamp until the Payjoin parameters are valid. + pub expires_at: u64, +} + +impl PayjoinV2 { + /// Construct Payjoin v2 parameters from encoded key strings. + pub fn new( + endpoint: String, + ohttp_keys: O, + receiver_key: R, + expires_at: u64, + ) -> Result + where + O: AsRef, + R: AsRef, + { + Ok(Self { + endpoint, + ohttp_keys: ohttp_keys.as_ref().parse()?, + receiver_key: receiver_key.as_ref().parse()?, + expires_at, + }) + } +} + +fn decode_prefixless_key( + value: &str, + field: &'static str, + hrp: &'static str, + prefix: &'static str, + expected: usize, +) -> Result<[u8; N], PayjoinV2KeyError> { + if value.starts_with(prefix) { + return Err(PayjoinV2KeyError::HasPrefix { field, prefix }); + } + + let encoded = format!("{prefix}{value}"); + let hrp_string = CheckedHrpstring::new::(&encoded) + .map_err(|error| map_bech32_error(field, error))?; + let expected_hrp = Hrp::parse(hrp).map_err(|_| PayjoinV2KeyError::InvalidPrefix { field })?; + if hrp_string.hrp() != expected_hrp { + return Err(PayjoinV2KeyError::InvalidPrefix { field }); + } + + let bytes = hrp_string.byte_iter().collect::>(); + if bytes.len() != expected { + return Err(PayjoinV2KeyError::InvalidLength { + field, + actual: bytes.len(), + expected, + }); + } + + bytes + .try_into() + .map_err(|bytes: Vec| PayjoinV2KeyError::InvalidLength { + field, + actual: bytes.len(), + expected, + }) +} + +fn write_prefixless_key( + f: &mut fmt::Formatter<'_>, + hrp: &'static str, + prefix: &'static str, + bytes: &[u8], +) -> fmt::Result { + let hrp = Hrp::parse(hrp).map_err(|_| fmt::Error)?; + let encoded = bech32::encode_upper::(hrp, bytes).map_err(|_| fmt::Error)?; + let value = encoded.strip_prefix(prefix).ok_or(fmt::Error)?; + f.write_str(value) +} + +fn validate_compressed_pubkey(field: &'static str, bytes: &[u8]) -> Result<(), PayjoinV2KeyError> { + secp256k1::PublicKey::from_slice(bytes) + .map(|_| ()) + .map_err(|_| PayjoinV2KeyError::InvalidPublicKey { field }) +} + +fn map_bech32_error(field: &'static str, error: CheckedHrpstringError) -> PayjoinV2KeyError { + match error { + CheckedHrpstringError::Parse(UncheckedHrpstringError::Char(CharError::InvalidChar( + character, + ))) => PayjoinV2KeyError::InvalidCharacter { field, character }, + CheckedHrpstringError::Parse(UncheckedHrpstringError::Char( + CharError::MissingSeparator | CharError::NothingAfterSeparator | CharError::MixedCase, + )) + | CheckedHrpstringError::Parse(UncheckedHrpstringError::Hrp(_)) + | CheckedHrpstringError::Checksum(_) => PayjoinV2KeyError::InvalidPrefix { field }, + _ => PayjoinV2KeyError::InvalidPadding { field }, + } +} diff --git a/crates/cdk-bdk/Cargo.toml b/crates/cdk-bdk/Cargo.toml index 7ab55dd047..d1e4490563 100644 --- a/crates/cdk-bdk/Cargo.toml +++ b/crates/cdk-bdk/Cargo.toml @@ -14,6 +14,7 @@ readme = "README.md" default = ["bitcoin-rpc", "esplora"] bitcoin-rpc = ["dep:bdk_bitcoind_rpc"] esplora = ["dep:bdk_esplora"] +payjoin-local-https = ["payjoin/_manual-tls"] [dependencies] async-trait.workspace = true @@ -30,6 +31,9 @@ tokio-stream = { workspace = true, features = ["sync"] } serde.workspace = true serde_json.workspace = true uuid.workspace = true +payjoin = { version = "0.25.0", default-features = false, features = ["v2", "io"] } +reqwest.workspace = true +url.workspace = true [dev-dependencies] cdk-sqlite = { workspace = true } diff --git a/crates/cdk-bdk/src/chain/bitcoin_rpc.rs b/crates/cdk-bdk/src/chain/bitcoin_rpc.rs index ffe6ceff10..fe96df8bbd 100644 --- a/crates/cdk-bdk/src/chain/bitcoin_rpc.rs +++ b/crates/cdk-bdk/src/chain/bitcoin_rpc.rs @@ -3,7 +3,7 @@ use std::time::Instant; use bdk_bitcoind_rpc::bitcoincore_rpc::{Auth, Client, Error as BitcoinRpcError, RawTx, RpcApi}; use bdk_bitcoind_rpc::{BlockEvent, Emitter, NO_EXPECTED_MEMPOOL_TXS}; -use bdk_wallet::bitcoin::{Block, Transaction}; +use bdk_wallet::bitcoin::{Block, OutPoint, Transaction}; use tokio::sync::Mutex; use tokio::time::{interval, Duration}; use tokio_util::sync::CancellationToken; @@ -306,6 +306,25 @@ pub(crate) async fn broadcast_bitcoin_rpc( } } +/// Dry-run check whether a transaction would be accepted to the mempool via +/// Bitcoin Core's `testmempoolaccept`. The `Client` is synchronous, so this is a +/// blocking call intended to be used inside the payjoin `can_broadcast` closure. +pub(crate) fn accepts_broadcast_bitcoin_rpc( + config: &BitcoinRpcConfig, + tx: &Transaction, +) -> Result { + let rpc_client = Client::new( + &format!("http://{}:{}", config.host, config.port), + Auth::UserPass(config.user.clone(), config.password.clone()), + )?; + + let results = rpc_client.test_mempool_accept(&[tx.raw_hex()])?; + Ok(results + .first() + .map(|result| result.allowed) + .unwrap_or(false)) +} + pub(crate) async fn fetch_fee_rate_bitcoin_rpc( config: &BitcoinRpcConfig, target_blocks: u16, @@ -338,6 +357,36 @@ pub(crate) async fn fetch_fee_rate_bitcoin_rpc( .map_err(|e| Error::FeeEstimationFailed(e.to_string()))? } +pub(crate) async fn any_confirmed_spend_bitcoin_rpc( + config: &BitcoinRpcConfig, + outpoints: &[OutPoint], +) -> Result { + let config = config.clone(); + let outpoints = outpoints.to_vec(); + + tokio::task::spawn_blocking(move || { + let rpc_client = Client::new( + &format!("http://{}:{}", config.host, config.port), + Auth::UserPass(config.user, config.password), + )?; + + for outpoint in outpoints { + // include_mempool=false means an unconfirmed spend still reports + // the output as unspent. Only confirmed spends return None. + if rpc_client + .get_tx_out(&outpoint.txid, outpoint.vout, Some(false))? + .is_none() + { + return Ok(true); + } + } + + Ok(false) + }) + .await + .map_err(|e| Error::Wallet(e.to_string()))? +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/cdk-bdk/src/chain/esplora.rs b/crates/cdk-bdk/src/chain/esplora.rs index f8e6f45e61..584bfe66aa 100644 --- a/crates/cdk-bdk/src/chain/esplora.rs +++ b/crates/cdk-bdk/src/chain/esplora.rs @@ -2,7 +2,7 @@ use std::time::Instant; use bdk_esplora::esplora_client::{AsyncClient, Builder}; use bdk_esplora::EsploraAsyncExt; -use bdk_wallet::bitcoin::Transaction; +use bdk_wallet::bitcoin::{OutPoint, Transaction}; use tokio::time::{interval, Duration}; use tokio_util::sync::CancellationToken; @@ -283,6 +283,31 @@ pub(crate) async fn fetch_fee_rate_esplora( Err(Error::FeeEstimationUnavailable) } +pub(crate) async fn any_confirmed_spend_esplora( + config: &EsploraConfig, + outpoints: &[OutPoint], +) -> Result { + let client = Builder::new(&config.url) + .build_async() + .map_err(|e| Error::Esplora(e.to_string()))?; + + for outpoint in outpoints { + let Some(status) = client + .get_output_status(&outpoint.txid, outpoint.vout.into()) + .await + .map_err(|e| Error::Esplora(e.to_string()))? + else { + continue; + }; + + if status.spent && status.status.is_some_and(|tx_status| tx_status.confirmed) { + return Ok(true); + } + } + + Ok(false) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/cdk-bdk/src/chain/mod.rs b/crates/cdk-bdk/src/chain/mod.rs index ced291a70c..9afe29c7d9 100644 --- a/crates/cdk-bdk/src/chain/mod.rs +++ b/crates/cdk-bdk/src/chain/mod.rs @@ -1,4 +1,4 @@ -use bdk_wallet::bitcoin::Transaction; +use bdk_wallet::bitcoin::{OutPoint, Transaction}; use tokio_util::sync::CancellationToken; use crate::error::Error; @@ -110,6 +110,27 @@ impl ChainSource { } } + /// Dry-run whether a transaction would be accepted for broadcast. + /// + /// `Some(true/false)` via Bitcoin Core `testmempoolaccept`, or `None` when the + /// backend has no dry-run (Esplora) and the caller should rely on a min-fee + /// floor. Synchronous so it works in the payjoin `can_broadcast` closure. + #[cfg_attr(not(feature = "bitcoin-rpc"), allow(unused_variables))] + pub(crate) fn accepts_broadcast(&self, tx: &Transaction) -> Result, Error> { + match self { + // Esplora has no `testmempoolaccept`; `None` tells the caller to fall + // back to the min-fee-rate floor (see `is_payjoin_input_seen`). + #[cfg(feature = "esplora")] + ChainSource::Esplora(_) => Ok(None), + #[cfg(feature = "bitcoin-rpc")] + ChainSource::BitcoinRpc(config) => { + bitcoin_rpc::accepts_broadcast_bitcoin_rpc(config, tx).map(Some) + } + #[allow(unreachable_patterns)] + _ => unreachable!("ChainSource must have at least one feature enabled"), + } + } + pub async fn fetch_fee_rate(&self, target_blocks: u16) -> Result { match self { #[cfg(feature = "esplora")] @@ -124,4 +145,48 @@ impl ChainSource { _ => unreachable!("ChainSource must have at least one feature enabled"), } } + + /// Return true when any provided outpoint is confirmed spent on chain. + /// + /// Mempool spends are intentionally ignored so cut-through recovery never + /// releases a reserved melt on an unconfirmed conflict. + pub(crate) async fn any_confirmed_spend(&self, outpoints: &[OutPoint]) -> Result { + match self { + #[cfg(feature = "esplora")] + ChainSource::Esplora(config) => { + esplora::any_confirmed_spend_esplora(config, outpoints).await + } + #[cfg(feature = "bitcoin-rpc")] + ChainSource::BitcoinRpc(config) => { + bitcoin_rpc::any_confirmed_spend_bitcoin_rpc(config, outpoints).await + } + #[allow(unreachable_patterns)] + _ => unreachable!("ChainSource must have at least one feature enabled"), + } + } +} + +#[cfg(all(test, feature = "esplora"))] +mod tests { + use bdk_wallet::bitcoin::absolute::LockTime; + use bdk_wallet::bitcoin::transaction::Version; + + use super::*; + + #[test] + fn esplora_has_no_test_broadcast_capability() { + // Esplora cannot dry-run accept-check, so it must report `None` so the + // payjoin caller falls back to the minimum-fee-rate floor instead. + let chain = ChainSource::Esplora(EsploraConfig { + url: "https://example.invalid".to_string(), + parallel_requests: 1, + }); + let tx = Transaction { + version: Version::TWO, + lock_time: LockTime::ZERO, + input: vec![], + output: vec![], + }; + assert_eq!(chain.accepts_broadcast(&tx).unwrap(), None); + } } diff --git a/crates/cdk-bdk/src/error.rs b/crates/cdk-bdk/src/error.rs index 73d5e52e81..3d192d1bef 100644 --- a/crates/cdk-bdk/src/error.rs +++ b/crates/cdk-bdk/src/error.rs @@ -27,6 +27,21 @@ pub enum Error { #[error("Unsupported payment type for onchain backend")] UnsupportedOnchain, + /// Payjoin protocol or IO error. + #[error("Payjoin error: {0}")] + Payjoin(String), + + /// Payjoin send failed before the signed original PSBT was shared with + /// the receiver. + /// + /// This is the only Payjoin send failure where the caller may safely fall + /// back to a direct onchain send: no signed transaction has been exposed, + /// so building a fresh transaction cannot double-spend or double-pay. Any + /// other Payjoin send error means the original PSBT is already in the + /// receiver's hands and must not be followed by a second transaction. + #[error("Payjoin send not started: {0}")] + PayjoinSendNotStarted(Box), + /// Wallet selected a `fee_index` outside the configured BDK fee options. #[error("unknown fee_index {0}; expected one of the configured BDK fee options")] UnknownFeeIndex(u32), diff --git a/crates/cdk-bdk/src/fee.rs b/crates/cdk-bdk/src/fee.rs index 47636dbed2..291f097bf5 100644 --- a/crates/cdk-bdk/src/fee.rs +++ b/crates/cdk-bdk/src/fee.rs @@ -12,7 +12,6 @@ //! final transaction fee is still determined by BDK at payment time. use std::cell::Cell; -use std::str::FromStr; use bdk_wallet::bitcoin::{ absolute, transaction, Amount as BitcoinAmount, FeeRate, Script, Transaction, TxIn, TxOut, @@ -26,6 +25,7 @@ use bdk_wallet::{KeychainKind, Utxo, WeightedUtxo}; use crate::error::Error; use crate::types::{FeeEstimationConfig, PaymentTier}; +use crate::util::parse_checked_address; use crate::CdkBdk; const P2WPKH_CHANGE_OUTPUT_VBYTES: u64 = 31; @@ -227,11 +227,8 @@ impl CdkBdk { }); let fee_rate = fee_rate_from_sat_per_vb(sat_per_vb)?; - let recipient_script = bdk_wallet::bitcoin::Address::from_str(address) - .map_err(|e| Error::Wallet(e.to_string()))? - .require_network(self.network) - .map_err(|e| Error::Wallet(e.to_string()))? - .script_pubkey(); + let recipient_script = + parse_checked_address(address, self.network, Error::Wallet)?.script_pubkey(); let (weighted_utxos, change_script) = { let wallet_with_db = self.wallet_with_db.lock().await; diff --git a/crates/cdk-bdk/src/lib.rs b/crates/cdk-bdk/src/lib.rs index 0a5c489b3f..1bb7eb2801 100644 --- a/crates/cdk-bdk/src/lib.rs +++ b/crates/cdk-bdk/src/lib.rs @@ -22,6 +22,7 @@ use bdk_wallet::{KeychainKind, PersistedWallet, Wallet}; use cdk_common::common::FeeReserve; use cdk_common::database::KVStore; use cdk_common::nuts::nut30::MeltQuoteOnchainFeeOption; +use cdk_common::payjoin::payjoin_v2_is_expired_at; use cdk_common::payment::{ CreateIncomingPaymentResponse, Event, IncomingPaymentOptions, MakePaymentResponse, MintPayment, OnchainSettings, OutgoingPaymentOptions, PaymentIdentifier, PaymentQuoteResponse, @@ -38,13 +39,14 @@ pub use crate::chain::{BitcoinRpcConfig, ChainSource, EsploraConfig}; pub use crate::error::Error; pub use crate::storage::{BdkStorage, FinalizedReceiveIntentRecord, FinalizedSendIntentRecord}; pub use crate::types::{ - BatchConfig, FeeEstimationConfig, PaymentMetadata, PaymentTier, SyncConfig, - DEFAULT_TARGET_BLOCK_TIME_SECS, + BatchConfig, FeeEstimationConfig, PayjoinConfig, PaymentMetadata, PaymentTier, SyncConfig, + DEFAULT_PAYJOIN_EXPIRY_SECS, DEFAULT_TARGET_BLOCK_TIME_SECS, }; pub mod chain; pub mod error; pub(crate) mod fee; +pub(crate) mod payjoin; pub mod receive; pub(crate) mod recovery; pub mod send; @@ -63,6 +65,16 @@ pub(crate) struct BackgroundTasks { pub(crate) cancel: CancellationToken, pub(crate) sync: JoinHandle<()>, pub(crate) batch: JoinHandle<()>, + pub(crate) payjoin_receive: Option>, + pub(crate) payjoin_send: Option>, +} + +#[derive(Clone)] +pub(crate) struct PayjoinOhttpKeysCache { + pub(crate) keys: ::payjoin::OhttpKeys, + pub(crate) fetched_at: u64, + pub(crate) directory_url: String, + pub(crate) ohttp_relay_url: String, } struct PaymentEventStream { @@ -146,6 +158,12 @@ pub struct CdkBdk { pub(crate) sync_config: SyncConfig, /// Cache for fee rate estimation: Tier -> (sat_per_vb, timestamp) pub(crate) fee_rate_cache: Arc>>, + /// Payjoin v2 configuration, when enabled by operator settings. + pub(crate) payjoin_config: Option, + /// Cache for Payjoin OHTTP keys fetched from the configured directory. + pub(crate) payjoin_ohttp_keys_cache: Arc>>, + /// Single-flight lock for OHTTP key fetches when the cache is empty or stale. + pub(crate) payjoin_ohttp_keys_fetch_lock: Arc>, } impl CdkBdk { @@ -154,10 +172,7 @@ impl CdkBdk { address: &str, amount_sat: u64, ) -> Result<(), Error> { - let address = bdk_wallet::bitcoin::Address::from_str(address) - .map_err(|e| Error::Wallet(e.to_string()))? - .require_network(self.network) - .map_err(|e| Error::Wallet(e.to_string()))?; + let address = crate::util::parse_checked_address(address, self.network, Error::Wallet)?; let dust_limit = bdk_wallet::bitcoin::TxOut::minimal_non_dust(address.script_pubkey()) .value @@ -246,6 +261,7 @@ impl CdkBdk { sync_interval_secs: u64, shutdown_timeout_secs: Option, sync_config: Option, + payjoin_config: Option, ) -> Result { let storage_dir_path = PathBuf::from(storage_dir_path); let storage_dir_path = storage_dir_path.join("bdk_wallet"); @@ -315,6 +331,86 @@ impl CdkBdk { sync_interval_secs, sync_config: sync_config.unwrap_or_default(), fee_rate_cache: Arc::new(Mutex::new(std::collections::HashMap::new())), + payjoin_config, + payjoin_ohttp_keys_cache: Arc::new(Mutex::new(None)), + payjoin_ohttp_keys_fetch_lock: Arc::new(Mutex::new(())), + }) + } + + async fn check_outgoing_payment_status_local( + &self, + payment_identifier: &PaymentIdentifier, + ) -> Result { + let quote_id = match payment_identifier { + PaymentIdentifier::QuoteId(id) => id.to_string(), + _ => return Err(Error::UnsupportedOnchain), + }; + + if let Some(record) = self.storage.get_send_intent_by_quote_id("e_id).await? { + let total_spent = match &record.state { + crate::send::payment_intent::record::SendIntentState::Pending { .. } + | crate::send::payment_intent::record::SendIntentState::BatchClaimed { .. } + | crate::send::payment_intent::record::SendIntentState::CutThroughReserved { + .. + } + | crate::send::payment_intent::record::SendIntentState::PayjoinNegotiating { + .. + } + | crate::send::payment_intent::record::SendIntentState::Batched { .. } => { + Amount::new(0, CurrencyUnit::Sat) + } + crate::send::payment_intent::record::SendIntentState::AwaitingConfirmation { + fee_contribution_sat, + .. + } => Amount::new(record.amount_sat + fee_contribution_sat, CurrencyUnit::Sat), + crate::send::payment_intent::record::SendIntentState::Failed { .. } => { + Amount::new(0, CurrencyUnit::Sat) + } + }; + let status = match record.state { + crate::send::payment_intent::record::SendIntentState::Pending { .. } + | crate::send::payment_intent::record::SendIntentState::BatchClaimed { .. } + | crate::send::payment_intent::record::SendIntentState::CutThroughReserved { + .. + } + | crate::send::payment_intent::record::SendIntentState::PayjoinNegotiating { + .. + } + | crate::send::payment_intent::record::SendIntentState::Batched { .. } + | crate::send::payment_intent::record::SendIntentState::AwaitingConfirmation { + .. + } => MeltQuoteState::Pending, + crate::send::payment_intent::record::SendIntentState::Failed { .. } => { + MeltQuoteState::Failed + } + }; + + return Ok(MakePaymentResponse { + payment_lookup_id: payment_identifier.clone(), + payment_proof: None, + status, + total_spent, + }); + } + + if let Some(record) = self + .storage + .get_finalized_intent_by_quote_id("e_id) + .await? + { + return Ok(MakePaymentResponse { + payment_lookup_id: payment_identifier.clone(), + payment_proof: Some(record.outpoint), + status: MeltQuoteState::Paid, + total_spent: Amount::new(record.total_spent_sat, CurrencyUnit::Sat), + }); + } + + Ok(MakePaymentResponse { + payment_lookup_id: payment_identifier.clone(), + payment_proof: None, + status: MeltQuoteState::Unknown, + total_spent: Amount::new(0, CurrencyUnit::Sat), }) } } @@ -400,6 +496,7 @@ impl MintPayment for CdkBdk { self.recover_receive_saga().await?; self.recover_send_saga().await?; + self.recover_payjoin_sessions_once().await?; let cancel = CancellationToken::new(); @@ -422,11 +519,39 @@ impl MintPayment for CdkBdk { }) .await; }); + let payjoin_receive_handle = if self.payjoin_config().is_some() { + let payjoin_self = self.clone(); + let payjoin_cancel = cancel.clone(); + Some(tokio::spawn(async move { + supervise("payjoin receive poller", payjoin_cancel, move |cancel| { + let me = payjoin_self.clone(); + async move { me.run_payjoin_receive_poller(cancel).await } + }) + .await; + })) + } else { + None + }; + let payjoin_send_handle = if self.payjoin_config().is_some() { + let payjoin_self = self.clone(); + let payjoin_cancel = cancel.clone(); + Some(tokio::spawn(async move { + supervise("payjoin send poller", payjoin_cancel, move |cancel| { + let me = payjoin_self.clone(); + async move { me.run_payjoin_send_poller(cancel).await } + }) + .await; + })) + } else { + None + }; *tasks_lock = Some(BackgroundTasks { cancel, sync: sync_handle, batch: batch_handle, + payjoin_receive: payjoin_receive_handle, + payjoin_send: payjoin_send_handle, }); Ok(()) @@ -445,16 +570,31 @@ impl MintPayment for CdkBdk { let sync_aborter = bg.sync.abort_handle(); let batch_aborter = bg.batch.abort_handle(); + let payjoin_receive_aborter = + bg.payjoin_receive.as_ref().map(|task| task.abort_handle()); + let payjoin_send_aborter = bg.payjoin_send.as_ref().map(|task| task.abort_handle()); let joined = tokio::time::timeout(self.shutdown_timeout, async move { let _ = bg.sync.await; let _ = bg.batch.await; + if let Some(task) = bg.payjoin_receive { + let _ = task.await; + } + if let Some(task) = bg.payjoin_send { + let _ = task.await; + } }) .await; if joined.is_err() { sync_aborter.abort(); batch_aborter.abort(); + if let Some(aborter) = payjoin_receive_aborter { + aborter.abort(); + } + if let Some(aborter) = payjoin_send_aborter { + aborter.abort(); + } tracing::error!( "cdk-bdk background tasks did not exit within {:?}; forced abort", self.shutdown_timeout @@ -494,6 +634,20 @@ impl MintPayment for CdkBdk { onchain_options.amount.clone().to_u64(), )?; let amount_sat = onchain_options.amount.clone().to_u64(); + let requested_payjoin = Self::requested_payjoin(onchain_options.metadata.as_deref()); + let payjoin_extra = match requested_payjoin { + Some(payjoin) => { + if payjoin_v2_is_expired_at(&payjoin, crate::util::unix_now()) { + return Err(cdk_common::payment::Error::InvalidExpiry); + } + if self.payjoin_config().is_some() { + Some(Self::accepted_payjoin_extra(&payjoin)) + } else { + None + } + } + None => None, + }; // Estimate fee_reserve for each configured tier so the mint presents // only the operator-enabled options. The configured order owns the @@ -528,7 +682,7 @@ impl MintPayment for CdkBdk { amount: onchain_options.amount, fee: Amount::new(cheapest.fee_reserve.into(), CurrencyUnit::Sat), state: MeltQuoteState::Unpaid, - extra_json: None, + extra_json: payjoin_extra, estimated_blocks: Some(cheapest.estimated_blocks), fee_options: Some(fee_options), }) @@ -547,6 +701,13 @@ impl MintPayment for CdkBdk { let address = onchain_options.address; let amount = onchain_options.amount; let quote_id = onchain_options.quote_id; + let requested_payjoin = Self::requested_payjoin(onchain_options.metadata.as_deref()); + if requested_payjoin + .as_ref() + .is_some_and(|payjoin| payjoin_v2_is_expired_at(payjoin, crate::util::unix_now())) + { + return Err(cdk_common::payment::Error::InvalidExpiry); + } self.validate_send_amount(&address, amount.clone().to_u64())?; @@ -563,6 +724,49 @@ impl MintPayment for CdkBdk { .tier_for_fee_index(onchain_options.fee_index) .map_err(Error::UnknownFeeIndex)?; let metadata = PaymentMetadata::from_optional_json(onchain_options.metadata.as_deref()); + if let Some(payjoin) = requested_payjoin { + if self.payjoin_config().is_some() { + match self + .start_payjoin_send( + "e_id, + &address, + amount_sat, + max_fee_sat, + tier, + metadata.clone(), + &payjoin, + ) + .await + { + // The send was prepared and persisted; the background poller + // drives the negotiation and broadcasts the Payjoin tx or + // the original fallback. + Ok(response) => return Ok(response), + // Only safe to fall back to a direct onchain send when the + // Payjoin attempt failed *before* the signed original PSBT + // was shared with the receiver. `start_payjoin_send` only + // posts via the poller, so all of its failures are + // pre-exposure and reported as `PayjoinSendNotStarted`. + Err(Error::PayjoinSendNotStarted(err)) => { + tracing::warn!( + quote_id = %quote_id, + error = %err, + "Optional Payjoin send could not be started; falling back to direct onchain send" + ); + } + Err(err) => { + tracing::error!( + quote_id = %quote_id, + error = %err, + "Payjoin send failed after the original PSBT was shared; not \ + falling back to a direct send to avoid double-spending" + ); + return Err(err.into()); + } + } + } + } + let fee_estimate = self .estimate_onchain_fee_reserve(&address, amount_sat, tier) .await?; @@ -617,14 +821,40 @@ impl MintPayment for CdkBdk { .wallet .reveal_next_address(KeychainKind::External); let address_str = address.address.to_string(); + let quote_id = onchain_options.quote_id; wallet_with_db.persist().map_err(|err| { tracing::error!("Could not persist to bdk db: {}", err); Error::BdkPersist })?; + drop(wallet_with_db); - let quote_id = onchain_options.quote_id; + let extra_json = match tokio::time::timeout( + Duration::from_secs(3), + self.create_payjoin_receive_extra("e_id, &address.address, 0), + ) + .await + { + Ok(Ok(extra_json)) => extra_json, + Ok(Err(err)) => { + tracing::warn!( + quote_id = %quote_id, + address = %address.address, + "Could not create optional Payjoin receive session: {}", + err + ); + None + } + Err(_) => { + tracing::warn!( + quote_id = %quote_id, + address = %address.address, + "Timed out creating optional Payjoin receive session" + ); + None + } + }; self.storage .track_receive_address(&address_str, "e_id.to_string()) @@ -634,7 +864,7 @@ impl MintPayment for CdkBdk { request_lookup_id: PaymentIdentifier::QuoteId(quote_id), request: address_str, expiry: None, - extra_json: None, + extra_json, }) } @@ -675,7 +905,7 @@ impl MintPayment for CdkBdk { results.push(WaitPaymentResponse { payment_identifier: payment_identifier.clone(), payment_amount: Amount::new(record.amount_sat, CurrencyUnit::Sat), - payment_id: record.outpoint, + payment_id: record.payment_id.unwrap_or(record.outpoint), }); } @@ -691,65 +921,31 @@ impl MintPayment for CdkBdk { _ => return Err(Error::UnsupportedOnchain.into()), }; - // 1. Check active intents + // 1. Drive active Payjoin intents. Normal runtime/background checks are + // allowed to advance negotiation or fall back to the original tx. if let Some(record) = self.storage.get_send_intent_by_quote_id("e_id).await? { - // `total_spent` is the actual amount spent (amount + fee) and is - // only reported once the payment has been made. Before the batch - // transaction has been built, the per-intent fee contribution is - // unknown, so we return `0` as a sentinel. This matches the - // convention used by other backends for non-terminal states. - let total_spent = match &record.state { - crate::send::payment_intent::record::SendIntentState::Pending { .. } - | crate::send::payment_intent::record::SendIntentState::Batched { .. } => { - Amount::new(0, CurrencyUnit::Sat) - } - crate::send::payment_intent::record::SendIntentState::AwaitingConfirmation { - fee_contribution_sat, - .. - } => Amount::new(record.amount_sat + fee_contribution_sat, CurrencyUnit::Sat), - crate::send::payment_intent::record::SendIntentState::Failed { .. } => { - Amount::new(0, CurrencyUnit::Sat) - } - }; - let status = match record.state { - crate::send::payment_intent::record::SendIntentState::Pending { .. } - | crate::send::payment_intent::record::SendIntentState::Batched { .. } - | crate::send::payment_intent::record::SendIntentState::AwaitingConfirmation { - .. - } => MeltQuoteState::Pending, - crate::send::payment_intent::record::SendIntentState::Failed { .. } => { - MeltQuoteState::Failed - } - }; - - return Ok(MakePaymentResponse { - payment_lookup_id: payment_identifier.clone(), - payment_proof: None, - status, - total_spent, - }); + if let crate::send::payment_intent::SendIntentAny::PayjoinNegotiating(intent) = + crate::send::payment_intent::from_record(&record) + { + self.process_payjoin_send_intent(intent).await?; + } + return Ok(self + .check_outgoing_payment_status_local(payment_identifier) + .await?); } - // 2. Check finalized tombstones - if let Some(record) = self - .storage - .get_finalized_intent_by_quote_id("e_id) - .await? - { - return Ok(MakePaymentResponse { - payment_lookup_id: payment_identifier.clone(), - payment_proof: Some(record.outpoint), - status: MeltQuoteState::Paid, - total_spent: Amount::new(record.total_spent_sat, CurrencyUnit::Sat), - }); - } + Ok(self + .check_outgoing_payment_status_local(payment_identifier) + .await?) + } - Ok(MakePaymentResponse { - payment_lookup_id: payment_identifier.clone(), - payment_proof: None, - status: MeltQuoteState::Unknown, - total_spent: Amount::new(0, CurrencyUnit::Sat), - }) + async fn check_outgoing_payment_status_only( + &self, + payment_identifier: &PaymentIdentifier, + ) -> Result { + Ok(self + .check_outgoing_payment_status_local(payment_identifier) + .await?) } fn is_payment_event_stream_active(&self) -> bool { @@ -772,6 +968,7 @@ mod tests { use bdk_wallet::keys::bip39::Mnemonic; use cdk_common::common::FeeReserve; use cdk_common::payment::MintPayment; + use futures::future::join_all; use futures::StreamExt; use super::*; @@ -833,11 +1030,63 @@ mod tests { sync_interval_secs, Some(shutdown_timeout_secs), None, + None, )?; Ok((backend, tmp)) } + /// Build a test instance with a Payjoin config so the send poller paths are + /// active. The directory/relay URLs are unreachable, which is fine: the + /// fallback/idempotency paths under test never reach the network (broadcast + /// goes to the bogus Esplora URL and is tolerated). + async fn build_test_instance_with_payjoin( + shutdown_timeout_secs: u64, + ) -> (CdkBdk, tempfile::TempDir) { + let tmp = tempfile::tempdir().expect("tempdir"); + let mnemonic = Mnemonic::from_str( + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", + ) + .expect("mnemonic"); + let kv = cdk_sqlite::mint::memory::empty() + .await + .expect("in-memory kv store"); + let chain_source = ChainSource::Esplora(EsploraConfig { + url: "http://127.0.0.1:1".to_string(), + parallel_requests: 1, + }); + let fee_reserve = FeeReserve { + min_fee_reserve: Amount::new(1, CurrencyUnit::Sat).into(), + percent_fee_reserve: 0.02, + }; + let payjoin_config = PayjoinConfig::new( + "http://127.0.0.1:1".to_string(), + "http://127.0.0.1:1".to_string(), + Some(3600), + ) + .expect("valid payjoin config"); + + let backend = CdkBdk::new( + mnemonic, + Network::Regtest, + chain_source, + tmp.path().to_string_lossy().into_owned(), + fee_reserve, + Arc::new(kv), + None, + 1, + 0, + 546, + 60, + Some(shutdown_timeout_secs), + None, + Some(payjoin_config), + ) + .expect("build payjoin CdkBdk test instance"); + + (backend, tmp) + } + async fn fund_backend_wallet(backend: &CdkBdk, amount_sat: u64) { let mut wallet_with_db = backend.wallet_with_db.lock().await; let funding_script = wallet_with_db @@ -1260,6 +1509,460 @@ mod tests { use cdk_common::QuoteId; use uuid::Uuid; + #[tokio::test] + async fn test_start_payjoin_send_pre_exposure_failure_is_recoverable() { + // A Payjoin send that fails before the original PSBT is shared with the + // receiver (here, because no Payjoin directory is configured) must be + // reported as `PayjoinSendNotStarted`. That is the only failure where + // `make_payment` may safely fall back to a direct onchain send; any + // other error means the original was already exposed and a second + // transaction could double-spend. + let (backend, _tmp) = build_test_instance_with_tempdir(5).await; + fund_backend_wallet(&backend, 100_000).await; + let quote_id = QuoteId::UUID(Uuid::new_v4()); + let payjoin = cdk_common::nuts::nut31::PayjoinV2::new( + "https://payjoin.example/pj".to_string(), + "QYPFLM8XL59R0XV4VGPLS7FRDSSM4TUXL07TXCWC4S0GLVLNK2SE4NQ", + "QV6WSX0UQPAEA0RH54430D0UVZWS8CZ6FEGZF4RGFCDKJLPGMYEJG", + crate::util::unix_now() + 3600, + ) + .expect("valid Payjoin keys"); + + let err = backend + .start_payjoin_send( + "e_id, + "bcrt1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080", + 10_000, + 1_000, + PaymentTier::Immediate, + PaymentMetadata::default(), + &payjoin, + ) + .await + .expect_err("payjoin send without a configured directory must fail"); + + assert!( + matches!(err, Error::PayjoinSendNotStarted(_)), + "pre-exposure failures must be recoverable, got {err:?}" + ); + } + + #[tokio::test] + async fn test_check_outgoing_payment_recovers_payjoin_intent_without_config() { + use crate::send::payment_intent::record::SendIntentState; + use crate::send::payment_intent::{state as intent_state, SendIntent}; + + let (backend, _tmp) = build_test_instance_with_tempdir(5).await; + let quote_id = QuoteId::UUID(Uuid::new_v4()); + let address = "bcrt1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080".to_string(); + let fallback_script = bdk_wallet::bitcoin::Address::from_str(&address) + .expect("valid fallback address") + .require_network(Network::Regtest) + .expect("regtest fallback address") + .script_pubkey(); + + let original_tx = Transaction { + version: transaction::Version::TWO, + lock_time: absolute::LockTime::ZERO, + input: vec![TxIn { + previous_output: OutPoint::new(Txid::all_zeros(), 0), + script_sig: Default::default(), + sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, + witness: Witness::new(), + }], + output: vec![TxOut { + value: bdk_wallet::bitcoin::Amount::from_sat(10_000), + script_pubkey: fallback_script, + }], + }; + + SendIntent::::new_payjoin( + &backend.storage, + quote_id.to_string(), + address, + 10_000, + 1_000, + PaymentTier::Immediate, + PaymentMetadata::default(), + bdk_wallet::bitcoin::consensus::serialize(&original_tx), + 500, + Vec::new(), + ) + .await + .expect("persist Payjoin negotiating intent"); + + let response = backend + .check_outgoing_payment(&PaymentIdentifier::QuoteId(quote_id.clone())) + .await + .expect("check outgoing payment"); + + assert_eq!(response.status, MeltQuoteState::Pending); + assert_eq!(response.total_spent, Amount::new(10_500, CurrencyUnit::Sat)); + + let stored = backend + .storage + .get_send_intent_by_quote_id("e_id.to_string()) + .await + .expect("lookup send intent") + .expect("send intent remains active"); + assert!( + matches!(stored.state, SendIntentState::AwaitingConfirmation { .. }), + "fallback recovery must move Payjoin intent into AwaitingConfirmation, got {:?}", + stored.state + ); + } + + #[tokio::test] + async fn test_status_only_payjoin_negotiating_intent_is_pending_without_progress() { + use crate::send::payment_intent::record::SendIntentState; + use crate::send::payment_intent::{state as intent_state, SendIntent}; + + let (backend, _tmp) = build_test_instance_with_tempdir(5).await; + let quote_id = QuoteId::UUID(Uuid::new_v4()); + let address = "bcrt1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080".to_string(); + let fallback_script = bdk_wallet::bitcoin::Address::from_str(&address) + .expect("valid fallback address") + .require_network(Network::Regtest) + .expect("regtest fallback address") + .script_pubkey(); + let original_tx = Transaction { + version: transaction::Version::TWO, + lock_time: absolute::LockTime::ZERO, + input: vec![TxIn { + previous_output: OutPoint::new(Txid::all_zeros(), 0), + script_sig: Default::default(), + sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, + witness: Witness::new(), + }], + output: vec![TxOut { + value: bdk_wallet::bitcoin::Amount::from_sat(10_000), + script_pubkey: fallback_script, + }], + }; + + SendIntent::::new_payjoin( + &backend.storage, + quote_id.to_string(), + address, + 10_000, + 1_000, + PaymentTier::Immediate, + PaymentMetadata::default(), + bdk_wallet::bitcoin::consensus::serialize(&original_tx), + 500, + Vec::new(), + ) + .await + .expect("persist Payjoin negotiating intent"); + + let response = backend + .check_outgoing_payment_status_only(&PaymentIdentifier::QuoteId(quote_id.clone())) + .await + .expect("status-only check"); + + assert_eq!(response.status, MeltQuoteState::Pending); + assert_eq!(response.total_spent, Amount::new(0, CurrencyUnit::Sat)); + + let stored = backend + .storage + .get_send_intent_by_quote_id("e_id.to_string()) + .await + .expect("lookup send intent") + .expect("send intent remains active"); + assert!( + matches!(stored.state, SendIntentState::PayjoinNegotiating { .. }), + "status-only recovery must not progress Payjoin intent, got {:?}", + stored.state + ); + } + + #[tokio::test] + async fn test_status_only_terminal_and_unknown_intents() { + use crate::send::payment_intent::SendIntent; + + let backend = build_test_instance(5).await; + let failed_quote_id = QuoteId::UUID(Uuid::new_v4()); + let pending = SendIntent::new( + &backend.storage, + failed_quote_id.to_string(), + "bcrt1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080".to_string(), + 30_000, + 2_000, + PaymentTier::Immediate, + PaymentMetadata::default(), + ) + .await + .expect("create Pending send intent"); + pending + .fail(&backend.storage, "fee too high".to_string()) + .await + .expect("transition Pending to Failed"); + + let failed = backend + .check_outgoing_payment_status_only(&PaymentIdentifier::QuoteId( + failed_quote_id.clone(), + )) + .await + .expect("status-only failed"); + assert_eq!(failed.status, MeltQuoteState::Failed); + assert_eq!(failed.total_spent, Amount::new(0, CurrencyUnit::Sat)); + + let paid_quote_id = QuoteId::UUID(Uuid::new_v4()); + let pending = SendIntent::new( + &backend.storage, + paid_quote_id.to_string(), + "bcrt1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080".to_string(), + 40_000, + 2_000, + PaymentTier::Immediate, + PaymentMetadata::default(), + ) + .await + .expect("create Pending send intent"); + let awaiting = pending + .assign_to_batch(&backend.storage, Uuid::new_v4()) + .await + .expect("transition Pending to Batched") + .mark_broadcast( + &backend.storage, + "deadbeef".to_string(), + "deadbeef:1".to_string(), + 321, + ) + .await + .expect("transition Batched to AwaitingConfirmation"); + awaiting + .finalize(&backend.storage) + .await + .expect("finalize send intent"); + + let paid = backend + .check_outgoing_payment_status_only(&PaymentIdentifier::QuoteId(paid_quote_id)) + .await + .expect("status-only paid"); + assert_eq!(paid.status, MeltQuoteState::Paid); + assert_eq!(paid.payment_proof.as_deref(), Some("deadbeef:1")); + assert_eq!(paid.total_spent, Amount::new(40_321, CurrencyUnit::Sat)); + + let unknown = backend + .check_outgoing_payment_status_only(&PaymentIdentifier::QuoteId(QuoteId::UUID( + Uuid::new_v4(), + ))) + .await + .expect("status-only unknown"); + assert_eq!(unknown.status, MeltQuoteState::Unknown); + assert_eq!(unknown.total_spent, Amount::new(0, CurrencyUnit::Sat)); + } + + #[tokio::test] + async fn test_ohttp_key_fetch_is_single_flight() { + let _guard = crate::payjoin::lock_test_ohttp_fetch().await; + crate::payjoin::configure_test_ohttp_fetch(Duration::from_millis(100), false); + let (backend, _tmp) = build_test_instance_with_payjoin(5).await; + let address = + bdk_wallet::bitcoin::Address::from_str("bcrt1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080") + .expect("valid fallback address") + .require_network(Network::Regtest) + .expect("regtest fallback address"); + + let futures = (0..8).map(|_| { + let backend = backend.clone(); + let address = address.clone(); + let quote_id = QuoteId::UUID(Uuid::new_v4()); + async move { + backend + .create_payjoin_receive_extra("e_id, &address, 0) + .await + } + }); + + let results = join_all(futures).await; + let fetch_calls = crate::payjoin::test_ohttp_fetch_calls(); + crate::payjoin::disable_test_ohttp_fetch(); + + assert!(results.iter().all(Result::is_ok)); + assert_eq!( + fetch_calls, 1, + "concurrent cache misses must collapse to one OHTTP key fetch" + ); + } + + #[tokio::test] + async fn test_create_incoming_payment_request_falls_back_on_ohttp_timeout() { + let _guard = crate::payjoin::lock_test_ohttp_fetch().await; + crate::payjoin::configure_test_ohttp_fetch(Duration::from_secs(5), false); + let (backend, _tmp) = build_test_instance_with_payjoin(5).await; + let quote_id = QuoteId::UUID(Uuid::new_v4()); + let started = std::time::Instant::now(); + + let response = backend + .create_incoming_payment_request(IncomingPaymentOptions::Onchain( + cdk_common::payment::OnchainIncomingPaymentOptions { quote_id }, + )) + .await + .expect("plain onchain quote should be returned"); + crate::payjoin::disable_test_ohttp_fetch(); + + assert!( + started.elapsed() < Duration::from_secs(4), + "quote creation should be bounded by the Payjoin metadata timeout" + ); + assert!(response.extra_json.is_none()); + } + + #[tokio::test] + async fn test_recover_payjoin_receive_sessions_without_config() { + use crate::storage::PayjoinReceiveSessionRecord; + + let (backend, _tmp) = build_test_instance_with_tempdir(5).await; + let now = crate::util::unix_now(); + let expired_quote = QuoteId::UUID(Uuid::new_v4()).to_string(); + let old_closed_quote = QuoteId::UUID(Uuid::new_v4()).to_string(); + let open_quote = QuoteId::UUID(Uuid::new_v4()).to_string(); + + backend + .storage + .put_payjoin_receive_session(&PayjoinReceiveSessionRecord { + quote_id: expired_quote.clone(), + fallback_address: "bcrt1qexpired".to_string(), + amount_sat: 1_000, + proposal_receiver_outpoints: Vec::new(), + expires_at: now.saturating_sub(1), + events: Vec::new(), + closed: false, + }) + .await + .expect("store expired session"); + + backend + .storage + .put_payjoin_receive_session(&PayjoinReceiveSessionRecord { + quote_id: old_closed_quote.clone(), + fallback_address: "bcrt1qclosed".to_string(), + amount_sat: 1_000, + proposal_receiver_outpoints: Vec::new(), + expires_at: now.saturating_sub(7 * 24 * 60 * 60).saturating_sub(1), + events: Vec::new(), + closed: true, + }) + .await + .expect("store old closed session"); + + backend + .storage + .put_payjoin_receive_session(&PayjoinReceiveSessionRecord { + quote_id: open_quote.clone(), + fallback_address: "bcrt1qopen".to_string(), + amount_sat: 1_000, + proposal_receiver_outpoints: Vec::new(), + expires_at: now + 60, + events: Vec::new(), + closed: false, + }) + .await + .expect("store open session"); + + backend + .recover_payjoin_sessions_once() + .await + .expect("recover payjoin sessions"); + + let expired = backend + .storage + .get_payjoin_receive_session(&expired_quote) + .await + .expect("lookup expired") + .expect("expired session remains"); + assert!(expired.closed, "expired session should be closed"); + + assert!( + backend + .storage + .get_payjoin_receive_session(&old_closed_quote) + .await + .expect("lookup old closed") + .is_none(), + "old closed session should be pruned" + ); + + let open = backend + .storage + .get_payjoin_receive_session(&open_quote) + .await + .expect("lookup open") + .expect("open session remains"); + assert!(!open.closed, "unexpired open session should stay open"); + } + + #[tokio::test] + async fn test_process_payjoin_send_intent_unreplayable_broadcasts_original_fallback() { + // An empty/unreplayable event log (e.g. an expired session) must cause + // the poller to broadcast the stored signed original as the fallback, + // and stage the existing intent for the quote. + use crate::send::payment_intent::record::SendIntentState; + use crate::send::payment_intent::{state as intent_state, SendIntent}; + + let (backend, _tmp) = build_test_instance_with_payjoin(5).await; + let quote_id = QuoteId::UUID(Uuid::new_v4()); + let address = "bcrt1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080".to_string(); + let fallback_script = bdk_wallet::bitcoin::Address::from_str(&address) + .expect("valid fallback address") + .require_network(Network::Regtest) + .expect("regtest fallback address") + .script_pubkey(); + + let original_tx = Transaction { + version: transaction::Version::TWO, + lock_time: absolute::LockTime::ZERO, + input: vec![TxIn { + previous_output: OutPoint::new(Txid::all_zeros(), 0), + script_sig: Default::default(), + sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, + witness: Witness::new(), + }], + output: vec![TxOut { + value: bdk_wallet::bitcoin::Amount::from_sat(10_000), + script_pubkey: fallback_script, + }], + }; + let original_tx_bytes = bdk_wallet::bitcoin::consensus::serialize(&original_tx); + + let intent = SendIntent::::new_payjoin( + &backend.storage, + quote_id.to_string(), + address, + 10_000, + 1_000, + PaymentTier::Immediate, + PaymentMetadata::default(), + original_tx_bytes, + 500, + // Empty event log: `replay_event_log` returns an error, so the + // poller takes the original-fallback path. + Vec::new(), + ) + .await + .expect("persist payjoin intent"); + + backend + .process_payjoin_send_intent(intent) + .await + .expect("process intent"); + + let intent = backend + .storage + .get_send_intent_by_quote_id("e_id.to_string()) + .await + .expect("lookup send intent") + .expect("fallback must keep the send intent"); + assert_eq!(intent.amount_sat, 10_000); + assert!( + matches!(intent.state, SendIntentState::AwaitingConfirmation { .. }), + "fallback must move Payjoin intent into AwaitingConfirmation, got {:?}", + intent.state + ); + } + /// Build an onchain outgoing payment option with a fresh quote id. fn onchain_options_for(amount_sat: u64) -> (QuoteId, OutgoingPaymentOptions) { let quote_id = QuoteId::UUID(Uuid::new_v4()); diff --git a/crates/cdk-bdk/src/payjoin.rs b/crates/cdk-bdk/src/payjoin.rs new file mode 100644 index 0000000000..046707e879 --- /dev/null +++ b/crates/cdk-bdk/src/payjoin.rs @@ -0,0 +1,2974 @@ +//! Payjoin support for the BDK on-chain backend. + +use std::collections::HashSet; +use std::str::FromStr; +use std::sync::atomic::{AtomicBool, Ordering}; +#[cfg(test)] +use std::sync::atomic::{AtomicU64, AtomicUsize}; +use std::sync::{Arc, Mutex as StdMutex}; +use std::time::Duration; + +use bdk_wallet::bitcoin::{ + consensus, FeeRate, OutPoint, Script, Sequence, Transaction, TxIn, TxOut, +}; +use bdk_wallet::KeychainKind; +use cdk_common::nuts::nut31::PayjoinV2; +use cdk_common::payjoin::{ + format_bip21_amount_from_sats, payjoin_v2_from_bip77_endpoint, payjoin_v2_to_bip77_endpoint, +}; +use cdk_common::payment::{Event, MakePaymentResponse, PaymentIdentifier, WaitPaymentResponse}; +use cdk_common::{Amount, CurrencyUnit, MeltQuoteState}; +use tokio_util::sync::CancellationToken; +use uuid::Uuid; + +use crate::error::Error; +use crate::receive::payjoin_session::{self, PayjoinReceiveSession, PayjoinReceiveSessionAny}; +use crate::send::batch_transaction::record::{ + BatchOutputAssignment, SendBatchRecord, SendBatchState, +}; +use crate::send::payment_intent::{state as intent_state, SendIntent}; +use crate::types::{PayjoinConfig, PaymentMetadata, PaymentTier}; +use crate::util::parse_checked_address; +use crate::CdkBdk; + +const PAYJOIN_RECEIVE_SESSION_RETENTION_SECS: u64 = 7 * 24 * 60 * 60; +const PAYJOIN_OHTTP_KEYS_CACHE_TTL_SECS: u64 = 10 * 60; +const PAYJOIN_OHTTP_KEYS_FETCH_TIMEOUT: Duration = Duration::from_secs(3); +const PAYJOIN_HTTP_REQUEST_TIMEOUT: Duration = Duration::from_secs(35); +const PAYJOIN_RECEIVER_MAX_EFFECTIVE_FEE_RATE: FeeRate = FeeRate::ZERO; +/// Minimum fee rate enforced on a sender's original PSBT during the +/// broadcast-suitability check. On backends without `testmempoolaccept` (Esplora) +/// this floor is the primary anti-probing protection; on Bitcoin Core it is an +/// additional constraint on top of the full mempool-acceptance check. +const PAYJOIN_RECEIVER_MIN_ORIGINAL_FEE_RATE: FeeRate = FeeRate::from_sat_per_vb_u32(1); +#[cfg(test)] +const TEST_OHTTP_KEYS: &str = "QYPFLM8XL59R0XV4VGPLS7FRDSSM4TUXL07TXCWC4S0GLVLNK2SE4NQ"; +#[cfg(test)] +static TEST_OHTTP_FETCH_ENABLED: AtomicBool = AtomicBool::new(false); +#[cfg(test)] +static TEST_OHTTP_FETCH_FAIL: AtomicBool = AtomicBool::new(false); +#[cfg(test)] +static TEST_OHTTP_FETCH_DELAY_MS: AtomicU64 = AtomicU64::new(0); +#[cfg(test)] +static TEST_OHTTP_FETCH_CALLS: AtomicUsize = AtomicUsize::new(0); +#[cfg(test)] +static TEST_OHTTP_FETCH_TEST_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); + +#[cfg(test)] +pub(crate) async fn lock_test_ohttp_fetch() -> tokio::sync::MutexGuard<'static, ()> { + TEST_OHTTP_FETCH_TEST_LOCK.lock().await +} + +#[cfg(test)] +pub(crate) fn configure_test_ohttp_fetch(delay: Duration, fail: bool) { + TEST_OHTTP_FETCH_ENABLED.store(true, Ordering::SeqCst); + TEST_OHTTP_FETCH_FAIL.store(fail, Ordering::SeqCst); + TEST_OHTTP_FETCH_DELAY_MS.store(delay.as_millis() as u64, Ordering::SeqCst); + TEST_OHTTP_FETCH_CALLS.store(0, Ordering::SeqCst); +} + +#[cfg(test)] +pub(crate) fn disable_test_ohttp_fetch() { + TEST_OHTTP_FETCH_ENABLED.store(false, Ordering::SeqCst); + TEST_OHTTP_FETCH_FAIL.store(false, Ordering::SeqCst); + TEST_OHTTP_FETCH_DELAY_MS.store(0, Ordering::SeqCst); + TEST_OHTTP_FETCH_CALLS.store(0, Ordering::SeqCst); +} + +#[cfg(test)] +pub(crate) fn test_ohttp_fetch_calls() -> usize { + TEST_OHTTP_FETCH_CALLS.load(Ordering::SeqCst) +} + +#[derive(Debug, Clone)] +struct RecordingSessionPersister { + events: Arc>>, + closed: Arc, +} + +impl RecordingSessionPersister +where + E: Clone, +{ + fn new(events: Vec, closed: bool) -> Self { + Self { + events: Arc::new(StdMutex::new(events)), + closed: Arc::new(AtomicBool::new(closed)), + } + } + + fn events(&self) -> Result, Error> { + self.events + .lock() + .map(|events| events.clone()) + .map_err(|err| Error::Payjoin(format!("Payjoin session lock poisoned: {}", err))) + } + + fn closed(&self) -> bool { + self.closed.load(Ordering::SeqCst) + } + + fn replace(&self, events: Vec, closed: bool) -> Result<(), Error> { + *self + .events + .lock() + .map_err(|err| Error::Payjoin(format!("Payjoin session lock poisoned: {}", err)))? = + events; + self.closed.store(closed, Ordering::SeqCst); + Ok(()) + } +} + +impl ::payjoin::persist::SessionPersister for RecordingSessionPersister +where + E: Clone + Send + Sync + 'static, +{ + type InternalStorageError = Error; + type SessionEvent = E; + + fn save_event(&self, event: Self::SessionEvent) -> Result<(), Self::InternalStorageError> { + self.events + .lock() + .map_err(|err| Error::Payjoin(format!("Payjoin session lock poisoned: {}", err)))? + .push(event); + + Ok(()) + } + + fn load( + &self, + ) -> Result>, Self::InternalStorageError> { + let events = self + .events + .lock() + .map(|events| events.clone()) + .map_err(|err| Error::Payjoin(format!("Payjoin session lock poisoned: {}", err)))?; + + Ok(Box::new(events.into_iter())) + } + + fn close(&self) -> Result<(), Self::InternalStorageError> { + self.closed.store(true, Ordering::SeqCst); + Ok(()) + } +} + +/// Pre-exposure state for a Payjoin send: everything built and signed before +/// the original PSBT is shared with the receiver. The `Sender` itself is not +/// returned — it is saved into `persister`'s event log, from which the +/// background poller replays it; the persisted events plus the signed original +/// are all the poller needs to drive (and resume) the session. +struct PreparedPayjoinSend { + /// The signed original transaction, broadcastable as the Payjoin fallback. + original_tx: Transaction, + original_fee_sat: u64, + persister: RecordingSessionPersister<::payjoin::send::v2::SessionEvent>, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct PayjoinSendValidation { + /// The single receiver-script output used for the melt payment proof. + payment_outpoint: OutPoint, + /// The mint wallet's net spend above the quoted receiver amount. + fee_contribution_sat: u64, +} + +struct PayjoinReceiveProposal { + proposal: ::payjoin::receive::v2::Receiver<::payjoin::receive::v2::PayjoinProposal>, + cut_through: Option, +} + +#[derive(Clone)] +struct CutThroughProposal { + settlement_id: Uuid, + proposal_tx: Transaction, + original_tx: Transaction, + receive_payment_id: String, + receive_outpoint: String, + melt_outpoint: String, + fee_contribution_sat: u64, +} + +impl CdkBdk { + pub(crate) fn requested_payjoin(metadata: Option<&str>) -> Option { + let value = metadata + .and_then(|metadata| serde_json::from_str::(metadata).ok())?; + value + .get("payjoin") + .cloned() + .and_then(|value| serde_json::from_value(value).ok()) + } + pub(crate) fn accepted_payjoin_extra(payjoin: &PayjoinV2) -> serde_json::Value { + serde_json::json!({ + "payjoin": payjoin, + }) + } + pub(crate) async fn create_payjoin_receive_extra( + &self, + quote_id: &cdk_common::QuoteId, + address: &bdk_wallet::bitcoin::Address, + amount_sat: u64, + ) -> Result, Error> { + let Some(config) = self.payjoin_config() else { + return Ok(None); + }; + + let ohttp_keys = self.cached_ohttp_keys(config).await?; + let persister = RecordingSessionPersister::new(Vec::new(), false); + let mut receiver_builder = ::payjoin::receive::v2::ReceiverBuilder::new( + address.clone(), + config.directory_url.clone(), + ohttp_keys.clone(), + ) + .map_err(|err| Error::Payjoin(err.to_string()))? + .with_expiration(Duration::from_secs(config.expiry_secs)); + if amount_sat > 0 { + receiver_builder = + receiver_builder.with_amount(bdk_wallet::bitcoin::Amount::from_sat(amount_sat)); + } + let receiver = receiver_builder + .build() + .save(&persister) + .map_err(|err| Error::Payjoin(err.to_string()))?; + + let pj_uri = receiver.pj_uri().to_string(); + let endpoint = extract_bip21_payjoin_endpoint(&pj_uri)?; + let payjoin = payjoin_v2_from_bip77_endpoint(&endpoint) + .map_err(|err| Error::Payjoin(err.to_string()))?; + + let session = PayjoinReceiveSession::new(crate::storage::PayjoinReceiveSessionRecord { + quote_id: quote_id.to_string(), + fallback_address: address.to_string(), + amount_sat, + proposal_receiver_outpoints: Vec::new(), + expires_at: payjoin.expires_at, + events: persister.events()?, + closed: persister.closed(), + }); + session.persist(&self.storage).await?; + + tracing::debug!( + quote_id = %quote_id, + fallback_address = %address, + amount_sat, + endpoint = %payjoin.endpoint, + expires_at = payjoin.expires_at, + "Created Payjoin receive session" + ); + + Ok(Some(serde_json::json!({ "payjoin": payjoin }))) + } + + pub(crate) async fn run_payjoin_receive_poller( + &self, + cancel_token: CancellationToken, + ) -> Result<(), Error> { + let mut tick = tokio::time::interval(Duration::from_secs(15)); + tracing::info!("Starting Payjoin receive poller"); + loop { + tokio::select! { + _ = cancel_token.cancelled() => break, + _ = tick.tick() => { + let now = crate::util::unix_now(); + let sessions = self.storage.get_all_payjoin_receive_sessions().await?; + let active_count = sessions + .iter() + .filter(|record| !record.closed && record.expires_at >= now) + .count(); + tracing::debug!( + session_count = sessions.len(), + active_count, + "Polling Payjoin receive sessions" + ); + for record in sessions { + if let Err(err) = self.handle_payjoin_receive_session_once(record, now).await { + tracing::warn!("Payjoin receive session processing failed: {}", err); + } + } + } + } + } + Ok(()) + } + + async fn recover_payjoin_receive_sessions_once(&self) -> Result<(), Error> { + let now = crate::util::unix_now(); + let sessions = self.storage.get_all_payjoin_receive_sessions().await?; + + for record in sessions { + if let Err(err) = self.handle_payjoin_receive_session_once(record, now).await { + tracing::warn!("Payjoin receive session recovery failed: {}", err); + } + } + + Ok(()) + } + + async fn handle_payjoin_receive_session_once( + &self, + record: crate::storage::PayjoinReceiveSessionRecord, + now: u64, + ) -> Result<(), Error> { + match payjoin_session::from_record(record) { + PayjoinReceiveSessionAny::Closed(session) => { + if session.should_prune(now, PAYJOIN_RECEIVE_SESSION_RETENTION_SECS) { + tracing::debug!( + quote_id = %session.record().quote_id, + expires_at = session.record().expires_at, + now, + "Pruning closed Payjoin receive session" + ); + self.storage + .delete_payjoin_receive_session(&session.record().quote_id) + .await?; + } else { + tracing::trace!( + quote_id = %session.record().quote_id, + "Skipping closed Payjoin receive session" + ); + } + } + PayjoinReceiveSessionAny::Open(session) => { + if session.is_expired(now) { + tracing::debug!( + quote_id = %session.record().quote_id, + expires_at = session.record().expires_at, + now, + "Closing expired Payjoin receive session" + ); + session.close(&self.storage).await?; + return Ok(()); + } + + if self.payjoin_config().is_none() { + tracing::trace!( + quote_id = %session.record().quote_id, + "Payjoin receive config unavailable; leaving open session for fallback address detection" + ); + return Ok(()); + } + + tracing::debug!( + quote_id = %session.record().quote_id, + fallback_address = %session.record().fallback_address, + event_count = session.record().events.len(), + "Processing Payjoin receive session" + ); + self.process_payjoin_receive_session(session.into_record()) + .await?; + } + } + + Ok(()) + } + + async fn process_payjoin_receive_session( + &self, + mut record: crate::storage::PayjoinReceiveSessionRecord, + ) -> Result<(), Error> { + use ::payjoin::persist::OptionalTransitionOutcome; + + let Some(config) = self.payjoin_config() else { + return Ok(()); + }; + let fallback_address = + parse_checked_address(&record.fallback_address, self.network, Error::Payjoin)?; + let fallback_script = fallback_address.script_pubkey(); + let persister = RecordingSessionPersister::new(record.events.clone(), record.closed); + let (session, _history) = ::payjoin::receive::v2::replay_event_log(&persister) + .map_err(|err| Error::Payjoin(err.to_string()))?; + tracing::debug!( + quote_id = %record.quote_id, + state = payjoin_receive_session_state_name(&session), + event_count = record.events.len(), + "Replayed Payjoin receive session" + ); + let mut closed = record.closed; + + let result: Result<(), Error> = async { + let payjoin_proposal = match session { + ::payjoin::receive::v2::ReceiveSession::Initialized(receiver) => { + tracing::debug!( + quote_id = %record.quote_id, + "Polling Payjoin directory for original PSBT" + ); + let (request, context) = receiver + .create_poll_request(&config.ohttp_relay_url) + .map_err(|err| Error::Payjoin(err.to_string()))?; + let response = payjoin_http_request(request).await?; + match receiver + .process_response(&response, context) + .save(&persister) + .map_err(|err| Error::Payjoin(err.to_string()))? + { + OptionalTransitionOutcome::Progress(unchecked) => { + tracing::debug!( + quote_id = %record.quote_id, + "Received Payjoin original PSBT" + ); + Some( + self.accept_payjoin_receive_proposal( + unchecked, + &fallback_script, + &record.quote_id, + &persister, + ) + .await?, + ) + } + OptionalTransitionOutcome::Stasis(_) => { + tracing::debug!( + quote_id = %record.quote_id, + "No Payjoin original PSBT available yet" + ); + None + } + } + } + ::payjoin::receive::v2::ReceiveSession::UncheckedOriginalPayload(unchecked) => { + Some( + self.accept_payjoin_receive_proposal( + unchecked, + &fallback_script, + &record.quote_id, + &persister, + ) + .await?, + ) + } + ::payjoin::receive::v2::ReceiveSession::MaybeInputsOwned(receiver) => { + let receiver = self + .check_payjoin_inputs_not_owned(receiver, &persister) + .await?; + Some( + self.accept_payjoin_checked_inputs( + receiver, + &fallback_script, + &record.quote_id, + &persister, + ) + .await?, + ) + } + ::payjoin::receive::v2::ReceiveSession::MaybeInputsSeen(receiver) => Some( + self.accept_payjoin_checked_inputs( + receiver, + &fallback_script, + &record.quote_id, + &persister, + ) + .await?, + ), + ::payjoin::receive::v2::ReceiveSession::OutputsUnknown(receiver) => { + let receiver = self.identify_payjoin_receiver_outputs( + receiver, + &fallback_script, + &record.quote_id, + &persister, + )?; + Some( + self.accept_payjoin_wants_outputs(receiver, &record.quote_id, &persister) + .await?, + ) + } + ::payjoin::receive::v2::ReceiveSession::WantsOutputs(receiver) => Some( + self.accept_payjoin_wants_outputs(receiver, &record.quote_id, &persister) + .await?, + ), + ::payjoin::receive::v2::ReceiveSession::WantsInputs(receiver) => { + let receiver = self.contribute_payjoin_inputs(receiver, &persister).await?; + Some(PayjoinReceiveProposal { + proposal: self.finalize_payjoin_proposal(receiver, &persister).await?, + cut_through: None, + }) + } + ::payjoin::receive::v2::ReceiveSession::WantsFeeRange(receiver) => { + let receiver = apply_zero_receiver_fee_range(receiver, &persister)?; + Some(PayjoinReceiveProposal { + proposal: self.finalize_payjoin_proposal(receiver, &persister).await?, + cut_through: None, + }) + } + ::payjoin::receive::v2::ReceiveSession::ProvisionalProposal(receiver) => { + Some(PayjoinReceiveProposal { + proposal: self.finalize_payjoin_proposal(receiver, &persister).await?, + cut_through: None, + }) + } + ::payjoin::receive::v2::ReceiveSession::PayjoinProposal(proposal) => { + Some(PayjoinReceiveProposal { + proposal, + cut_through: None, + }) + } + ::payjoin::receive::v2::ReceiveSession::HasReplyableError(receiver) => { + if let Some(error_reply) = + latest_payjoin_receive_replyable_error(&persister.events()?) + { + tracing::warn!( + quote_id = %record.quote_id, + error_reply = %error_reply, + "Sending Payjoin receiver rejection to sender" + ); + } + let (request, context) = receiver + .create_error_request(&config.ohttp_relay_url) + .map_err(|err| Error::Payjoin(err.to_string()))?; + let response = payjoin_http_request(request).await?; + receiver + .process_error_response(&response, context) + .save(&persister) + .map_err(|err| Error::Payjoin(err.to_string()))?; + closed = true; + None + } + ::payjoin::receive::v2::ReceiveSession::Closed(_) => { + closed = true; + None + } + _ => None, + }; + + if let Some(payjoin_proposal) = payjoin_proposal { + let PayjoinReceiveProposal { + proposal, + cut_through, + } = payjoin_proposal; + update_payjoin_receive_credit_cap(&mut record); + if cut_through.is_none() { + if let Err(err) = ensure_payjoin_receiver_credit( + proposal.psbt(), + &fallback_script, + record.amount_sat, + ) { + closed = true; + return Err(err); + } + update_payjoin_receive_proposal_receiver_outpoints( + &mut record, + proposal.psbt(), + &fallback_script, + ); + } + tracing::debug!( + quote_id = %record.quote_id, + "Posting Payjoin proposal response" + ); + self.persist_payjoin_receive_session_progress(&mut record, &persister, closed) + .await?; + if let Some(cut_through) = cut_through.as_ref() { + self.persist_cut_through_exposure(cut_through).await?; + } + + let (request, context) = proposal + .create_post_request(&config.ohttp_relay_url) + .map_err(|err| Error::Payjoin(err.to_string()))?; + let response = payjoin_http_request(request).await?; + proposal + .process_response(&response, context) + .save(&persister) + .map_err(|err| Error::Payjoin(err.to_string()))?; + } + + Ok(()) + } + .await; + + if result.is_err() { + if let Some(error_reply) = latest_payjoin_receive_replyable_error(&persister.events()?) + { + tracing::warn!( + quote_id = %record.quote_id, + error_reply = %error_reply, + "Payjoin receiver rejected original PSBT" + ); + } + } + self.persist_payjoin_receive_session_progress(&mut record, &persister, closed) + .await?; + result + } + async fn persist_payjoin_receive_session_progress( + &self, + record: &mut crate::storage::PayjoinReceiveSessionRecord, + persister: &RecordingSessionPersister<::payjoin::receive::v2::SessionEvent>, + closed: bool, + ) -> Result<(), Error> { + record.events = persister.events()?; + update_payjoin_receive_credit_cap(record); + record.closed = closed || persister.closed(); + self.storage.put_payjoin_receive_session(record).await + } + async fn accept_payjoin_receive_proposal( + &self, + unchecked: ::payjoin::receive::v2::Receiver< + ::payjoin::receive::v2::UncheckedOriginalPayload, + >, + fallback_script: &bdk_wallet::bitcoin::Script, + quote_id: &str, + persister: &RecordingSessionPersister<::payjoin::receive::v2::SessionEvent>, + ) -> Result { + // The mint is a non-interactive receiver (auto-published URI per quote), + // so validate the original is broadcastable before advancing — this is the + // probing/poisoning defense (inputs are only recorded as seen afterwards). + let chain_source = &self.chain_source; + let can_broadcast = + move |tx: &Transaction| -> Result { + match chain_source + .accepts_broadcast(tx) + .map_err(::payjoin::ImplementationError::new)? + { + // Bitcoin Core: trust the testmempoolaccept verdict. + Some(allowed) => Ok(allowed), + // Esplora (no dry-run): rely on the enforced minimum fee rate. + None => Ok(true), + } + }; + let receiver = unchecked + .check_broadcast_suitability( + Some(PAYJOIN_RECEIVER_MIN_ORIGINAL_FEE_RATE), + can_broadcast, + ) + .save(persister) + .map_err(|err| Error::Payjoin(err.to_string()))?; + + let receiver = self + .check_payjoin_inputs_not_owned(receiver, persister) + .await?; + + self.accept_payjoin_checked_inputs(receiver, fallback_script, quote_id, persister) + .await + } + async fn accept_payjoin_checked_inputs( + &self, + receiver: ::payjoin::receive::v2::Receiver<::payjoin::receive::v2::MaybeInputsSeen>, + fallback_script: &bdk_wallet::bitcoin::Script, + quote_id: &str, + persister: &RecordingSessionPersister<::payjoin::receive::v2::SessionEvent>, + ) -> Result { + let receiver = self + .check_payjoin_inputs_not_seen(receiver, quote_id, persister) + .await?; + let receiver = + self.identify_payjoin_receiver_outputs(receiver, fallback_script, quote_id, persister)?; + + self.accept_payjoin_wants_outputs(receiver, quote_id, persister) + .await + } + async fn accept_payjoin_wants_outputs( + &self, + receiver: ::payjoin::receive::v2::Receiver<::payjoin::receive::v2::WantsOutputs>, + quote_id: &str, + persister: &RecordingSessionPersister<::payjoin::receive::v2::SessionEvent>, + ) -> Result { + if let Some(cut_through) = self + .try_build_cut_through_receive_proposal(receiver.clone(), quote_id, persister) + .await? + { + return Ok(cut_through); + } + + let receiver = receiver + .commit_outputs() + .save(persister) + .map_err(|err| Error::Payjoin(err.to_string()))?; + let receiver = self.contribute_payjoin_inputs(receiver, persister).await?; + + Ok(PayjoinReceiveProposal { + proposal: self.finalize_payjoin_proposal(receiver, persister).await?, + cut_through: None, + }) + } + + async fn try_build_cut_through_receive_proposal( + &self, + receiver: ::payjoin::receive::v2::Receiver<::payjoin::receive::v2::WantsOutputs>, + quote_id: &str, + persister: &RecordingSessionPersister<::payjoin::receive::v2::SessionEvent>, + ) -> Result, Error> { + let events = persister.events()?; + let Some(original_receive_amount_sat) = + payjoin_original_receiver_output_amount_from_events(&events) + else { + return Ok(None); + }; + let receiver_output_count = payjoin_receiver_output_count_from_events(&events).unwrap_or(1); + if receiver_output_count > 2 { + return Ok(None); + } + + let Some((settlement, intent_record)) = self + .reserve_cut_through_candidate(quote_id, original_receive_amount_sat) + .await? + else { + return Ok(None); + }; + + let result = self + .build_reserved_cut_through_proposal( + receiver, + &events, + &settlement, + &intent_record, + original_receive_amount_sat, + ) + .await; + + match result { + Ok(Some((proposal, cut_through, staged_persister))) => { + persister.replace(staged_persister.events()?, staged_persister.closed())?; + Ok(Some(PayjoinReceiveProposal { + proposal, + cut_through: Some(cut_through), + })) + } + Ok(None) => { + self.abandon_cut_through_before_exposure(settlement).await?; + Ok(None) + } + Err(err) => { + tracing::warn!( + settlement_id = %settlement.settlement_id, + send_intent_id = %intent_record.intent_id, + "Cut-through proposal construction failed before exposure; falling back to normal Payjoin: {}", + err + ); + self.abandon_cut_through_before_exposure(settlement).await?; + Ok(None) + } + } + } + + async fn reserve_cut_through_candidate( + &self, + quote_id: &str, + original_receive_amount_sat: u64, + ) -> Result< + Option<( + crate::storage::CutThroughSettlementRecord, + crate::send::payment_intent::record::SendIntentRecord, + )>, + Error, + > { + if original_receive_amount_sat < self.min_receive_amount_sat { + return Ok(None); + } + + let mut candidates = self.storage.get_pending_send_intents().await?; + candidates.retain(|record| { + record.amount_sat <= original_receive_amount_sat + && matches!( + record.state, + crate::send::payment_intent::record::SendIntentState::Pending { .. } + ) + }); + candidates.sort_by_key(|record| match record.state { + crate::send::payment_intent::record::SendIntentState::Pending { created_at } => { + created_at + } + _ => u64::MAX, + }); + + for candidate in candidates { + let settlement_id = Uuid::new_v4(); + let settlement = crate::storage::CutThroughSettlementRecord { + settlement_id, + receive_quote_id: quote_id.to_string(), + send_intent_id: candidate.intent_id, + send_quote_id: candidate.quote_id.clone(), + original_receive_amount_sat, + melt_amount_sat: candidate.amount_sat, + max_fee_sat: candidate.max_fee_amount_sat, + created_at: crate::util::unix_now(), + state: crate::storage::CutThroughSettlementState::Reserved, + }; + + if let Some(reserved) = self + .storage + .reserve_pending_send_intent_for_cut_through(&candidate.intent_id, &settlement) + .await? + { + tracing::debug!( + settlement_id = %settlement_id, + receive_quote_id = quote_id, + send_intent_id = %candidate.intent_id, + "Reserved pending melt for Payjoin cut-through" + ); + return Ok(Some((settlement, reserved))); + } + } + + Ok(None) + } + + async fn build_reserved_cut_through_proposal( + &self, + receiver: ::payjoin::receive::v2::Receiver<::payjoin::receive::v2::WantsOutputs>, + events: &[::payjoin::receive::v2::SessionEvent], + settlement: &crate::storage::CutThroughSettlementRecord, + intent_record: &crate::send::payment_intent::record::SendIntentRecord, + original_receive_amount_sat: u64, + ) -> Result< + Option<( + ::payjoin::receive::v2::Receiver<::payjoin::receive::v2::PayjoinProposal>, + CutThroughProposal, + RecordingSessionPersister<::payjoin::receive::v2::SessionEvent>, + )>, + Error, + > { + let original_tx = payjoin_original_tx_from_events(events)?; + let melt_address = + parse_checked_address(&intent_record.address, self.network, Error::Payjoin)?; + let melt_script = melt_address.script_pubkey(); + let surplus_sat = original_receive_amount_sat.saturating_sub(intent_record.amount_sat); + + let (drain_script, drain_is_original_surplus, drain_value_sat) = { + let mut wallet_with_db = self.wallet_with_db.lock().await; + let drain_address = wallet_with_db + .wallet + .reveal_next_address(KeychainKind::External); + let drain_script = drain_address.address.script_pubkey(); + let dust_limit = TxOut::minimal_non_dust(drain_script.clone()).value.to_sat(); + let drain_value_sat = if surplus_sat >= dust_limit { + surplus_sat + } else { + dust_limit + }; + wallet_with_db.persist().map_err(Error::Database)?; + (drain_script, surplus_sat >= dust_limit, drain_value_sat) + }; + + let replacement_outputs = vec![ + TxOut { + value: bdk_wallet::bitcoin::Amount::from_sat(intent_record.amount_sat), + script_pubkey: melt_script.clone(), + }, + TxOut { + value: bdk_wallet::bitcoin::Amount::from_sat(drain_value_sat), + script_pubkey: drain_script.clone(), + }, + ]; + + let staged_persister = RecordingSessionPersister::new(events.to_vec(), false); + let receiver = receiver + .replace_receiver_outputs(replacement_outputs, drain_script.as_script()) + .map_err(|err| Error::Payjoin(err.to_string()))? + .commit_outputs() + .save(&staged_persister) + .map_err(|err| Error::Payjoin(err.to_string()))?; + let receiver = self + .contribute_payjoin_inputs(receiver, &staged_persister) + .await?; + let proposal = self + .finalize_payjoin_proposal(receiver, &staged_persister) + .await?; + let proposal_tx = + proposal.psbt().clone().extract_tx().map_err(|err| { + Error::Payjoin(format!("Could not extract cut-through tx: {}", err)) + })?; + let wallet_with_db = self.wallet_with_db.lock().await; + let (sent, received) = wallet_with_db.wallet.sent_and_received(&proposal_tx); + let fee_contribution_sat = sent.to_sat().saturating_sub(received.to_sat()); + drop(wallet_with_db); + if fee_contribution_sat > intent_record.max_fee_amount_sat { + tracing::warn!( + settlement_id = %settlement.settlement_id, + fee_contribution_sat, + max_fee_sat = intent_record.max_fee_amount_sat, + "Cut-through proposal exceeds melt fee cap" + ); + return Ok(None); + } + + let melt_outpoint = find_output_outpoint( + &proposal_tx, + melt_script.as_script(), + intent_record.amount_sat, + ) + .ok_or_else(|| Error::Payjoin("Cut-through proposal missing melt output".to_string()))?; + let surplus_outpoint = if drain_is_original_surplus { + find_output_outpoint(&proposal_tx, drain_script.as_script(), 1) + } else { + None + }; + let receive_payment_id = surplus_outpoint.unwrap_or(melt_outpoint).to_string(); + let receive_outpoint = receive_payment_id.clone(); + + Ok(Some(( + proposal, + CutThroughProposal { + settlement_id: settlement.settlement_id, + proposal_tx, + original_tx, + receive_payment_id, + receive_outpoint, + melt_outpoint: melt_outpoint.to_string(), + fee_contribution_sat, + }, + staged_persister, + ))) + } + + async fn abandon_cut_through_before_exposure( + &self, + mut settlement: crate::storage::CutThroughSettlementRecord, + ) -> Result<(), Error> { + self.storage + .release_cut_through_reserved_intent( + &settlement.send_intent_id, + settlement.settlement_id, + ) + .await?; + settlement.state = crate::storage::CutThroughSettlementState::Abandoned { + abandoned_at: crate::util::unix_now(), + }; + self.storage.put_cut_through_settlement(&settlement).await + } + + async fn persist_cut_through_exposure( + &self, + cut_through: &CutThroughProposal, + ) -> Result<(), Error> { + { + let mut wallet_with_db = self.wallet_with_db.lock().await; + wallet_with_db.wallet.apply_unconfirmed_txs([( + cut_through.proposal_tx.clone(), + crate::util::unix_now(), + )]); + wallet_with_db.persist().map_err(Error::Database)?; + } + + let Some(mut settlement) = self + .storage + .get_cut_through_settlement(&cut_through.settlement_id) + .await? + else { + return Err(Error::Payjoin(format!( + "Cut-through settlement {} missing before exposure", + cut_through.settlement_id + ))); + }; + settlement.state = crate::storage::CutThroughSettlementState::ProposalExposed { + proposal_tx_bytes: consensus::serialize(&cut_through.proposal_tx), + proposal_txid: cut_through.proposal_tx.compute_txid().to_string(), + original_tx_bytes: consensus::serialize(&cut_through.original_tx), + original_txid: cut_through.original_tx.compute_txid().to_string(), + receive_payment_id: cut_through.receive_payment_id.clone(), + receive_outpoint: cut_through.receive_outpoint.clone(), + melt_outpoint: cut_through.melt_outpoint.clone(), + fee_contribution_sat: cut_through.fee_contribution_sat, + }; + self.storage.put_cut_through_settlement(&settlement).await + } + async fn check_payjoin_inputs_not_owned( + &self, + receiver: ::payjoin::receive::v2::Receiver<::payjoin::receive::v2::MaybeInputsOwned>, + persister: &RecordingSessionPersister<::payjoin::receive::v2::SessionEvent>, + ) -> Result<::payjoin::receive::v2::Receiver<::payjoin::receive::v2::MaybeInputsSeen>, Error> + { + let wallet_with_db = self.wallet_with_db.lock().await; + let mut is_owned = |script: &bdk_wallet::bitcoin::Script| { + Ok(wallet_with_db.wallet.is_mine(script.to_owned())) + }; + let receiver = receiver + .check_inputs_not_owned(&mut is_owned) + .save(persister) + .map_err(|err| Error::Payjoin(err.to_string()))?; + drop(wallet_with_db); + + Ok(receiver) + } + async fn check_payjoin_inputs_not_seen( + &self, + receiver: ::payjoin::receive::v2::Receiver<::payjoin::receive::v2::MaybeInputsSeen>, + quote_id: &str, + persister: &RecordingSessionPersister<::payjoin::receive::v2::SessionEvent>, + ) -> Result<::payjoin::receive::v2::Receiver<::payjoin::receive::v2::OutputsUnknown>, Error> + { + let original_input_outpoints = + payjoin_original_input_outpoints_from_events(&persister.events()?)?; + let mut seen_outpoints = HashSet::new(); + for outpoint in &original_input_outpoints { + if self + .storage + .is_payjoin_input_seen(&outpoint.to_string()) + .await? + { + seen_outpoints.insert(*outpoint); + } + } + tracing::debug!( + quote_id, + input_count = original_input_outpoints.len(), + seen_input_count = seen_outpoints.len(), + "Checked Payjoin original input replay index" + ); + + let mut is_known = + |outpoint: &bdk_wallet::bitcoin::OutPoint| Ok(seen_outpoints.contains(outpoint)); + let staged_persister = + RecordingSessionPersister::new(persister.events()?, persister.closed()); + let receiver = match receiver + .check_no_inputs_seen_before(&mut is_known) + .save(&staged_persister) + { + Ok(receiver) => receiver, + Err(err) => { + persister.replace(staged_persister.events()?, staged_persister.closed())?; + return Err(Error::Payjoin(err.to_string())); + } + }; + + let checked_outpoints = original_input_outpoints + .into_iter() + .map(|outpoint| outpoint.to_string()) + .collect::>(); + self.storage + .mark_payjoin_inputs_seen(&checked_outpoints) + .await?; + persister.replace(staged_persister.events()?, staged_persister.closed())?; + + Ok(receiver) + } + fn identify_payjoin_receiver_outputs( + &self, + receiver: ::payjoin::receive::v2::Receiver<::payjoin::receive::v2::OutputsUnknown>, + fallback_script: &bdk_wallet::bitcoin::Script, + quote_id: &str, + persister: &RecordingSessionPersister<::payjoin::receive::v2::SessionEvent>, + ) -> Result<::payjoin::receive::v2::Receiver<::payjoin::receive::v2::WantsOutputs>, Error> { + let mut is_receiver_output = + |script: &bdk_wallet::bitcoin::Script| Ok(script == fallback_script); + let receiver = receiver + .identify_receiver_outputs(&mut is_receiver_output) + .save(persister) + .map_err(|err| Error::Payjoin(err.to_string()))?; + let events = persister.events()?; + if let Some(receiver_output_count) = payjoin_receiver_output_count_from_events(&events) { + tracing::debug!( + quote_id, + receiver_output_count, + "Identified Payjoin original PSBT receiver outputs" + ); + } + + Ok(receiver) + } + async fn contribute_payjoin_inputs( + &self, + receiver: ::payjoin::receive::v2::Receiver<::payjoin::receive::v2::WantsInputs>, + persister: &RecordingSessionPersister<::payjoin::receive::v2::SessionEvent>, + ) -> Result<::payjoin::receive::v2::Receiver<::payjoin::receive::v2::ProvisionalProposal>, Error> + { + let wallet_with_db = self.wallet_with_db.lock().await; + let candidate_inputs = wallet_with_db + .wallet + .list_unspent() + .filter_map(|utxo| { + let psbt_input = wallet_with_db + .wallet + .get_psbt_input(utxo.clone(), None, false) + .ok()?; + ::payjoin::receive::InputPair::new( + TxIn { + previous_output: utxo.outpoint, + script_sig: Default::default(), + sequence: Sequence::MAX, + witness: Default::default(), + }, + psbt_input, + None, + ) + .ok() + }) + .collect::>(); + let selected = receiver + .try_preserving_privacy(candidate_inputs.clone()) + .or_else(|_| { + candidate_inputs.into_iter().next().ok_or_else(|| { + Error::Payjoin("no Payjoin contribution input available".to_string()) + }) + })?; + let receiver = receiver + .contribute_inputs([selected]) + .map_err(|err| Error::Payjoin(err.to_string()))? + .commit_inputs() + .save(persister) + .map_err(|err| Error::Payjoin(err.to_string()))?; + let receiver = apply_zero_receiver_fee_range(receiver, persister)?; + drop(wallet_with_db); + + Ok(receiver) + } + async fn finalize_payjoin_proposal( + &self, + receiver: ::payjoin::receive::v2::Receiver<::payjoin::receive::v2::ProvisionalProposal>, + persister: &RecordingSessionPersister<::payjoin::receive::v2::SessionEvent>, + ) -> Result<::payjoin::receive::v2::Receiver<::payjoin::receive::v2::PayjoinProposal>, Error> + { + let wallet_with_db = self.wallet_with_db.lock().await; + let receiver = receiver + .finalize_proposal(|psbt| { + let mut psbt = psbt.clone(); + wallet_with_db + .wallet + .sign(&mut psbt, Default::default()) + .map_err(|err| -> ::payjoin::ImplementationError { + ::payjoin::ImplementationError::new(std::io::Error::other(err.to_string())) + })?; + Ok(psbt) + }) + .save(persister) + .map_err(|err| Error::Payjoin(err.to_string()))?; + drop(wallet_with_db); + + Ok(receiver) + } + /// Start an optional Payjoin send for an onchain melt. + /// + /// This only *prepares* the send: it builds and signs the original PSBT and + /// the Payjoin sender, reserves the original's inputs locally, persists the + /// send session, and returns `Pending` immediately. The actual negotiation + /// (post the original, poll for the proposal, broadcast the Payjoin tx or + /// the original fallback) is driven asynchronously by + /// [`Self::run_payjoin_send_poller`], so a slow or unresponsive receiver + /// never blocks the melt and there is no artificial negotiation timeout. + /// + /// Any failure here happens *before* anything is shared with the receiver, + /// so it is wrapped in [`Error::PayjoinSendNotStarted`] to tell the caller a + /// direct onchain fallback is safe (no signed transaction has been exposed). + #[allow(clippy::too_many_arguments)] + pub(crate) async fn start_payjoin_send( + &self, + quote_id: &cdk_common::QuoteId, + address: &str, + amount_sat: u64, + max_fee_sat: u64, + tier: PaymentTier, + metadata: PaymentMetadata, + payjoin: &PayjoinV2, + ) -> Result { + let prepared = self + .prepare_payjoin_send(address, amount_sat, max_fee_sat, tier, payjoin) + .await + .map_err(|err| Error::PayjoinSendNotStarted(Box::new(err)))?; + let PreparedPayjoinSend { + original_tx, + original_fee_sat, + persister, + } = prepared; + + let mut intent = SendIntent::::new_payjoin( + &self.storage, + quote_id.to_string(), + address.to_string(), + amount_sat, + max_fee_sat, + tier, + metadata, + consensus::serialize(&original_tx), + original_fee_sat, + persister.events()?, + ) + .await + .map_err(|err| Error::PayjoinSendNotStarted(Box::new(err)))?; + + // Reserve the original's inputs immediately so a concurrent melt/batch + // cannot select the same coins while the (potentially long-lived) + // negotiation runs. The poller evicts/replaces this tx if a Payjoin + // proposal with different inputs arrives. + let original_txid = original_tx.compute_txid(); + let reservation_result = { + let mut wallet_with_db = self.wallet_with_db.lock().await; + wallet_with_db + .wallet + .apply_unconfirmed_txs([(original_tx.clone(), crate::util::unix_now())]); + wallet_with_db.persist().map_err(Error::Database) + }; + if let Err(err) = reservation_result { + if let Err(evict_err) = self.evict_unstaged_payjoin_tx(original_txid).await { + return Err(Error::PayjoinSendNotStarted(Box::new(Error::Payjoin( + format!( + "Could not persist reservation of original Payjoin tx {}: {}; \ + additionally could not persist eviction of the in-memory reservation: {}", + original_txid, err, evict_err + ), + )))); + } + if let Err(fail_err) = intent + .fail( + &self.storage, + format!("Could not persist Payjoin original tx reservation: {}", err), + ) + .await + { + tracing::warn!( + quote_id = %quote_id, + error = %fail_err, + "Could not mark Payjoin send intent failed after reservation failure" + ); + } + return Err(Error::PayjoinSendNotStarted(Box::new(err))); + } + + intent + .update_payjoin_events(&self.storage, persister.events()?) + .await?; + + tracing::debug!( + quote_id = %quote_id, + original_txid = %original_txid, + "Started Payjoin send session; negotiation runs in the background poller" + ); + + // Return Pending immediately. `check_outgoing_payment` can see the + // PayjoinNegotiating intent and drive recovery if the poller is not + // running yet. + Ok(MakePaymentResponse { + payment_lookup_id: PaymentIdentifier::QuoteId(quote_id.clone()), + payment_proof: None, + status: MeltQuoteState::Pending, + total_spent: Amount::new(0, CurrencyUnit::Sat), + }) + } + + /// Build and sign the original PSBT and the Payjoin sender, saving the + /// sender into a fresh persister's event log. Nothing is shared with the + /// receiver here. + async fn prepare_payjoin_send( + &self, + address: &str, + amount_sat: u64, + max_fee_sat: u64, + tier: PaymentTier, + payjoin: &PayjoinV2, + ) -> Result { + use ::payjoin::UriExt; + + if self.payjoin_config().is_none() { + return Err(Error::Payjoin( + "operator did not configure Payjoin directory and OHTTP relay".to_string(), + )); + } + + let fallback_address = parse_checked_address(address, self.network, Error::Wallet)?; + let sat_per_vb = self + .estimate_fee_rate_sat_per_vb(tier) + .await + .unwrap_or_else(|e| { + tracing::warn!( + tier = ?tier, + error = %e, + "Payjoin fee-rate estimation failed, using configured fallback" + ); + self.batch_config.fee_estimation.fallback_sat_per_vb + }); + let fee_rate = bdk_wallet::bitcoin::FeeRate::from_sat_per_vb_u32(sat_per_vb.ceil() as u32); + + let (original_psbt, original_fee_sat, original_tx) = { + let mut wallet_with_db = self.wallet_with_db.lock().await; + let mut tx_builder = wallet_with_db.wallet.build_tx(); + tx_builder.add_recipient( + fallback_address.clone(), + bdk_wallet::bitcoin::Amount::from_sat(amount_sat), + ); + tx_builder.fee_rate(fee_rate); + let mut original_psbt = tx_builder + .finish() + .map_err(|err| Error::Payjoin(format!("Could not build original PSBT: {}", err)))?; + let original_fee_sat = original_psbt + .fee() + .map_err(|err| { + Error::Payjoin(format!("Could not calculate original PSBT fee: {}", err)) + })? + .to_sat(); + if original_fee_sat > max_fee_sat { + return Err(Error::Payjoin(format!( + "original Payjoin PSBT fee {} exceeds max fee {}", + original_fee_sat, max_fee_sat + ))); + } + if !wallet_with_db + .wallet + .sign(&mut original_psbt, Default::default()) + .map_err(|err| Error::Payjoin(format!("Could not sign original PSBT: {}", err)))? + { + return Err(Error::CouldNotSign); + } + wallet_with_db + .persist() + .map_err(|err| Error::Payjoin(format!("Could not persist wallet: {}", err)))?; + // Capture the broadcastable fallback transaction before the PSBT is + // consumed by the sender builder. This is the same signed original + // we share with the receiver, so broadcasting it later can never + // conflict with a Payjoin proposal derived from it. + let original_tx = original_psbt.clone().extract_tx().map_err(|err| { + Error::Payjoin(format!("Could not extract original Payjoin tx: {}", err)) + })?; + (original_psbt, original_fee_sat, original_tx) + }; + + let pj_uri = build_payjoin_uri(address, amount_sat, payjoin)?; + let pj_uri = ::payjoin::Uri::try_from(pj_uri.as_str()) + .map_err(|err| Error::Payjoin(format!("Invalid Payjoin URI: {}", err)))? + .assume_checked() + .check_pj_supported() + .map_err(|_| { + Error::Payjoin("Payjoin URI did not contain supported pj params".to_string()) + })?; + let persister = RecordingSessionPersister::new(Vec::new(), false); + // Save the sender into the event log; the poller replays it from there. + let _sender = ::payjoin::send::v2::SenderBuilder::new(original_psbt, pj_uri) + .build_recommended(fee_rate) + .map_err(|err| Error::Payjoin(err.to_string()))? + .save(&persister) + .map_err(|err| Error::Payjoin(err.to_string()))?; + + Ok(PreparedPayjoinSend { + original_tx, + original_fee_sat, + persister, + }) + } + + /// Background poller that drives every open Payjoin send session to + /// completion, mirroring [`Self::run_payjoin_receive_poller`]. It posts the + /// original PSBT, polls for the proposal, and broadcasts either the Payjoin + /// transaction or the signed original fallback. Because it lists persisted + /// sessions each tick, it transparently resumes in-flight sends after a + /// restart. + pub(crate) async fn run_payjoin_send_poller( + &self, + cancel_token: CancellationToken, + ) -> Result<(), Error> { + let mut tick = tokio::time::interval(Duration::from_secs(15)); + tracing::info!("Starting Payjoin send poller"); + loop { + tokio::select! { + _ = cancel_token.cancelled() => break, + _ = tick.tick() => { + let intents = self.payjoin_send_intents().await?; + tracing::debug!( + intent_count = intents.len(), + active_count = intents.len(), + "Polling Payjoin send intents" + ); + for intent in intents { + if let Err(err) = self.process_payjoin_send_intent(intent).await { + tracing::warn!("Payjoin send intent processing failed: {}", err); + } + } + } + } + } + Ok(()) + } + + pub(crate) async fn recover_payjoin_sessions_once(&self) -> Result<(), Error> { + self.recover_payjoin_receive_sessions_once().await?; + self.recover_cut_through_settlements_once().await?; + self.recover_payjoin_send_intents_once().await + } + + async fn recover_cut_through_settlements_once(&self) -> Result<(), Error> { + let settlements = self.storage.get_all_cut_through_settlements().await?; + let now = crate::util::unix_now(); + + for mut settlement in settlements { + match settlement.state.clone() { + crate::storage::CutThroughSettlementState::Reserved => { + self.storage + .release_cut_through_reserved_intent( + &settlement.send_intent_id, + settlement.settlement_id, + ) + .await?; + settlement.state = + crate::storage::CutThroughSettlementState::Abandoned { abandoned_at: now }; + self.storage.put_cut_through_settlement(&settlement).await?; + } + crate::storage::CutThroughSettlementState::ProposalExposed { + proposal_txid, + original_txid, + original_tx_bytes, + receive_payment_id, + receive_outpoint, + melt_outpoint, + fee_contribution_sat, + .. + } => { + let (proposal_confirmed, original_confirmed) = { + let wallet_with_db = self.wallet_with_db.lock().await; + ( + self.txid_has_required_confirmations( + &wallet_with_db.wallet, + &proposal_txid, + "cut_through_proposal", + &settlement.settlement_id.to_string(), + ), + self.txid_has_required_confirmations( + &wallet_with_db.wallet, + &original_txid, + "cut_through_original", + &settlement.settlement_id.to_string(), + ), + ) + }; + + if proposal_confirmed { + self.finalize_confirmed_cut_through( + &mut settlement, + receive_payment_id, + receive_outpoint, + melt_outpoint, + fee_contribution_sat, + ) + .await?; + } else if original_confirmed + || self + .cut_through_original_inputs_confirmed_spent(&original_tx_bytes) + .await? + { + self.abandon_exposed_cut_through(&mut settlement, &proposal_txid, now) + .await?; + } + } + crate::storage::CutThroughSettlementState::Confirmed { finalized_at } + | crate::storage::CutThroughSettlementState::Abandoned { + abandoned_at: finalized_at, + } => { + if now.saturating_sub(finalized_at) > PAYJOIN_RECEIVE_SESSION_RETENTION_SECS { + self.storage + .delete_cut_through_settlement(&settlement.settlement_id) + .await?; + } + } + } + } + + Ok(()) + } + + async fn abandon_exposed_cut_through( + &self, + settlement: &mut crate::storage::CutThroughSettlementRecord, + proposal_txid: &str, + now: u64, + ) -> Result<(), Error> { + if let Ok(txid) = bdk_wallet::bitcoin::Txid::from_str(proposal_txid) { + self.evict_unstaged_payjoin_tx(txid).await?; + } + self.storage + .release_cut_through_reserved_intent( + &settlement.send_intent_id, + settlement.settlement_id, + ) + .await?; + settlement.state = + crate::storage::CutThroughSettlementState::Abandoned { abandoned_at: now }; + self.storage.put_cut_through_settlement(settlement).await + } + + async fn cut_through_original_inputs_confirmed_spent( + &self, + original_tx_bytes: &[u8], + ) -> Result { + let original_tx = + consensus::deserialize::(original_tx_bytes).map_err(|err| { + Error::Payjoin(format!( + "Could not deserialize cut-through original tx: {}", + err + )) + })?; + let outpoints = original_tx + .input + .iter() + .map(|input| input.previous_output) + .collect::>(); + self.chain_source.any_confirmed_spend(&outpoints).await + } + + async fn finalize_confirmed_cut_through( + &self, + settlement: &mut crate::storage::CutThroughSettlementRecord, + receive_payment_id: String, + receive_outpoint: String, + melt_outpoint: String, + fee_contribution_sat: u64, + ) -> Result<(), Error> { + let finalized_at = crate::util::unix_now(); + let receive_intent_id = Uuid::new_v4(); + let receive_record = crate::storage::FinalizedReceiveIntentRecord { + intent_id: receive_intent_id, + quote_id: settlement.receive_quote_id.clone(), + address: String::new(), + txid: receive_outpoint + .split_once(':') + .map(|(txid, _)| txid.to_string()) + .unwrap_or_else(|| receive_outpoint.clone()), + outpoint: receive_outpoint.clone(), + payment_id: Some(receive_payment_id.clone()), + amount_sat: settlement.original_receive_amount_sat, + finalized_at, + }; + let send_record = crate::storage::FinalizedSendIntentRecord { + intent_id: settlement.send_intent_id, + quote_id: settlement.send_quote_id.clone(), + total_spent_sat: settlement + .melt_amount_sat + .saturating_add(fee_contribution_sat), + outpoint: melt_outpoint.clone(), + finalized_at, + }; + settlement.state = crate::storage::CutThroughSettlementState::Confirmed { finalized_at }; + self.storage + .finalize_cut_through_pair(&receive_record, &send_record, settlement) + .await?; + + if let Ok(quote_id) = cdk_common::QuoteId::from_str(&settlement.receive_quote_id) { + let response = WaitPaymentResponse { + payment_identifier: PaymentIdentifier::QuoteId(quote_id), + payment_amount: Amount::new( + settlement.original_receive_amount_sat, + CurrencyUnit::Sat, + ), + payment_id: receive_payment_id, + }; + if let Err(err) = self.payment_sender.send(Event::PaymentReceived(response)) { + tracing::error!( + settlement_id = %settlement.settlement_id, + "Could not send cut-through receive event: {}", + err + ); + } + } + if let Ok(quote_id) = cdk_common::QuoteId::from_str(&settlement.send_quote_id) { + let details = MakePaymentResponse { + payment_lookup_id: PaymentIdentifier::QuoteId(quote_id.clone()), + payment_proof: Some(melt_outpoint), + status: MeltQuoteState::Paid, + total_spent: Amount::new( + settlement + .melt_amount_sat + .saturating_add(fee_contribution_sat), + CurrencyUnit::Sat, + ), + }; + if let Err(err) = self + .payment_sender + .send(Event::PaymentSuccessful { quote_id, details }) + { + tracing::error!( + settlement_id = %settlement.settlement_id, + "Could not send cut-through send event: {}", + err + ); + } + } + + Ok(()) + } + + async fn recover_payjoin_send_intents_once(&self) -> Result<(), Error> { + for intent in self.payjoin_send_intents().await? { + if let Err(err) = self.process_payjoin_send_intent(intent).await { + tracing::warn!("Payjoin send intent recovery failed: {}", err); + } + } + + Ok(()) + } + + async fn payjoin_send_intents( + &self, + ) -> Result>, Error> { + let records = self.storage.get_all_send_intents().await?; + Ok(records + .iter() + .filter_map( + |record| match crate::send::payment_intent::from_record(record) { + crate::send::payment_intent::SendIntentAny::PayjoinNegotiating(intent) => { + Some(intent) + } + _ => None, + }, + ) + .collect()) + } + + pub(crate) async fn process_payjoin_send_intent( + &self, + mut intent: SendIntent, + ) -> Result<(), Error> { + use ::payjoin::persist::OptionalTransitionOutcome; + use ::payjoin::send::v2::{SendSession, SessionOutcome}; + + // Idempotency: if this quote already has a staged or finalized send, + // the negotiation has completed on another path. + let active_done = self + .storage + .get_send_intent_by_quote_id(&intent.quote_id) + .await? + .is_some_and(|active| { + !matches!( + active.state, + crate::send::payment_intent::record::SendIntentState::PayjoinNegotiating { .. } + ) + }); + if active_done + || self + .storage + .get_finalized_intent_by_quote_id(&intent.quote_id) + .await? + .is_some() + { + return Ok(()); + } + + let Some(config) = self.payjoin_config() else { + tracing::debug!( + quote_id = %intent.quote_id, + "Payjoin send config unavailable; broadcasting original fallback" + ); + return self.broadcast_payjoin_send_fallback(intent).await; + }; + + let persister = RecordingSessionPersister::new(intent.state.events.clone(), false); + let session = match ::payjoin::send::v2::replay_event_log(&persister) { + Ok((session, _history)) => session, + Err(err) => { + // The session can no longer be replayed (most commonly because + // the Payjoin parameters have expired). Broadcast the signed + // original fallback so the melt still settles. + tracing::debug!( + quote_id = %intent.quote_id, + error = %err, + "Payjoin send session not replayable (expired?); broadcasting original fallback" + ); + return self.broadcast_payjoin_send_fallback(intent).await; + } + }; + + match session { + SendSession::WithReplyKey(sender) => { + tracing::debug!( + quote_id = %intent.quote_id, + "Posting original PSBT to Payjoin directory" + ); + let (request, context) = sender + .create_v2_post_request(&config.ohttp_relay_url) + .map_err(|err| Error::Payjoin(err.to_string()))?; + let response = payjoin_http_request(request).await?; + sender + .process_response(&response, context) + .save(&persister) + .map_err(|err| Error::Payjoin(err.to_string()))?; + self.persist_payjoin_send_progress(&mut intent, &persister) + .await?; + } + SendSession::PollingForProposal(sender) => { + let (request, context) = sender + .create_poll_request(&config.ohttp_relay_url) + .map_err(|err| Error::Payjoin(err.to_string()))?; + let response = payjoin_http_request(request).await?; + match sender + .process_response(&response, context) + .save(&persister) + .map_err(|err| Error::Payjoin(err.to_string()))? + { + OptionalTransitionOutcome::Progress(proposal_psbt) => { + tracing::debug!( + quote_id = %intent.quote_id, + "Received Payjoin proposal PSBT" + ); + self.persist_payjoin_send_progress(&mut intent, &persister) + .await?; + self.finalize_and_stage_payjoin_send(intent, proposal_psbt) + .await?; + } + OptionalTransitionOutcome::Stasis(_) => { + tracing::debug!( + quote_id = %intent.quote_id, + "No Payjoin proposal available yet" + ); + self.persist_payjoin_send_progress(&mut intent, &persister) + .await?; + } + } + } + SendSession::Closed(outcome) => match outcome { + SessionOutcome::Success(proposal_psbt) => { + // Crash/resume: the proposal was received before staging. + self.finalize_and_stage_payjoin_send(intent, proposal_psbt) + .await?; + } + SessionOutcome::Failure | SessionOutcome::Cancel => { + tracing::debug!( + quote_id = %intent.quote_id, + "Payjoin send session closed without success; broadcasting original fallback" + ); + self.broadcast_payjoin_send_fallback(intent).await?; + } + }, + } + + Ok(()) + } + + /// Sign the Payjoin proposal, evict the locally-reserved original, then + /// stage and broadcast the Payjoin transaction. If the proposal would make + /// the mint spend more than the quote amount plus max fee, broadcast the + /// original fallback instead (it is already within budget). + async fn finalize_and_stage_payjoin_send( + &self, + intent: SendIntent, + proposal_psbt: bdk_wallet::bitcoin::Psbt, + ) -> Result<(), Error> { + let fallback_address = + parse_checked_address(&intent.address, self.network, Error::Payjoin)?; + + let mut final_psbt = proposal_psbt; + let (tx, validation) = { + let wallet_with_db = self.wallet_with_db.lock().await; + if !wallet_with_db + .wallet + .sign(&mut final_psbt, Default::default()) + .map_err(|err| Error::Payjoin(format!("Could not sign Payjoin PSBT: {}", err)))? + { + return Err(Error::CouldNotSign); + } + let tx = final_psbt + .extract_tx() + .map_err(|err| Error::Payjoin(format!("Could not extract Payjoin tx: {}", err)))?; + let (sent, received) = wallet_with_db.wallet.sent_and_received(&tx); + let validation = validate_payjoin_send_transaction( + &tx, + fallback_address.script_pubkey().as_script(), + intent.amount, + intent.max_fee_amount, + sent.to_sat(), + received.to_sat(), + ); + (tx, validation) + }; + let validation = match validation { + Ok(validation) => validation, + Err(err) => { + tracing::warn!( + quote_id = %intent.quote_id, + error = %err, + "Payjoin proposal exceeds local spend limits or altered the payment output; \ + broadcasting original fallback instead" + ); + return self.broadcast_payjoin_send_fallback(intent).await; + } + }; + + // The Payjoin tx spends the original's inputs plus the receiver's, so + // evict the locally-reserved original before applying the Payjoin tx to + // avoid a conflicting double-application in the wallet graph. + if let Ok(original_tx) = + consensus::deserialize::(&intent.state.original_tx_bytes) + { + let original_txid = original_tx.compute_txid(); + if original_txid != tx.compute_txid() { + self.evict_unstaged_payjoin_tx(original_txid).await?; + } + } + + self.stage_and_broadcast_payjoin_send(tx, validation, intent) + .await?; + Ok(()) + } + + /// Stage and broadcast the signed original transaction as the Payjoin + /// fallback, then close the session. + async fn broadcast_payjoin_send_fallback( + &self, + intent: SendIntent, + ) -> Result<(), Error> { + let original_tx = consensus::deserialize::(&intent.state.original_tx_bytes) + .map_err(|err| { + Error::Payjoin(format!( + "Could not deserialize original Payjoin tx: {}", + err + )) + })?; + let fallback_address = + parse_checked_address(&intent.address, self.network, Error::Payjoin)?; + let validation = PayjoinSendValidation { + payment_outpoint: require_payjoin_send_payment_output( + &original_tx, + fallback_address.script_pubkey().as_script(), + intent.amount, + )?, + fee_contribution_sat: intent.state.original_fee_sat, + }; + + self.stage_and_broadcast_payjoin_send(original_tx, validation, intent) + .await?; + Ok(()) + } + + /// Durably stage and broadcast a chosen Payjoin send transaction (either the + /// Payjoin proposal or the original fallback), creating a send intent keyed + /// by `quote_id` so `check_outgoing_payment` can track it. + #[allow(clippy::too_many_arguments)] + async fn stage_and_broadcast_payjoin_send( + &self, + tx: Transaction, + validation: PayjoinSendValidation, + payjoin_intent: SendIntent, + ) -> Result<(), Error> { + let quote_id = payjoin_intent.quote_id.clone(); + let txid = tx.compute_txid(); + let outpoint = validation.payment_outpoint; + let fee_contribution_sat = validation.fee_contribution_sat; + { + let mut wallet_with_db = self.wallet_with_db.lock().await; + wallet_with_db + .wallet + .apply_unconfirmed_txs([(tx.clone(), crate::util::unix_now())]); + wallet_with_db.persist().map_err(Error::Database)?; + } + + let batch_id = Uuid::new_v4(); + let intent_id = payjoin_intent.intent_id; + let tx_bytes = consensus::serialize(&tx); + let assignment = BatchOutputAssignment { + intent_id, + vout: outpoint.vout, + fee_contribution_sat, + }; + if let Err(err) = self + .storage + .store_send_batch(&SendBatchRecord { + batch_id, + state: SendBatchState::Signed { + tx_bytes: tx_bytes.clone(), + assignments: vec![assignment.clone()], + fee_sat: fee_contribution_sat, + }, + }) + .await + { + let reason = format!("Payjoin staging failed before broadcast: {}", err); + if let Err(evict_err) = self.evict_unstaged_payjoin_tx(txid).await { + return Err(Error::Payjoin(format!( + "{}; additionally could not persist eviction of unstaged tx {}: {}", + reason, txid, evict_err + ))); + } + if let Err(fail_err) = payjoin_intent.fail(&self.storage, reason.clone()).await { + tracing::warn!( + quote_id, + error = %fail_err, + "Could not mark Payjoin send intent failed after staging failure" + ); + } + return Err(err); + } + + let batched = payjoin_intent + .assign_to_batch(&self.storage, batch_id) + .await?; + + if let Err(err) = self + .storage + .update_send_batch( + &batch_id, + &SendBatchState::Broadcast { + txid: txid.to_string(), + tx_bytes, + assignments: vec![assignment], + fee_sat: fee_contribution_sat, + }, + ) + .await + { + tracing::warn!( + quote_id, + batch_id = %batch_id, + error = %err, + "Payjoin signed batch is durable but could not be marked broadcast" + ); + return Ok(()); + } + if let Err(err) = batched + .mark_broadcast( + &self.storage, + txid.to_string(), + outpoint.to_string(), + fee_contribution_sat, + ) + .await + { + tracing::warn!( + quote_id, + batch_id = %batch_id, + txid = %txid, + error = %err, + "Payjoin batch is durable but send intent could not be marked awaiting confirmation" + ); + return Ok(()); + } + + match self.broadcast_transaction_internal(tx.clone()).await { + Ok(crate::chain::BroadcastOutcome::Accepted) + | Ok(crate::chain::BroadcastOutcome::AlreadyKnown) => {} + Err(failure) => { + tracing::warn!( + quote_id, + txid = %txid, + error = %failure.message, + "Payjoin transaction is durably staged but broadcast failed" + ); + } + } + + Ok(()) + } + + /// Persist the current Payjoin send session event log without changing the + /// poller's terminal `closed` flag (which is set only after the resulting + /// transaction is staged). + async fn persist_payjoin_send_progress( + &self, + intent: &mut SendIntent, + persister: &RecordingSessionPersister<::payjoin::send::v2::SessionEvent>, + ) -> Result<(), Error> { + intent + .update_payjoin_events(&self.storage, persister.events()?) + .await + } + + async fn evict_unstaged_payjoin_tx( + &self, + txid: bdk_wallet::bitcoin::Txid, + ) -> Result<(), Error> { + let evict_time = crate::util::unix_now().saturating_add(1); + let mut wallet_with_db = self.wallet_with_db.lock().await; + wallet_with_db + .wallet + .apply_evicted_txs([(txid, evict_time)]); + wallet_with_db.persist().map_err(Error::Database)?; + Ok(()) + } + + pub(crate) fn payjoin_config(&self) -> Option<&PayjoinConfig> { + self.payjoin_config.as_ref() + } + + async fn cached_ohttp_keys( + &self, + config: &PayjoinConfig, + ) -> Result<::payjoin::OhttpKeys, Error> { + let now = crate::util::unix_now(); + let cached_keys = { + let cache = self.payjoin_ohttp_keys_cache.lock().await; + cache.as_ref().and_then(|cached| { + let config_matches = cached.directory_url == config.directory_url + && cached.ohttp_relay_url == config.ohttp_relay_url; + if !config_matches { + return None; + } + + if now.saturating_sub(cached.fetched_at) <= PAYJOIN_OHTTP_KEYS_CACHE_TTL_SECS { + return Some(Ok(cached.keys.clone())); + } + + Some(Err(cached.keys.clone())) + }) + }; + + match cached_keys { + Some(Ok(keys)) => Ok(keys), + Some(Err(stale_keys)) => { + let _fetch_guard = self.payjoin_ohttp_keys_fetch_lock.lock().await; + if let Some(keys) = self.fresh_cached_ohttp_keys(config).await { + return Ok(keys); + } + + match fetch_ohttp_keys_with_timeout(config).await { + Ok(refreshed_keys) => { + self.store_ohttp_keys( + config, + refreshed_keys.clone(), + crate::util::unix_now(), + ) + .await; + Ok(refreshed_keys) + } + Err(err) => { + tracing::warn!( + error = %err, + "Could not refresh Payjoin OHTTP keys; using stale cached keys" + ); + Ok(stale_keys) + } + } + } + None => { + let _fetch_guard = self.payjoin_ohttp_keys_fetch_lock.lock().await; + if let Some(keys) = self.fresh_cached_ohttp_keys(config).await { + return Ok(keys); + } + + let keys = fetch_ohttp_keys_with_timeout(config).await?; + self.store_ohttp_keys(config, keys.clone(), crate::util::unix_now()) + .await; + Ok(keys) + } + } + } + + async fn fresh_cached_ohttp_keys( + &self, + config: &PayjoinConfig, + ) -> Option<::payjoin::OhttpKeys> { + let now = crate::util::unix_now(); + let cache = self.payjoin_ohttp_keys_cache.lock().await; + cache.as_ref().and_then(|cached| { + let config_matches = cached.directory_url == config.directory_url + && cached.ohttp_relay_url == config.ohttp_relay_url; + if config_matches + && now.saturating_sub(cached.fetched_at) <= PAYJOIN_OHTTP_KEYS_CACHE_TTL_SECS + { + Some(cached.keys.clone()) + } else { + None + } + }) + } + + async fn store_ohttp_keys( + &self, + config: &PayjoinConfig, + keys: ::payjoin::OhttpKeys, + fetched_at: u64, + ) { + let mut cache = self.payjoin_ohttp_keys_cache.lock().await; + *cache = Some(crate::PayjoinOhttpKeysCache { + keys, + fetched_at, + directory_url: config.directory_url.clone(), + ohttp_relay_url: config.ohttp_relay_url.clone(), + }); + } +} + +async fn fetch_ohttp_keys_with_timeout( + config: &PayjoinConfig, +) -> Result<::payjoin::OhttpKeys, Error> { + tokio::time::timeout(PAYJOIN_OHTTP_KEYS_FETCH_TIMEOUT, fetch_ohttp_keys(config)) + .await + .map_err(|_| { + Error::Payjoin(format!( + "Payjoin OHTTP key fetch timed out after {} seconds", + PAYJOIN_OHTTP_KEYS_FETCH_TIMEOUT.as_secs() + )) + })? +} + +async fn fetch_ohttp_keys(config: &PayjoinConfig) -> Result<::payjoin::OhttpKeys, Error> { + #[cfg(test)] + if let Some(result) = test_fetch_ohttp_keys(config).await { + return result; + } + + #[cfg(feature = "payjoin-local-https")] + { + if let Some(cert_der) = config.local_tls_cert_der.clone() { + return ::payjoin::io::fetch_ohttp_keys_with_cert( + &config.ohttp_relay_url, + &config.directory_url, + &cert_der, + ) + .await + .map_err(|err| Error::Payjoin(err.to_string())); + } + } + + ::payjoin::io::fetch_ohttp_keys(&config.ohttp_relay_url, &config.directory_url) + .await + .map_err(|err| Error::Payjoin(err.to_string())) +} + +#[cfg(test)] +async fn test_fetch_ohttp_keys( + _config: &PayjoinConfig, +) -> Option> { + if !TEST_OHTTP_FETCH_ENABLED.load(Ordering::SeqCst) { + return None; + } + + TEST_OHTTP_FETCH_CALLS.fetch_add(1, Ordering::SeqCst); + let delay_ms = TEST_OHTTP_FETCH_DELAY_MS.load(Ordering::SeqCst); + if delay_ms > 0 { + tokio::time::sleep(Duration::from_millis(delay_ms)).await; + } + + if TEST_OHTTP_FETCH_FAIL.load(Ordering::SeqCst) { + return Some(Err(Error::Payjoin( + "test OHTTP key fetch failure".to_string(), + ))); + } + + let keys = TEST_OHTTP_KEYS + .parse::() + .map_err(|err| Error::Payjoin(err.to_string())) + .and_then(|keys| { + ::payjoin::OhttpKeys::try_from(keys.as_bytes().as_slice()) + .map_err(|err| Error::Payjoin(err.to_string())) + }); + Some(keys) +} + +fn extract_bip21_payjoin_endpoint(uri: &str) -> Result { + let query = uri.split_once('?').map(|(_, query)| query).ok_or_else(|| { + Error::Payjoin("Payjoin URI did not include query parameters".to_string()) + })?; + + for (key, value) in url::form_urlencoded::parse(query.as_bytes()) { + if key == "pj" { + return Ok(value.into_owned()); + } + } + + Err(Error::Payjoin( + "Payjoin URI did not include a pj endpoint".to_string(), + )) +} + +fn payjoin_receive_session_state_name( + session: &::payjoin::receive::v2::ReceiveSession, +) -> &'static str { + match session { + ::payjoin::receive::v2::ReceiveSession::Initialized(_) => "initialized", + ::payjoin::receive::v2::ReceiveSession::UncheckedOriginalPayload(_) => { + "unchecked_original_payload" + } + ::payjoin::receive::v2::ReceiveSession::MaybeInputsOwned(_) => "maybe_inputs_owned", + ::payjoin::receive::v2::ReceiveSession::MaybeInputsSeen(_) => "maybe_inputs_seen", + ::payjoin::receive::v2::ReceiveSession::OutputsUnknown(_) => "outputs_unknown", + ::payjoin::receive::v2::ReceiveSession::WantsOutputs(_) => "wants_outputs", + ::payjoin::receive::v2::ReceiveSession::WantsInputs(_) => "wants_inputs", + ::payjoin::receive::v2::ReceiveSession::WantsFeeRange(_) => "wants_fee_range", + ::payjoin::receive::v2::ReceiveSession::ProvisionalProposal(_) => "provisional_proposal", + ::payjoin::receive::v2::ReceiveSession::PayjoinProposal(_) => "payjoin_proposal", + ::payjoin::receive::v2::ReceiveSession::HasReplyableError(_) => "has_replyable_error", + ::payjoin::receive::v2::ReceiveSession::Closed(_) => "closed", + _ => "unknown", + } +} + +fn latest_payjoin_receive_replyable_error( + events: &[::payjoin::receive::v2::SessionEvent], +) -> Option { + events.iter().rev().find_map(|event| match event { + ::payjoin::receive::v2::SessionEvent::GotReplyableError(error) => Some(error.to_json()), + _ => None, + }) +} + +fn build_payjoin_uri(address: &str, amount_sat: u64, payjoin: &PayjoinV2) -> Result { + let mut serializer = url::form_urlencoded::Serializer::new(String::new()); + serializer.append_pair("amount", &format_bip21_amount_from_sats(amount_sat)); + serializer.append_pair("pj", &build_payjoin_endpoint(payjoin)?); + Ok(format!("bitcoin:{}?{}", address, serializer.finish())) +} + +fn build_payjoin_endpoint(payjoin: &PayjoinV2) -> Result { + // The payjoin sender expects a BIP21/BIP77 `pj` URI. Cashu uses Unix + // timestamp; BIP77 URI fragments use encoded `EX1`, so rebuild it only at + // this library boundary. + payjoin_v2_to_bip77_endpoint(payjoin).map_err(|err| Error::Payjoin(err.to_string())) +} + +fn update_payjoin_receive_credit_cap(record: &mut crate::storage::PayjoinReceiveSessionRecord) { + if let Some(amount_sat) = payjoin_original_receiver_output_amount_from_events(&record.events) { + if record.amount_sat == 0 { + tracing::debug!( + quote_id = %record.quote_id, + fallback_address = %record.fallback_address, + previous_amount_sat = record.amount_sat, + credit_cap_amount_sat = amount_sat, + "Updated Payjoin receive credit cap from original PSBT receiver outputs" + ); + record.amount_sat = amount_sat; + } else if record.amount_sat != amount_sat { + tracing::debug!( + quote_id = %record.quote_id, + fallback_address = %record.fallback_address, + quoted_amount_sat = record.amount_sat, + original_receiver_output_sat = amount_sat, + "Keeping existing Payjoin receive credit cap" + ); + } + } +} + +fn update_payjoin_receive_proposal_receiver_outpoints( + record: &mut crate::storage::PayjoinReceiveSessionRecord, + psbt: &bdk_wallet::bitcoin::Psbt, + fallback_script: &Script, +) { + let txid = psbt.unsigned_tx.compute_txid(); + let outpoints = psbt + .unsigned_tx + .output + .iter() + .enumerate() + .filter(|(_, output)| output.script_pubkey.as_script() == fallback_script) + .map(|(vout, _)| OutPoint::new(txid, vout as u32).to_string()) + .collect::>(); + + if outpoints.is_empty() { + tracing::warn!( + quote_id = %record.quote_id, + fallback_address = %record.fallback_address, + "Payjoin proposal has no receiver-script outpoints to record" + ); + return; + } + + if record.proposal_receiver_outpoints != outpoints { + tracing::debug!( + quote_id = %record.quote_id, + fallback_address = %record.fallback_address, + proposal_receiver_outpoint_count = outpoints.len(), + "Updated Payjoin receive proposal receiver outpoints" + ); + record.proposal_receiver_outpoints = outpoints; + } +} + +fn zero_receiver_fee_range() -> (Option, Option) { + (None, Some(PAYJOIN_RECEIVER_MAX_EFFECTIVE_FEE_RATE)) +} + +fn apply_zero_receiver_fee_range( + receiver: ::payjoin::receive::v2::Receiver<::payjoin::receive::v2::WantsFeeRange>, + persister: &RecordingSessionPersister<::payjoin::receive::v2::SessionEvent>, +) -> Result<::payjoin::receive::v2::Receiver<::payjoin::receive::v2::ProvisionalProposal>, Error> { + let (min_fee_rate, max_effective_fee_rate) = zero_receiver_fee_range(); + receiver + .apply_fee_range(min_fee_rate, max_effective_fee_rate) + .save(persister) + .map_err(|err| Error::Payjoin(err.to_string())) +} + +fn ensure_payjoin_receiver_credit( + psbt: &bdk_wallet::bitcoin::Psbt, + fallback_script: &Script, + minimum_amount_sat: u64, +) -> Result<(), Error> { + let credited_amount_sat = payjoin_receiver_output_amount(psbt, fallback_script)?; + if credited_amount_sat < minimum_amount_sat { + return Err(Error::Payjoin(format!( + "Payjoin proposal receiver output amount {} is below original amount {}", + credited_amount_sat, minimum_amount_sat + ))); + } + + Ok(()) +} + +fn payjoin_receiver_output_amount( + psbt: &bdk_wallet::bitcoin::Psbt, + fallback_script: &Script, +) -> Result { + psbt.unsigned_tx + .output + .iter() + .filter(|output| output.script_pubkey.as_script() == fallback_script) + .try_fold(0_u64, |amount_sat, output| { + amount_sat + .checked_add(output.value.to_sat()) + .ok_or_else(|| { + Error::Payjoin("Payjoin receiver output amount overflow".to_string()) + }) + }) +} + +fn payjoin_receiver_output_count_from_events( + events: &[::payjoin::receive::v2::SessionEvent], +) -> Option { + events.iter().rev().find_map(|event| match event { + ::payjoin::receive::v2::SessionEvent::IdentifiedReceiverOutputs(vouts) => Some(vouts.len()), + _ => None, + }) +} + +fn payjoin_original_input_outpoints_from_events( + events: &[::payjoin::receive::v2::SessionEvent], +) -> Result, Error> { + let original = events.iter().rev().find_map(|event| match event { + ::payjoin::receive::v2::SessionEvent::RetrievedOriginalPayload { original, .. } => { + Some(original) + } + _ => None, + }); + let Some(original) = original else { + return Err(Error::Payjoin( + "Payjoin original payload event missing".to_string(), + )); + }; + + let mut outpoints = Vec::new(); + let mut collect_outpoint = |outpoint: &OutPoint| { + outpoints.push(*outpoint); + Ok(false) + }; + original + .check_no_inputs_seen_before(&mut collect_outpoint) + .map_err(|err| Error::Payjoin(err.to_string()))?; + + Ok(outpoints) +} + +fn payjoin_original_tx_from_events( + events: &[::payjoin::receive::v2::SessionEvent], +) -> Result { + let original = events.iter().rev().find_map(|event| match event { + ::payjoin::receive::v2::SessionEvent::RetrievedOriginalPayload { original, .. } => { + Some(original) + } + _ => None, + }); + let Some(original) = original else { + return Err(Error::Payjoin( + "Payjoin original payload event missing".to_string(), + )); + }; + + let original_tx = StdMutex::new(None); + original + .check_broadcast_suitability(None, |tx| { + *original_tx.lock().map_err(|err| { + ::payjoin::ImplementationError::new(std::io::Error::other(err.to_string())) + })? = Some(tx.clone()); + Ok(true) + }) + .map_err(|err| Error::Payjoin(err.to_string()))?; + + original_tx + .into_inner() + .map_err(|err| Error::Payjoin(format!("Payjoin original tx lock poisoned: {}", err)))? + .ok_or_else(|| Error::Payjoin("Payjoin original tx missing".to_string())) +} + +fn payjoin_original_receiver_output_amount_from_events( + events: &[::payjoin::receive::v2::SessionEvent], +) -> Option { + let mut receiver_vouts = None; + let mut committed_outputs = None; + + for event in events { + match event { + ::payjoin::receive::v2::SessionEvent::IdentifiedReceiverOutputs(vouts) => { + receiver_vouts = Some(vouts.as_slice()); + } + ::payjoin::receive::v2::SessionEvent::CommittedOutputs(outputs) => { + committed_outputs = Some(outputs.as_slice()); + } + _ => {} + } + } + + let receiver_vouts = receiver_vouts?; + let committed_outputs = committed_outputs?; + + receiver_vouts.iter().try_fold(0_u64, |amount_sat, vout| { + let output = committed_outputs.get(*vout)?; + amount_sat.checked_add(output.value.to_sat()) + }) +} + +async fn payjoin_http_request(request: ::payjoin::Request) -> Result, Error> { + let response = tokio::time::timeout(PAYJOIN_HTTP_REQUEST_TIMEOUT, async { + reqwest::Client::new() + .post(request.url) + .header(reqwest::header::CONTENT_TYPE, request.content_type) + .body(request.body) + .send() + .await + .map_err(|err| Error::Payjoin(err.to_string())) + }) + .await + .map_err(|_| { + Error::Payjoin(format!( + "Payjoin HTTP request timed out after {} seconds", + PAYJOIN_HTTP_REQUEST_TIMEOUT.as_secs() + )) + })??; + if !response.status().is_success() { + return Err(Error::Payjoin(format!( + "Payjoin HTTP request failed with status {}", + response.status() + ))); + } + tokio::time::timeout(PAYJOIN_HTTP_REQUEST_TIMEOUT, response.bytes()) + .await + .map_err(|_| { + Error::Payjoin(format!( + "Payjoin HTTP response body timed out after {} seconds", + PAYJOIN_HTTP_REQUEST_TIMEOUT.as_secs() + )) + })? + .map(|bytes| bytes.to_vec()) + .map_err(|err| Error::Payjoin(err.to_string())) +} + +fn find_payment_outpoint( + tx: &Transaction, + payment_script: &Script, + amount_sat: u64, +) -> Option { + // The payment proof records one outpoint, so require one receiver-script + // output to cover the full quote. A proposal that only pays the quote via + // multiple smaller outputs is valid-looking value-wise but not representable + // by the current proof model. + tx.output + .iter() + .enumerate() + .find(|(_, output)| { + output.script_pubkey.as_script() == payment_script + && output.value.to_sat() >= amount_sat + }) + .map(|(vout, _)| OutPoint::new(tx.compute_txid(), vout as u32)) +} + +fn find_output_outpoint(tx: &Transaction, script: &Script, amount_sat: u64) -> Option { + find_payment_outpoint(tx, script, amount_sat) +} + +fn require_payjoin_send_payment_output( + tx: &Transaction, + payment_script: &Script, + amount_sat: u64, +) -> Result { + find_payment_outpoint(tx, payment_script, amount_sat).ok_or_else(|| { + Error::Payjoin(format!( + "Payjoin transaction missing payment output for {} sats", + amount_sat + )) + }) +} + +/// Validate a signed Payjoin send by local wallet accounting. +/// +/// A receiver may contribute inputs and increase the receiver output, so the +/// proposal's total transaction fee is not the mint's fee contribution. The +/// relevant budget is the mint wallet's net spend (`sent - received`), which +/// must stay within `amount_sat + max_fee_sat`. The recorded fee contribution is +/// therefore `mint_net_spend_sat - amount_sat`. +fn validate_payjoin_send_transaction( + tx: &Transaction, + payment_script: &Script, + amount_sat: u64, + max_fee_sat: u64, + sent_sat: u64, + received_sat: u64, +) -> Result { + let payment_outpoint = require_payjoin_send_payment_output(tx, payment_script, amount_sat)?; + let mint_net_spend_sat = sent_sat.checked_sub(received_sat).ok_or_else(|| { + Error::Payjoin(format!( + "Payjoin transaction wallet receive amount {} exceeds sent amount {}", + received_sat, sent_sat + )) + })?; + let max_net_spend_sat = amount_sat.checked_add(max_fee_sat).ok_or_else(|| { + Error::Payjoin(format!( + "Payjoin spend cap overflow for amount {} and max fee {}", + amount_sat, max_fee_sat + )) + })?; + if mint_net_spend_sat > max_net_spend_sat { + return Err(Error::Payjoin(format!( + "Payjoin transaction spends {} sats from mint wallet, exceeding cap {}", + mint_net_spend_sat, max_net_spend_sat + ))); + } + let fee_contribution_sat = mint_net_spend_sat.checked_sub(amount_sat).ok_or_else(|| { + Error::Payjoin(format!( + "Payjoin transaction mint net spend {} is below payment amount {}", + mint_net_spend_sat, amount_sat + )) + })?; + + Ok(PayjoinSendValidation { + payment_outpoint, + fee_contribution_sat, + }) +} + +#[cfg(test)] +mod tests { + use std::str::FromStr; + + use bdk_wallet::bitcoin::absolute::LockTime; + use bdk_wallet::bitcoin::{transaction, Amount as BitcoinAmount, Psbt, ScriptBuf, TxOut, Txid}; + + use super::*; + + fn test_psbt_with_outputs(outputs: Vec) -> Psbt { + let tx = Transaction { + version: transaction::Version::TWO, + lock_time: LockTime::ZERO, + input: vec![TxIn { + previous_output: OutPoint::new( + Txid::from_str( + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + ) + .expect("valid txid"), + 0, + ), + script_sig: ScriptBuf::new(), + sequence: Sequence::MAX, + witness: Default::default(), + }], + output: outputs, + }; + Psbt::from_unsigned_tx(tx).expect("valid test psbt") + } + + #[test] + fn amountless_payjoin_receive_session_cap_comes_from_original_receiver_outputs() { + let events = vec![ + ::payjoin::receive::v2::SessionEvent::IdentifiedReceiverOutputs(vec![1]), + ::payjoin::receive::v2::SessionEvent::CommittedOutputs(vec![ + TxOut { + value: BitcoinAmount::from_sat(8_000), + script_pubkey: ScriptBuf::new(), + }, + TxOut { + value: BitcoinAmount::from_sat(3_000), + script_pubkey: ScriptBuf::new(), + }, + ]), + ]; + let mut record = crate::storage::PayjoinReceiveSessionRecord { + quote_id: "quote-1".to_string(), + fallback_address: "bcrt1qfallback".to_string(), + amount_sat: 0, + proposal_receiver_outpoints: Vec::new(), + expires_at: 1_700_000_000, + events, + closed: false, + }; + + update_payjoin_receive_credit_cap(&mut record); + + assert_eq!(record.amount_sat, 3_000); + } + + #[test] + fn payjoin_receive_session_records_proposal_receiver_outpoints() { + let fallback_script = ScriptBuf::from_bytes(vec![0x51]); + let other_script = ScriptBuf::from_bytes(vec![0x6a]); + let psbt = test_psbt_with_outputs(vec![ + TxOut { + value: BitcoinAmount::from_sat(8_000), + script_pubkey: other_script, + }, + TxOut { + value: BitcoinAmount::from_sat(3_000), + script_pubkey: fallback_script.clone(), + }, + ]); + let expected_outpoint = OutPoint::new(psbt.unsigned_tx.compute_txid(), 1).to_string(); + let mut record = crate::storage::PayjoinReceiveSessionRecord { + quote_id: "quote-1".to_string(), + fallback_address: "bcrt1qfallback".to_string(), + amount_sat: 3_000, + proposal_receiver_outpoints: Vec::new(), + expires_at: 1_700_000_000, + events: Vec::new(), + closed: false, + }; + + update_payjoin_receive_proposal_receiver_outpoints(&mut record, &psbt, &fallback_script); + + assert_eq!(record.proposal_receiver_outpoints, vec![expected_outpoint]); + } + + #[test] + fn payjoin_receive_fee_range_keeps_sender_min_and_zeroes_receiver_fee_cap() { + let (min_fee_rate, max_effective_fee_rate) = zero_receiver_fee_range(); + + assert_eq!(min_fee_rate, None); + assert_eq!(max_effective_fee_rate, Some(FeeRate::ZERO)); + } + + #[test] + fn payjoin_receiver_credit_sums_final_receiver_outputs() { + let fallback_script = ScriptBuf::from_bytes(vec![0x51]); + let other_script = ScriptBuf::from_bytes(vec![0x6a]); + let psbt = test_psbt_with_outputs(vec![ + TxOut { + value: BitcoinAmount::from_sat(2_000), + script_pubkey: fallback_script.clone(), + }, + TxOut { + value: BitcoinAmount::from_sat(9_000), + script_pubkey: other_script, + }, + TxOut { + value: BitcoinAmount::from_sat(3_000), + script_pubkey: fallback_script.clone(), + }, + ]); + + assert_eq!( + payjoin_receiver_output_amount(&psbt, &fallback_script).expect("sum outputs"), + 5_000 + ); + } + + #[test] + fn payjoin_receiver_credit_accepts_unreduced_receiver_output() { + let fallback_script = ScriptBuf::from_bytes(vec![0x51]); + let psbt = test_psbt_with_outputs(vec![TxOut { + value: BitcoinAmount::from_sat(5_000), + script_pubkey: fallback_script.clone(), + }]); + + ensure_payjoin_receiver_credit(&psbt, &fallback_script, 5_000) + .expect("sender-funded payjoin keeps receiver output whole"); + } + + #[test] + fn payjoin_receiver_credit_rejects_reduced_receiver_output() { + let fallback_script = ScriptBuf::from_bytes(vec![0x51]); + let psbt = test_psbt_with_outputs(vec![TxOut { + value: BitcoinAmount::from_sat(4_999), + script_pubkey: fallback_script.clone(), + }]); + + let err = ensure_payjoin_receiver_credit(&psbt, &fallback_script, 5_000) + .expect_err("receiver output below original amount must be rejected"); + + assert!(err.to_string().contains("below original amount")); + } + + #[test] + fn payjoin_send_payment_output_accepts_exact_output() { + let payment_script = ScriptBuf::from_bytes(vec![0x51]); + let other_script = ScriptBuf::from_bytes(vec![0x6a]); + let psbt = test_psbt_with_outputs(vec![ + TxOut { + value: BitcoinAmount::from_sat(9_000), + script_pubkey: other_script, + }, + TxOut { + value: BitcoinAmount::from_sat(10_000), + script_pubkey: payment_script.clone(), + }, + ]); + + let outpoint = + require_payjoin_send_payment_output(&psbt.unsigned_tx, &payment_script, 10_000) + .expect("payment output is present"); + + assert_eq!(outpoint.vout, 1); + } + + #[test] + fn payjoin_send_payment_output_accepts_larger_output() { + let payment_script = ScriptBuf::from_bytes(vec![0x51]); + let psbt = test_psbt_with_outputs(vec![TxOut { + value: BitcoinAmount::from_sat(12_000), + script_pubkey: payment_script.clone(), + }]); + + let outpoint = + require_payjoin_send_payment_output(&psbt.unsigned_tx, &payment_script, 10_000) + .expect("larger payment output is present"); + + assert_eq!(outpoint.vout, 0); + } + + #[test] + fn payjoin_send_payment_output_rejects_smaller_single_output() { + let payment_script = ScriptBuf::from_bytes(vec![0x51]); + let other_script = ScriptBuf::from_bytes(vec![0x6a]); + let psbt = test_psbt_with_outputs(vec![ + TxOut { + value: BitcoinAmount::from_sat(9_999), + script_pubkey: payment_script.clone(), + }, + TxOut { + value: BitcoinAmount::from_sat(10_000), + script_pubkey: other_script, + }, + ]); + + let err = require_payjoin_send_payment_output(&psbt.unsigned_tx, &payment_script, 10_000) + .expect_err("altered payment output must be rejected"); + + assert!(err.to_string().contains("missing payment output")); + } + + #[test] + fn payjoin_send_payment_output_rejects_split_only_outputs() { + let payment_script = ScriptBuf::from_bytes(vec![0x51]); + let psbt = test_psbt_with_outputs(vec![ + TxOut { + value: BitcoinAmount::from_sat(6_000), + script_pubkey: payment_script.clone(), + }, + TxOut { + value: BitcoinAmount::from_sat(4_000), + script_pubkey: payment_script.clone(), + }, + ]); + + let err = require_payjoin_send_payment_output(&psbt.unsigned_tx, &payment_script, 10_000) + .expect_err("split-only receiver outputs are unsupported"); + + assert!(err.to_string().contains("missing payment output")); + } + + #[test] + fn payjoin_send_validation_accepts_net_spend_within_cap() { + let payment_script = ScriptBuf::from_bytes(vec![0x51]); + let psbt = test_psbt_with_outputs(vec![TxOut { + value: BitcoinAmount::from_sat(10_000), + script_pubkey: payment_script.clone(), + }]); + + let validation = validate_payjoin_send_transaction( + &psbt.unsigned_tx, + &payment_script, + 10_000, + 1_000, + 12_000, + 1_000, + ) + .expect("net spend at cap is accepted"); + + assert_eq!(validation.fee_contribution_sat, 1_000); + } + + #[test] + fn payjoin_send_validation_accepts_larger_receiver_output_with_local_fee_cap() { + let payment_script = ScriptBuf::from_bytes(vec![0x51]); + let psbt = test_psbt_with_outputs(vec![TxOut { + value: BitcoinAmount::from_sat(12_000), + script_pubkey: payment_script.clone(), + }]); + + let validation = validate_payjoin_send_transaction( + &psbt.unsigned_tx, + &payment_script, + 10_000, + 1_000, + 20_000, + 9_500, + ) + .expect("receiver-funded larger output is accepted when mint spend is capped"); + + assert_eq!(validation.fee_contribution_sat, 500); + } + + #[test] + fn payjoin_send_validation_rejects_net_spend_above_cap() { + let payment_script = ScriptBuf::from_bytes(vec![0x51]); + let psbt = test_psbt_with_outputs(vec![TxOut { + value: BitcoinAmount::from_sat(10_000), + script_pubkey: payment_script.clone(), + }]); + + let err = validate_payjoin_send_transaction( + &psbt.unsigned_tx, + &payment_script, + 10_000, + 1_000, + 12_001, + 1_000, + ) + .expect_err("net spend above amount plus max fee is rejected"); + + assert!(err.to_string().contains("exceeding cap")); + } + + #[test] + fn payjoin_send_validation_rejects_net_spend_below_payment_amount() { + let payment_script = ScriptBuf::from_bytes(vec![0x51]); + let psbt = test_psbt_with_outputs(vec![TxOut { + value: BitcoinAmount::from_sat(10_000), + script_pubkey: payment_script.clone(), + }]); + + let err = validate_payjoin_send_transaction( + &psbt.unsigned_tx, + &payment_script, + 10_000, + 1_000, + 9_999, + 0, + ) + .expect_err("mint net spend below quote cannot produce fee contribution"); + + assert!(err.to_string().contains("below payment amount")); + } + + #[test] + fn payjoin_original_receiver_output_amount_sums_all_receiver_outputs() { + let events = vec![ + ::payjoin::receive::v2::SessionEvent::IdentifiedReceiverOutputs(vec![0, 2]), + ::payjoin::receive::v2::SessionEvent::CommittedOutputs(vec![ + TxOut { + value: BitcoinAmount::from_sat(21_000), + script_pubkey: ScriptBuf::new(), + }, + TxOut { + value: BitcoinAmount::from_sat(99_000), + script_pubkey: ScriptBuf::new(), + }, + TxOut { + value: BitcoinAmount::from_sat(34_000), + script_pubkey: ScriptBuf::new(), + }, + ]), + ]; + + assert_eq!( + payjoin_original_receiver_output_amount_from_events(&events), + Some(55_000) + ); + } + + #[test] + fn payjoin_receive_amount_missing_events_returns_none() { + let events = vec![::payjoin::receive::v2::SessionEvent::IdentifiedReceiverOutputs(vec![0])]; + + assert_eq!( + payjoin_original_receiver_output_amount_from_events(&events), + None + ); + } + + #[test] + fn payjoin_original_input_outpoints_come_from_retrieved_payload_event() { + let first_outpoint = OutPoint::new( + Txid::from_str("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + .expect("valid txid"), + 0, + ); + let second_outpoint = OutPoint::new( + Txid::from_str("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb") + .expect("valid txid"), + 1, + ); + let tx = Transaction { + version: transaction::Version::TWO, + lock_time: LockTime::ZERO, + input: vec![ + TxIn { + previous_output: first_outpoint, + script_sig: ScriptBuf::new(), + sequence: Sequence::MAX, + witness: Default::default(), + }, + TxIn { + previous_output: second_outpoint, + script_sig: ScriptBuf::new(), + sequence: Sequence::MAX, + witness: Default::default(), + }, + ], + output: vec![TxOut { + value: BitcoinAmount::from_sat(1_000), + script_pubkey: ScriptBuf::new(), + }], + }; + let psbt = Psbt::from_unsigned_tx(tx).expect("valid unsigned psbt"); + let event = serde_json::json!({ + "RetrievedOriginalPayload": { + "original": { + "psbt": psbt, + "params": { + "v": 2, + "output_substitution": "Enabled", + "additional_fee_contribution": null, + "min_fee_rate": 250 + } + }, + "reply_key": null + } + }); + let event = serde_json::from_value(event).expect("deserialize Payjoin session event"); + + assert_eq!( + payjoin_original_input_outpoints_from_events(&[event]) + .expect("extract original input outpoints"), + vec![first_outpoint, second_outpoint] + ); + } + + #[test] + fn payjoin_receive_session_expiry_is_strictly_in_the_past() { + let record = crate::storage::PayjoinReceiveSessionRecord { + quote_id: "quote-1".to_string(), + fallback_address: "bcrt1qfallback".to_string(), + amount_sat: 1_000, + proposal_receiver_outpoints: Vec::new(), + expires_at: 100, + events: Vec::new(), + closed: false, + }; + + let PayjoinReceiveSessionAny::Open(session) = payjoin_session::from_record(record) else { + panic!("expected open session"); + }; + + assert!(!session.is_expired(100)); + assert!(session.is_expired(101)); + } + + #[test] + fn payjoin_receive_session_prunes_closed_records_after_retention() { + let record = crate::storage::PayjoinReceiveSessionRecord { + quote_id: "quote-1".to_string(), + fallback_address: "bcrt1qfallback".to_string(), + amount_sat: 1_000, + proposal_receiver_outpoints: Vec::new(), + expires_at: 100, + events: Vec::new(), + closed: true, + }; + let retention_edge = 100 + PAYJOIN_RECEIVE_SESSION_RETENTION_SECS; + + let PayjoinReceiveSessionAny::Closed(session) = payjoin_session::from_record(record) else { + panic!("expected closed session"); + }; + + assert!(!session.should_prune(retention_edge, PAYJOIN_RECEIVE_SESSION_RETENTION_SECS)); + assert!(session.should_prune(retention_edge + 1, PAYJOIN_RECEIVE_SESSION_RETENTION_SECS)); + + let mut open_record = session.record().clone(); + open_record.closed = false; + assert!(matches!( + payjoin_session::from_record(open_record), + PayjoinReceiveSessionAny::Open(_) + )); + } + + #[test] + fn builds_payjoin_endpoint_from_normalized_fields() { + let payjoin = PayjoinV2::new( + "https://payjoin.example/pj".to_string(), + "QYPFLM8XL59R0XV4VGPLS7FRDSSM4TUXL07TXCWC4S0GLVLNK2SE4NQ", + "QV6WSX0UQPAEA0RH54430D0UVZWS8CZ6FEGZF4RGFCDKJLPGMYEJG", + 1_720_547_781, + ) + .expect("valid Payjoin keys"); + + assert_eq!( + build_payjoin_endpoint(&payjoin).expect("endpoint builds"), + "https://payjoin.example/pj#EX1C4UC6ES-OH1QYPFLM8XL59R0XV4VGPLS7FRDSSM4TUXL07TXCWC4S0GLVLNK2SE4NQ-RK1QV6WSX0UQPAEA0RH54430D0UVZWS8CZ6FEGZF4RGFCDKJLPGMYEJG" + ); + } +} diff --git a/crates/cdk-bdk/src/receive/mod.rs b/crates/cdk-bdk/src/receive/mod.rs index fc39954212..ef5b3bcb51 100644 --- a/crates/cdk-bdk/src/receive/mod.rs +++ b/crates/cdk-bdk/src/receive/mod.rs @@ -25,5 +25,6 @@ //! intent is finalized into a tombstone so historical status queries can still //! return the payment after the active record is removed. +pub(crate) mod payjoin_session; pub(crate) mod receive_intent; pub(crate) mod service; diff --git a/crates/cdk-bdk/src/receive/payjoin_session.rs b/crates/cdk-bdk/src/receive/payjoin_session.rs new file mode 100644 index 0000000000..63afc99e5b --- /dev/null +++ b/crates/cdk-bdk/src/receive/payjoin_session.rs @@ -0,0 +1,101 @@ +//! Payjoin receive-session typestate wrapper. +//! +//! This tracks the pre-detection Payjoin negotiation state for an incoming +//! payment. Once a UTXO is observed, the normal `ReceiveIntent` flow takes over. + +use std::marker::PhantomData; + +use crate::error::Error; +use crate::storage::{BdkStorage, PayjoinReceiveSessionRecord}; + +/// Open Payjoin receive session marker. +#[derive(Debug, Clone)] +pub(crate) struct Open; + +/// Closed Payjoin receive session marker. +#[derive(Debug, Clone)] +pub(crate) struct Closed; + +/// Type-erased persisted Payjoin receive session. +#[derive(Debug, Clone)] +pub(crate) enum PayjoinReceiveSessionAny { + /// Session can still be negotiated if Payjoin config is available. + Open(PayjoinReceiveSession), + /// Session is terminal and can eventually be pruned. + Closed(PayjoinReceiveSession), +} + +/// Persisted Payjoin receive session in a particular typestate. +#[derive(Debug, Clone)] +pub(crate) struct PayjoinReceiveSession { + record: PayjoinReceiveSessionRecord, + _state: PhantomData, +} + +impl PayjoinReceiveSession { + /// Borrow the underlying durable record. + pub(crate) fn record(&self) -> &PayjoinReceiveSessionRecord { + &self.record + } + + /// Convert into the underlying durable record. + pub(crate) fn into_record(self) -> PayjoinReceiveSessionRecord { + self.record + } +} + +impl PayjoinReceiveSession { + /// Create a new open session wrapper from a freshly built record. + pub(crate) fn new(record: PayjoinReceiveSessionRecord) -> Self { + Self { + record, + _state: PhantomData, + } + } + + /// Whether this open session has expired. + pub(crate) fn is_expired(&self, now: u64) -> bool { + self.record.expires_at < now + } + + /// Persist the open session. + pub(crate) async fn persist(&self, storage: &BdkStorage) -> Result<(), Error> { + storage.put_payjoin_receive_session(&self.record).await + } + + /// Close and persist the session. + pub(crate) async fn close( + mut self, + storage: &BdkStorage, + ) -> Result, Error> { + self.record.closed = true; + let closed = PayjoinReceiveSession { + record: self.record, + _state: PhantomData, + }; + storage.put_payjoin_receive_session(&closed.record).await?; + Ok(closed) + } +} + +impl PayjoinReceiveSession { + /// Whether this closed session has aged past the retention window. + pub(crate) fn should_prune(&self, now: u64, retention_secs: u64) -> bool { + self.record.expires_at.saturating_add(retention_secs) < now + } +} + +/// Reconstruct a typed Payjoin receive session from a durable record. +pub(crate) fn from_record(record: PayjoinReceiveSessionRecord) -> PayjoinReceiveSessionAny { + if record.closed { + PayjoinReceiveSessionAny::Closed(PayjoinReceiveSession { + record, + _state: PhantomData, + }) + } else { + PayjoinReceiveSessionAny::Open(PayjoinReceiveSession { + record, + _state: PhantomData, + }) + } +} diff --git a/crates/cdk-bdk/src/receive/receive_intent/mod.rs b/crates/cdk-bdk/src/receive/receive_intent/mod.rs index a10a743a7a..ae5063d5e9 100644 --- a/crates/cdk-bdk/src/receive/receive_intent/mod.rs +++ b/crates/cdk-bdk/src/receive/receive_intent/mod.rs @@ -52,6 +52,26 @@ impl ReceiveIntent { })?; let quote_id = request; + let amount_sat = match storage.get_payjoin_receive_session("e_id).await? { + Some(record) + if record.amount_sat > 0 + && amount_sat > record.amount_sat + && record + .proposal_receiver_outpoints + .iter() + .any(|proposal_outpoint| proposal_outpoint == &outpoint) => + { + tracing::warn!( + quote_id, + outpoint, + detected_amount_sat = amount_sat, + credited_amount_sat = record.amount_sat, + "Capping Payjoin receive credit to the original receiver output amount" + ); + record.amount_sat + } + _ => amount_sat, + }; let record = ReceiveIntentRecord { intent_id, @@ -95,6 +115,7 @@ impl ReceiveIntent { address: self.state.address.clone(), txid: self.state.txid.clone(), outpoint: self.state.outpoint.clone(), + payment_id: Some(self.state.outpoint.clone()), amount_sat: self.state.amount_sat, finalized_at: crate::util::unix_now(), }; @@ -179,6 +200,233 @@ mod tests { assert_eq!(intent.state.address, "bcrt1qaddr"); assert_eq!(intent.state.quote_id, quote_id); + assert_eq!(intent.state.amount_sat, 50_000); + } + + #[tokio::test] + async fn test_detected_creation_caps_payjoin_credit_to_original_receiver_output_amount() { + let storage = test_storage().await; + + let address = "bcrt1qaddr".to_string(); + let quote_id = Uuid::new_v4().to_string(); + storage + .track_receive_address(&address, "e_id) + .await + .expect("track address"); + storage + .put_payjoin_receive_session(&crate::storage::PayjoinReceiveSessionRecord { + quote_id: quote_id.clone(), + fallback_address: address.clone(), + amount_sat: 3_000, + proposal_receiver_outpoints: vec!["txid_abc:0".to_string()], + expires_at: crate::util::unix_now() + 60, + events: Vec::new(), + closed: false, + }) + .await + .expect("store payjoin session"); + + let intent = ReceiveIntent::new( + &storage, + address, + "txid_abc".to_string(), + "txid_abc:0".to_string(), + 8_000, + 100, + ) + .await + .expect("create detected intent") + .expect("should not be duplicate"); + + assert_eq!(intent.state.amount_sat, 3_000); + } + + #[tokio::test] + async fn test_payjoin_then_smaller_address_reuse_credits_actual_reuse_payment() { + let storage = test_storage().await; + + let address = "bcrt1qaddr".to_string(); + let quote_id = Uuid::new_v4().to_string(); + storage + .track_receive_address(&address, "e_id) + .await + .expect("track address"); + storage + .put_payjoin_receive_session(&crate::storage::PayjoinReceiveSessionRecord { + quote_id: quote_id.clone(), + fallback_address: address.clone(), + amount_sat: 3_000, + proposal_receiver_outpoints: vec!["txid_payjoin:0".to_string()], + expires_at: crate::util::unix_now() + 60, + events: Vec::new(), + closed: false, + }) + .await + .expect("store payjoin session"); + + let payjoin_intent = ReceiveIntent::new( + &storage, + address.clone(), + "txid_payjoin".to_string(), + "txid_payjoin:0".to_string(), + 8_000, + 100, + ) + .await + .expect("create payjoin detected intent") + .expect("payjoin intent should not be duplicate"); + + assert_eq!(payjoin_intent.state.amount_sat, 3_000); + payjoin_intent + .finalize(&storage) + .await + .expect("finalize payjoin intent"); + + let address_reuse_intent = ReceiveIntent::new( + &storage, + address, + "txid_reuse".to_string(), + "txid_reuse:0".to_string(), + 5_000, + 101, + ) + .await + .expect("create address reuse detected intent") + .expect("address reuse intent should not be duplicate"); + + assert_eq!(address_reuse_intent.state.amount_sat, 5_000); + } + + #[tokio::test] + async fn test_normal_address_reuse_then_payjoin_credits_actual_then_capped_amounts() { + let storage = test_storage().await; + + let address = "bcrt1qaddr".to_string(); + let quote_id = Uuid::new_v4().to_string(); + storage + .track_receive_address(&address, "e_id) + .await + .expect("track address"); + storage + .put_payjoin_receive_session(&crate::storage::PayjoinReceiveSessionRecord { + quote_id: quote_id.clone(), + fallback_address: address.clone(), + amount_sat: 3_000, + proposal_receiver_outpoints: vec!["txid_payjoin:0".to_string()], + expires_at: crate::util::unix_now() + 60, + events: Vec::new(), + closed: false, + }) + .await + .expect("store payjoin session"); + + let normal_intent = ReceiveIntent::new( + &storage, + address.clone(), + "txid_normal".to_string(), + "txid_normal:0".to_string(), + 5_000, + 100, + ) + .await + .expect("create normal detected intent") + .expect("normal intent should not be duplicate"); + + assert_eq!(normal_intent.state.amount_sat, 5_000); + normal_intent + .finalize(&storage) + .await + .expect("finalize normal intent"); + + let payjoin_intent = ReceiveIntent::new( + &storage, + address, + "txid_payjoin".to_string(), + "txid_payjoin:0".to_string(), + 8_000, + 101, + ) + .await + .expect("create payjoin detected intent") + .expect("payjoin intent should not be duplicate"); + + assert_eq!(payjoin_intent.state.amount_sat, 3_000); + } + + #[tokio::test] + async fn test_oversized_non_payjoin_payment_to_payjoin_address_is_not_capped() { + let storage = test_storage().await; + + let address = "bcrt1qaddr".to_string(); + let quote_id = Uuid::new_v4().to_string(); + storage + .track_receive_address(&address, "e_id) + .await + .expect("track address"); + storage + .put_payjoin_receive_session(&crate::storage::PayjoinReceiveSessionRecord { + quote_id: quote_id.clone(), + fallback_address: address.clone(), + amount_sat: 3_000, + proposal_receiver_outpoints: vec!["txid_payjoin:0".to_string()], + expires_at: crate::util::unix_now() + 60, + events: Vec::new(), + closed: false, + }) + .await + .expect("store payjoin session"); + + let intent = ReceiveIntent::new( + &storage, + address, + "txid_normal".to_string(), + "txid_normal:0".to_string(), + 8_000, + 100, + ) + .await + .expect("create detected intent") + .expect("should not be duplicate"); + + assert_eq!(intent.state.amount_sat, 8_000); + } + + #[tokio::test] + async fn test_detected_creation_does_not_cap_when_payjoin_session_has_no_amount() { + let storage = test_storage().await; + + let address = "bcrt1qaddr".to_string(); + let quote_id = Uuid::new_v4().to_string(); + storage + .track_receive_address(&address, "e_id) + .await + .expect("track address"); + storage + .put_payjoin_receive_session(&crate::storage::PayjoinReceiveSessionRecord { + quote_id: quote_id.clone(), + fallback_address: address.clone(), + amount_sat: 0, + proposal_receiver_outpoints: vec!["txid_abc:0".to_string()], + expires_at: crate::util::unix_now() + 60, + events: Vec::new(), + closed: false, + }) + .await + .expect("store payjoin session"); + + let intent = ReceiveIntent::new( + &storage, + address, + "txid_abc".to_string(), + "txid_abc:0".to_string(), + 8_000, + 100, + ) + .await + .expect("create detected intent") + .expect("should not be duplicate"); + + assert_eq!(intent.state.amount_sat, 8_000); } #[tokio::test] diff --git a/crates/cdk-bdk/src/receive/service.rs b/crates/cdk-bdk/src/receive/service.rs index 06b41d773b..9a5aa19c2f 100644 --- a/crates/cdk-bdk/src/receive/service.rs +++ b/crates/cdk-bdk/src/receive/service.rs @@ -136,6 +136,14 @@ impl CdkBdk { continue; } + if self + .storage + .has_receive_intent_for_outpoint(&outpoint) + .await? + { + continue; + } + match ReceiveIntent::new( &self.storage, address, diff --git a/crates/cdk-bdk/src/recovery.rs b/crates/cdk-bdk/src/recovery.rs index 08c2058446..610db4eb7f 100644 --- a/crates/cdk-bdk/src/recovery.rs +++ b/crates/cdk-bdk/src/recovery.rs @@ -80,12 +80,27 @@ impl CdkBdk { found_ids.insert(record.intent_id); match &record.state { - SendIntentState::Pending { .. } => { + SendIntentState::Pending { .. } | SendIntentState::BatchClaimed { .. } => { saw_pending = true; tracing::warn!( batch_id = %batch_id, intent_id = %record.intent_id, - "Recovery found batch member stored as Pending" + "Recovery found batch member stored before Batched" + ); + } + SendIntentState::CutThroughReserved { .. } => { + tracing::warn!( + batch_id = %batch_id, + intent_id = %record.intent_id, + "Recovery found batch member reserved by cut-through" + ); + } + SendIntentState::PayjoinNegotiating { .. } => { + saw_pending = true; + tracing::warn!( + batch_id = %batch_id, + intent_id = %record.intent_id, + "Recovery found batch member still negotiating Payjoin" ); } SendIntentState::Batched { @@ -160,7 +175,22 @@ impl CdkBdk { SendIntentState::AwaitingConfirmation { .. } => { BatchIntentRelation::IntentAlreadyAdvanced } - SendIntentState::Pending { .. } | SendIntentState::Failed { .. } => { + SendIntentState::BatchClaimed { + batch_id: intent_batch_id, + .. + } => { + if *intent_batch_id == batch_id { + BatchIntentRelation::Valid + } else { + BatchIntentRelation::IntentReferencesDifferentBatch + } + } + SendIntentState::Pending { .. } + | SendIntentState::CutThroughReserved { .. } + | SendIntentState::Failed { .. } => { + BatchIntentRelation::IntentReferencesDifferentBatch + } + SendIntentState::PayjoinNegotiating { .. } => { BatchIntentRelation::IntentReferencesDifferentBatch } }, @@ -300,6 +330,51 @@ impl CdkBdk { } } } + SendIntentAny::BatchClaimed(intent) + if intent.state.batch_id == batch_record.batch_id => + { + tracing::warn!( + batch_id = %batch_record.batch_id, + intent_id = %id, + "Repairing Signed batch member still stored as BatchClaimed" + ); + match intent.assign_to_batch(&self.storage).await { + Ok(intent) => batched_intents.push(intent), + Err(err) => { + tracing::error!( + batch_id = %batch_record.batch_id, + intent_id = %id, + error = %err, + "Signed batch recovery aborted because claimed member could not be assigned" + ); + abort_recovery = true; + break; + } + } + } + SendIntentAny::PayjoinNegotiating(intent) => { + tracing::warn!( + batch_id = %batch_record.batch_id, + intent_id = %id, + "Repairing Signed batch member still stored as PayjoinNegotiating" + ); + match intent + .assign_to_batch(&self.storage, batch_record.batch_id) + .await + { + Ok(intent) => batched_intents.push(intent), + Err(err) => { + tracing::error!( + batch_id = %batch_record.batch_id, + intent_id = %id, + error = %err, + "Signed batch recovery aborted because Payjoin member could not be assigned" + ); + abort_recovery = true; + break; + } + } + } SendIntentAny::Batched(intent) => { tracing::error!( batch_id = %batch_record.batch_id, @@ -310,6 +385,26 @@ impl CdkBdk { abort_recovery = true; break; } + SendIntentAny::BatchClaimed(intent) => { + tracing::error!( + batch_id = %batch_record.batch_id, + intent_id = %id, + intent_batch_id = %intent.state.batch_id, + "Signed batch recovery aborted because a claimed member references a different batch" + ); + abort_recovery = true; + break; + } + SendIntentAny::CutThroughReserved(intent) => { + tracing::error!( + batch_id = %batch_record.batch_id, + intent_id = %id, + settlement_id = %intent.state.settlement_id, + "Signed batch recovery aborted because a member is reserved by cut-through" + ); + abort_recovery = true; + break; + } SendIntentAny::AwaitingConfirmation(_) => { tracing::error!( batch_id = %batch_record.batch_id, @@ -499,6 +594,35 @@ impl CdkBdk { for persisted in persisted_intents { match payment_intent::from_record(&persisted) { SendIntentAny::Pending(_) => {} + SendIntentAny::BatchClaimed(intent) => { + let intent_id = intent.intent_id; + let batch = batches.iter().find(|b| b.batch_id == intent.state.batch_id); + if batch.is_none() + || batch.is_some_and(|batch| { + !batch_intent_ids(&batch.state).contains(&intent_id) + }) + { + tracing::info!( + "Orphaned batch-claimed intent {}, reverting to Pending", + intent_id + ); + if let Err(e) = intent.revert_to_pending(&self.storage).await { + tracing::error!( + "Failed to revert orphaned claimed intent {} during recovery: {}", + intent_id, + e + ); + } + } + } + SendIntentAny::CutThroughReserved(intent) => { + tracing::trace!( + intent_id = %intent.intent_id, + settlement_id = %intent.state.settlement_id, + "Leaving cut-through-reserved intent for cut-through recovery" + ); + } + SendIntentAny::PayjoinNegotiating(_) => {} SendIntentAny::Failed => {} SendIntentAny::Batched(intent) => { let intent_id = intent.intent_id; @@ -706,6 +830,7 @@ mod tests { 60, Some(5), None, + None, ) .expect("build CdkBdk test instance") } diff --git a/crates/cdk-bdk/src/send/payment_intent/mod.rs b/crates/cdk-bdk/src/send/payment_intent/mod.rs index e1f3487f5a..b1d6dcef06 100644 --- a/crates/cdk-bdk/src/send/payment_intent/mod.rs +++ b/crates/cdk-bdk/src/send/payment_intent/mod.rs @@ -12,7 +12,10 @@ pub(crate) mod state; use uuid::Uuid; use self::record::{SendIntentRecord, SendIntentState}; -use self::state::{AwaitingConfirmation, Batched, Failed, Pending}; +use self::state::{ + AwaitingConfirmation, BatchClaimed, Batched, CutThroughReserved, Failed, PayjoinNegotiating, + Pending, +}; use crate::error::Error; use crate::storage::{BdkStorage, FailedSendAttemptRecord, FinalizedSendIntentRecord}; use crate::types::{PaymentMetadata, PaymentTier}; @@ -123,6 +126,207 @@ impl SendIntent { } /// Mark a pending intent as failed before a signed transaction was committed. + #[cfg(test)] + pub async fn fail( + self, + storage: &BdkStorage, + reason: String, + ) -> Result, Error> { + let failed_at = crate::util::unix_now(); + storage + .update_send_intent( + &self.intent_id, + &SendIntentState::Failed { + reason: reason.clone(), + created_at: self.created_at, + failed_at, + }, + ) + .await?; + storage + .add_failed_send_attempt(&FailedSendAttemptRecord { + attempt_id: Uuid::new_v4(), + intent_id: self.intent_id, + quote_id: self.quote_id.clone(), + reason: reason.clone(), + failed_at, + }) + .await?; + + Ok(SendIntent { + intent_id: self.intent_id, + quote_id: self.quote_id, + address: self.address, + amount: self.amount, + max_fee_amount: self.max_fee_amount, + tier: self.tier, + metadata: self.metadata, + created_at: self.created_at, + state: Failed, + }) + } +} + +impl SendIntent { + /// Transition a claimed intent to Batched state. + pub async fn assign_to_batch(self, storage: &BdkStorage) -> Result, Error> { + storage + .update_send_intent( + &self.intent_id, + &SendIntentState::Batched { + batch_id: self.state.batch_id, + created_at: self.created_at, + }, + ) + .await?; + + Ok(SendIntent { + intent_id: self.intent_id, + quote_id: self.quote_id, + address: self.address, + amount: self.amount, + max_fee_amount: self.max_fee_amount, + tier: self.tier, + metadata: self.metadata, + created_at: self.created_at, + state: Batched { + batch_id: self.state.batch_id, + }, + }) + } + + /// Revert to Pending state (compensation). + pub async fn revert_to_pending( + self, + storage: &BdkStorage, + ) -> Result, Error> { + storage + .update_send_intent( + &self.intent_id, + &SendIntentState::Pending { + created_at: self.created_at, + }, + ) + .await?; + + Ok(SendIntent { + intent_id: self.intent_id, + quote_id: self.quote_id, + address: self.address, + amount: self.amount, + max_fee_amount: self.max_fee_amount, + tier: self.tier, + metadata: self.metadata, + created_at: self.created_at, + state: Pending, + }) + } +} + +impl SendIntent { + /// Create a new Payjoin-negotiating send intent and persist it immediately. + #[allow(clippy::too_many_arguments)] + pub async fn new_payjoin( + storage: &BdkStorage, + quote_id: String, + address: String, + amount: u64, + max_fee_amount: u64, + tier: PaymentTier, + metadata: PaymentMetadata, + original_tx_bytes: Vec, + original_fee_sat: u64, + events: Vec, + ) -> Result { + let intent_id = Uuid::new_v4(); + let created_at = crate::util::unix_now(); + + let record = SendIntentRecord { + intent_id, + quote_id: quote_id.clone(), + address: address.clone(), + amount_sat: amount, + max_fee_amount_sat: max_fee_amount, + tier, + metadata: metadata.clone(), + state: SendIntentState::PayjoinNegotiating { + original_tx_bytes: original_tx_bytes.clone(), + original_fee_sat, + events: events.clone(), + created_at, + }, + }; + + storage.create_send_intent_if_absent(&record).await?; + + Ok(Self { + intent_id, + quote_id, + address, + amount, + max_fee_amount, + tier, + metadata, + created_at, + state: PayjoinNegotiating { + original_tx_bytes, + original_fee_sat, + events, + }, + }) + } + + /// Persist updated Payjoin sender events while staying in negotiation. + pub async fn update_payjoin_events( + &mut self, + storage: &BdkStorage, + events: Vec, + ) -> Result<(), Error> { + storage + .update_send_intent( + &self.intent_id, + &SendIntentState::PayjoinNegotiating { + original_tx_bytes: self.state.original_tx_bytes.clone(), + original_fee_sat: self.state.original_fee_sat, + events: events.clone(), + created_at: self.created_at, + }, + ) + .await?; + self.state.events = events; + Ok(()) + } + + /// Transition a Payjoin-negotiating intent into a one-intent batch. + pub async fn assign_to_batch( + self, + storage: &BdkStorage, + batch_id: Uuid, + ) -> Result, Error> { + storage + .update_send_intent( + &self.intent_id, + &SendIntentState::Batched { + batch_id, + created_at: self.created_at, + }, + ) + .await?; + + Ok(SendIntent { + intent_id: self.intent_id, + quote_id: self.quote_id, + address: self.address, + amount: self.amount, + max_fee_amount: self.max_fee_amount, + tier: self.tier, + metadata: self.metadata, + created_at: self.created_at, + state: Batched { batch_id }, + }) + } + + /// Mark a Payjoin negotiation as failed before any PSBT is shared. pub async fn fail( self, storage: &BdkStorage, @@ -269,6 +473,38 @@ pub(crate) fn from_record(record: &SendIntentRecord) -> SendIntentAny { created_at: *created_at, state: Pending, }), + SendIntentState::BatchClaimed { + batch_id, + created_at, + } => SendIntentAny::BatchClaimed(SendIntent { + intent_id: record.intent_id, + quote_id: record.quote_id.clone(), + address: record.address.clone(), + amount: record.amount_sat, + max_fee_amount: record.max_fee_amount_sat, + tier: record.tier, + metadata: record.metadata.clone(), + created_at: *created_at, + state: BatchClaimed { + batch_id: *batch_id, + }, + }), + SendIntentState::CutThroughReserved { + settlement_id, + created_at, + } => SendIntentAny::CutThroughReserved(SendIntent { + intent_id: record.intent_id, + quote_id: record.quote_id.clone(), + address: record.address.clone(), + amount: record.amount_sat, + max_fee_amount: record.max_fee_amount_sat, + tier: record.tier, + metadata: record.metadata.clone(), + created_at: *created_at, + state: CutThroughReserved { + settlement_id: *settlement_id, + }, + }), SendIntentState::Batched { batch_id, created_at, @@ -285,6 +521,26 @@ pub(crate) fn from_record(record: &SendIntentRecord) -> SendIntentAny { batch_id: *batch_id, }, }), + SendIntentState::PayjoinNegotiating { + original_tx_bytes, + original_fee_sat, + events, + created_at, + } => SendIntentAny::PayjoinNegotiating(SendIntent { + intent_id: record.intent_id, + quote_id: record.quote_id.clone(), + address: record.address.clone(), + amount: record.amount_sat, + max_fee_amount: record.max_fee_amount_sat, + tier: record.tier, + metadata: record.metadata.clone(), + created_at: *created_at, + state: PayjoinNegotiating { + original_tx_bytes: original_tx_bytes.clone(), + original_fee_sat: *original_fee_sat, + events: events.clone(), + }, + }), SendIntentState::AwaitingConfirmation { batch_id, txid, @@ -322,6 +578,12 @@ pub(crate) fn from_record(record: &SendIntentRecord) -> SendIntentAny { pub(crate) enum SendIntentAny { /// Intent in Pending state Pending(SendIntent), + /// Intent claimed by the normal batch builder + BatchClaimed(SendIntent), + /// Intent reserved by a Payjoin cut-through settlement + CutThroughReserved(SendIntent), + /// Intent in Payjoin negotiation state + PayjoinNegotiating(SendIntent), /// Intent in Batched state Batched(SendIntent), /// Intent in AwaitingConfirmation state diff --git a/crates/cdk-bdk/src/send/payment_intent/record.rs b/crates/cdk-bdk/src/send/payment_intent/record.rs index 9dbab57ec3..8d74d03f28 100644 --- a/crates/cdk-bdk/src/send/payment_intent/record.rs +++ b/crates/cdk-bdk/src/send/payment_intent/record.rs @@ -13,6 +13,33 @@ pub enum SendIntentState { /// When the intent was created (unix timestamp seconds) created_at: u64, }, + /// Intent has been claimed by the normal batch builder before transaction + /// construction. + BatchClaimed { + /// The batch this intent is claimed for + batch_id: Uuid, + /// When the intent was created (unix timestamp seconds) + created_at: u64, + }, + /// Intent has been reserved by an incoming Payjoin cut-through settlement. + CutThroughReserved { + /// Settlement this intent is reserved for + settlement_id: Uuid, + /// When the intent was created (unix timestamp seconds) + created_at: u64, + }, + /// Intent is negotiating a Payjoin transaction before a final transaction + /// has been selected and durably staged as a batch. + PayjoinNegotiating { + /// Consensus-serialized signed original transaction, used as fallback. + original_tx_bytes: Vec, + /// Fee of the signed original transaction in satoshis. + original_fee_sat: u64, + /// Append-only Payjoin sender event log. + events: Vec, + /// When the intent was created (unix timestamp seconds) + created_at: u64, + }, /// Intent has been assigned to a batch Batched { /// The batch this intent belongs to diff --git a/crates/cdk-bdk/src/send/payment_intent/state.rs b/crates/cdk-bdk/src/send/payment_intent/state.rs index e70477c7c1..3c951cd990 100644 --- a/crates/cdk-bdk/src/send/payment_intent/state.rs +++ b/crates/cdk-bdk/src/send/payment_intent/state.rs @@ -6,6 +6,31 @@ use uuid::Uuid; #[derive(Debug, Clone)] pub struct Pending; +/// Marker for an intent claimed by the normal batch builder +#[derive(Debug, Clone)] +pub struct BatchClaimed { + /// The batch this intent is claimed for + pub batch_id: Uuid, +} + +/// Marker for an intent reserved by a Payjoin cut-through settlement +#[derive(Debug, Clone)] +pub struct CutThroughReserved { + /// Settlement this intent is reserved for + pub settlement_id: Uuid, +} + +/// Marker for an intent negotiating a Payjoin transaction before broadcast +#[derive(Debug, Clone)] +pub struct PayjoinNegotiating { + /// Consensus-serialized signed original transaction. + pub original_tx_bytes: Vec, + /// Fee of the signed original transaction in satoshis. + pub original_fee_sat: u64, + /// Persisted Payjoin sender event log. + pub events: Vec, +} + /// Marker for an intent assigned to a batch #[derive(Debug, Clone)] pub struct Batched { diff --git a/crates/cdk-bdk/src/send/service.rs b/crates/cdk-bdk/src/send/service.rs index f3f6d72917..8eba58a90a 100644 --- a/crates/cdk-bdk/src/send/service.rs +++ b/crates/cdk-bdk/src/send/service.rs @@ -15,14 +15,31 @@ use crate::send::batch_transaction::record::{ use crate::send::batch_transaction::{allocate_batch_fee, state as batch_state, SendBatch}; use crate::send::payment_intent::{self, state as intent_state, SendIntent, SendIntentAny}; use crate::types::PaymentTier; +use crate::util::parse_checked_address; use crate::CdkBdk; impl CdkBdk { - async fn fail_send_intents(&self, intents: &[SendIntent], reason: &str) { + async fn fail_claimed_send_intents( + &self, + intents: &[SendIntent], + reason: &str, + ) { for intent in intents { - if let Err(err) = intent.clone().fail(&self.storage, reason.to_string()).await { + let failed_at = crate::util::unix_now(); + if let Err(err) = self + .storage + .update_send_intent( + &intent.intent_id, + &crate::send::payment_intent::record::SendIntentState::Failed { + reason: reason.to_string(), + created_at: intent.created_at, + failed_at, + }, + ) + .await + { tracing::error!( - "Failed to mark send intent {} failed after terminal batch failure: {}", + "Failed to mark claimed send intent {} failed after terminal batch failure: {}", intent.intent_id, err ); @@ -152,10 +169,10 @@ impl CdkBdk { /// `fee_allocations` must be positionally aligned with `intents` (i.e. /// `fee_allocations[i]` is the fee for `intents[i]`). This is the natural /// output of [`allocate_batch_fee`]. - pub(crate) fn derive_pending_vout_assignments( + pub(crate) fn derive_claimed_vout_assignments( &self, tx: &Transaction, - intents: &[SendIntent], + intents: &[SendIntent], fee_allocations: &[u64], ) -> Result, Error> { let intent_outputs: Vec<_> = intents @@ -345,24 +362,36 @@ impl CdkBdk { tracing::info!("Processing batch of {} intents", batch_intents.len()); - // Reconstruct typed SendIntent from persisted state - let mut pending_intents: Vec> = Vec::new(); - for pi in &batch_intents { - match payment_intent::from_record(pi) { - SendIntentAny::Pending(intent) => pending_intents.push(intent), + let batch_id = Uuid::new_v4(); + let intent_ids = batch_intents + .iter() + .map(|intent| intent.intent_id) + .collect::>(); + let claimed_records = self + .storage + .claim_pending_send_intents_for_batch(&intent_ids, batch_id) + .await?; + if claimed_records.is_empty() { + return Ok(()); + } + + let mut claimed_intents: Vec> = Vec::new(); + for record in &claimed_records { + match payment_intent::from_record(record) { + SendIntentAny::BatchClaimed(intent) => claimed_intents.push(intent), _ => continue, } } - self.build_sign_broadcast_batch(pending_intents).await + self.build_sign_broadcast_batch(batch_id, claimed_intents) + .await } pub(crate) async fn build_sign_broadcast_batch( &self, - intents: Vec>, + batch_id: Uuid, + intents: Vec>, ) -> Result<(), Error> { - let batch_id = Uuid::new_v4(); - let mut highest_tier = PaymentTier::Economy; let mut recipients = Vec::with_capacity(intents.len()); for intent in &intents { @@ -373,17 +402,12 @@ impl CdkBdk { highest_tier = PaymentTier::Standard; } - let address = match Address::from_str(&intent.address) - .map_err(|e| Error::Wallet(e.to_string())) - .and_then(|address| { - address - .require_network(self.network) - .map_err(|e| Error::Wallet(e.to_string())) - }) { + let address = match parse_checked_address(&intent.address, self.network, Error::Wallet) + { Ok(address) => address, Err(e) => { let reason = e.to_string(); - self.fail_send_intents(&intents, &reason).await; + self.fail_claimed_send_intents(&intents, &reason).await; return Err(e); } }; @@ -426,7 +450,7 @@ impl CdkBdk { let error_text = e.to_string(); drop(wallet_with_db); - self.fail_send_intents(&intents, &error_text).await; + self.fail_claimed_send_intents(&intents, &error_text).await; return Err(Error::Wallet(e.to_string())); } @@ -439,7 +463,7 @@ impl CdkBdk { let err = Error::Wallet(e.to_string()); let reason = err.to_string(); drop(wallet_with_db); - self.fail_send_intents(&intents, &reason).await; + self.fail_claimed_send_intents(&intents, &reason).await; return Err(err); } }; @@ -453,7 +477,7 @@ impl CdkBdk { tracing::warn!("Fee allocation failed, cancelling batch: {}", e); let reason = e.to_string(); drop(wallet_with_db); - self.fail_send_intents(&intents, &reason).await; + self.fail_claimed_send_intents(&intents, &reason).await; return Err(e); } }; @@ -463,7 +487,7 @@ impl CdkBdk { let err = Error::Database(e); let reason = err.to_string(); drop(wallet_with_db); - self.fail_send_intents(&intents, &reason).await; + self.fail_claimed_send_intents(&intents, &reason).await; return Err(err); } @@ -474,14 +498,14 @@ impl CdkBdk { let err = Error::Wallet(e.to_string()); let reason = err.to_string(); drop(wallet_with_db); - self.fail_send_intents(&intents, &reason).await; + self.fail_claimed_send_intents(&intents, &reason).await; return Err(err); } }; if !signed { let reason = Error::CouldNotSign.to_string(); drop(wallet_with_db); - self.fail_send_intents(&intents, &reason).await; + self.fail_claimed_send_intents(&intents, &reason).await; return Err(Error::CouldNotSign); } @@ -525,7 +549,7 @@ impl CdkBdk { // of Pending; this makes every post-sign crash/failure recoverable // from the signed transaction bytes instead of reverting into a new // batch. - let assignments = self.derive_pending_vout_assignments(&tx, &intents, &fee_allocations)?; + let assignments = self.derive_claimed_vout_assignments(&tx, &intents, &fee_allocations)?; let intent_count = assignments.len(); if let Err(e) = self @@ -563,7 +587,7 @@ impl CdkBdk { // 4. Transition intents to Batched after the signed transaction is durable. let mut batched_intents = Vec::new(); for intent in intents { - let batched = intent.assign_to_batch(&self.storage, batch_id).await?; + let batched = intent.assign_to_batch(&self.storage).await?; batched_intents.push(batched); } let signed_batch = @@ -869,10 +893,7 @@ fn derive_vout_assignments_inner( let mut assignments = Vec::with_capacity(intents.len()); for (idx, intent) in intents.iter().enumerate() { - let address = Address::from_str(intent.address) - .map_err(|e| Error::Wallet(e.to_string()))? - .require_network(network) - .map_err(|e| Error::Wallet(e.to_string()))?; + let address = parse_checked_address(intent.address, network, Error::Wallet)?; let vout = tx .output .iter() @@ -1222,6 +1243,7 @@ mod tests { 60, Some(5), None, + None, ) .expect("build CdkBdk test instance") } diff --git a/crates/cdk-bdk/src/storage/mod.rs b/crates/cdk-bdk/src/storage/mod.rs index fb9b37d004..9c44609ee3 100644 --- a/crates/cdk-bdk/src/storage/mod.rs +++ b/crates/cdk-bdk/src/storage/mod.rs @@ -15,7 +15,10 @@ pub mod receive; pub mod send; mod types; -pub use types::{FailedSendAttemptRecord, FinalizedReceiveIntentRecord, FinalizedSendIntentRecord}; +pub use types::{ + CutThroughSettlementRecord, CutThroughSettlementState, FailedSendAttemptRecord, + FinalizedReceiveIntentRecord, FinalizedSendIntentRecord, PayjoinReceiveSessionRecord, +}; /// Primary namespace for BDK KV store operations pub const BDK_NAMESPACE: &str = "bdk"; @@ -72,12 +75,21 @@ pub fn finalized_receive_intent_by_quote_namespace(quote_id: &str) -> String { /// Secondary namespace for finalized send intent quote id index (quote_id -> intent_id) pub const FINALIZED_SEND_INTENT_QUOTE_ID_NAMESPACE: &str = "finalized_send_intent_quote_id"; +/// Secondary namespace for Payjoin v2 receive sessions keyed by quote id. +pub const PAYJOIN_RECEIVE_SESSION_NAMESPACE: &str = "payjoin_receive_session"; + +/// Secondary namespace for Payjoin v2 receive input outpoints. +pub const PAYJOIN_RECEIVE_INPUT_OUTPOINT_NAMESPACE: &str = "payjoin_receive_input_outpoint"; + +/// Secondary namespace for Payjoin cut-through settlements. +pub const CUT_THROUGH_SETTLEMENT_NAMESPACE: &str = "cut_through_settlement"; + /// Encode an outpoint string for use as a KV store key. /// /// The KV store only allows ASCII letters, numbers, underscore, and /// hyphen. Outpoint strings contain `:` (e.g. `txid:vout`), so we /// replace it with `-`. -fn outpoint_to_key(outpoint: &str) -> String { +pub(crate) fn outpoint_to_key(outpoint: &str) -> String { outpoint.replace(':', "-") } @@ -207,6 +219,110 @@ impl BdkStorage { record.replace_state(new_state.clone()); self.put_record(&record).await } + + /// Store or replace a Payjoin receive session. + pub async fn put_payjoin_receive_session( + &self, + record: &PayjoinReceiveSessionRecord, + ) -> Result<(), Error> { + self.put_record(record).await + } + + /// Load a Payjoin receive session by quote id. + pub async fn get_payjoin_receive_session( + &self, + quote_id: &str, + ) -> Result, Error> { + self.get_record(quote_id).await + } + + /// List all Payjoin receive sessions. + pub async fn get_all_payjoin_receive_sessions( + &self, + ) -> Result, Error> { + self.list_records().await + } + + /// Delete a Payjoin receive session by quote id. + pub async fn delete_payjoin_receive_session(&self, quote_id: &str) -> Result<(), Error> { + self.delete_record::(quote_id) + .await + } + + /// Return whether a Payjoin receive input outpoint was previously seen. + /// + /// Intentionally **global** (keyed by outpoint only): probing spans many + /// cheap sessions, so only cross-session memory catches input reuse — + /// per-quote scoping would reset every probe and defeat anti-probing. The + /// tradeoff is a poisoning vector, gated on Bitcoin Core by `test_mempool_accept` + /// (see `ChainSource::accepts_broadcast`); on Esplora it stays poisonable but + /// only degrades gracefully (fallback, no fund loss). + pub async fn is_payjoin_input_seen(&self, outpoint: &str) -> Result { + let outpoint_key = outpoint_to_key(outpoint); + self.kv_store + .kv_read( + BDK_NAMESPACE, + PAYJOIN_RECEIVE_INPUT_OUTPOINT_NAMESPACE, + &outpoint_key, + ) + .await + .map(|entry| entry.is_some()) + .map_err(Error::from) + } + + /// Return when a Payjoin receive input outpoint was first seen, if any. + #[cfg(test)] + pub async fn get_payjoin_seen_input_seen_at( + &self, + outpoint: &str, + ) -> Result, Error> { + let outpoint_key = outpoint_to_key(outpoint); + let seen_at_bytes = self + .kv_store + .kv_read( + BDK_NAMESPACE, + PAYJOIN_RECEIVE_INPUT_OUTPOINT_NAMESPACE, + &outpoint_key, + ) + .await + .map_err(Error::from)?; + + let Some(seen_at_bytes) = seen_at_bytes else { + return Ok(None); + }; + + let seen_at = String::from_utf8(seen_at_bytes) + .map_err(|e| Error::Wallet(format!("Invalid Payjoin input index entry: {}", e)))?; + let seen_at = seen_at + .parse::() + .map_err(|e| Error::Wallet(format!("Invalid Payjoin input seen timestamp: {}", e)))?; + Ok(Some(seen_at)) + } + + /// Mark Payjoin receive input outpoints as seen. + pub async fn mark_payjoin_inputs_seen(&self, outpoints: &[String]) -> Result<(), Error> { + let seen_at = crate::util::unix_now().to_string(); + let mut tx = self + .kv_store + .begin_transaction() + .await + .map_err(Error::from)?; + + for outpoint in outpoints { + let outpoint_key = outpoint_to_key(outpoint); + tx.kv_write( + BDK_NAMESPACE, + PAYJOIN_RECEIVE_INPUT_OUTPOINT_NAMESPACE, + &outpoint_key, + seen_at.as_bytes(), + ) + .await + .map_err(Error::from)?; + } + + tx.commit().await.map_err(Error::from)?; + Ok(()) + } } impl KvRecord for SendIntentRecord { @@ -225,6 +341,14 @@ impl KvRecord for FailedSendAttemptRecord { } } +impl KvRecord for CutThroughSettlementRecord { + const NAMESPACE: &'static str = CUT_THROUGH_SETTLEMENT_NAMESPACE; + + fn key(&self) -> String { + self.settlement_id.to_string() + } +} + impl ReplaceState for SendIntentRecord { fn replace_state(&mut self, state: crate::send::payment_intent::record::SendIntentState) { self.state = state; @@ -269,6 +393,14 @@ impl KvRecord for FinalizedReceiveIntentRecord { } } +impl KvRecord for PayjoinReceiveSessionRecord { + const NAMESPACE: &'static str = PAYJOIN_RECEIVE_SESSION_NAMESPACE; + + fn key(&self) -> String { + self.quote_id.clone() + } +} + #[cfg(test)] mod tests { use std::sync::Arc; @@ -306,6 +438,230 @@ mod tests { } } + #[test] + fn finalized_receive_intent_deserializes_legacy_without_payment_id() { + let value = serde_json::json!({ + "intent_id": Uuid::new_v4(), + "quote_id": Uuid::new_v4().to_string(), + "address": "bcrt1qaddr", + "txid": "abc123", + "outpoint": "abc123:0", + "amount_sat": 50_000, + "finalized_at": 1_700_000_001_u64 + }); + + let record: FinalizedReceiveIntentRecord = + serde_json::from_value(value).expect("legacy tombstone should deserialize"); + + assert_eq!(record.payment_id, None); + assert_eq!(record.outpoint, "abc123:0"); + } + + #[tokio::test] + async fn test_claim_pending_send_intents_for_batch_is_conditional() { + let storage = test_storage().await; + let batch_id = Uuid::new_v4(); + let first_id = Uuid::new_v4(); + let second_id = Uuid::new_v4(); + let mut first = make_pending_intent(first_id); + first.quote_id = "claim-quote-1".to_string(); + let mut second = make_pending_intent(second_id); + second.quote_id = "claim-quote-2".to_string(); + + storage + .create_send_intent_if_absent(&first) + .await + .expect("store first"); + storage + .create_send_intent_if_absent(&second) + .await + .expect("store second"); + storage + .update_send_intent( + &second_id, + &SendIntentState::Batched { + batch_id: Uuid::new_v4(), + created_at: 1_700_000_000, + }, + ) + .await + .expect("advance second"); + + let claimed = storage + .claim_pending_send_intents_for_batch(&[first_id, second_id], batch_id) + .await + .expect("claim"); + + assert_eq!(claimed.len(), 1); + assert_eq!(claimed[0].intent_id, first_id); + assert!(matches!( + claimed[0].state, + SendIntentState::BatchClaimed { + batch_id: claimed_batch_id, + .. + } if claimed_batch_id == batch_id + )); + assert!(storage + .get_pending_send_intents() + .await + .expect("pending") + .is_empty()); + } + + #[tokio::test] + async fn test_cut_through_reservation_and_release_are_conditional() { + let storage = test_storage().await; + let intent_id = Uuid::new_v4(); + let mut intent = make_pending_intent(intent_id); + intent.quote_id = "cut-through-send".to_string(); + storage + .create_send_intent_if_absent(&intent) + .await + .expect("store intent"); + + let settlement = CutThroughSettlementRecord { + settlement_id: Uuid::new_v4(), + receive_quote_id: "receive-quote".to_string(), + send_intent_id: intent_id, + send_quote_id: intent.quote_id.clone(), + original_receive_amount_sat: 50_000, + melt_amount_sat: 40_000, + max_fee_sat: 1_000, + created_at: 1_700_000_000, + state: CutThroughSettlementState::Reserved, + }; + + let reserved = storage + .reserve_pending_send_intent_for_cut_through(&intent_id, &settlement) + .await + .expect("reserve") + .expect("should reserve"); + assert!(matches!( + reserved.state, + SendIntentState::CutThroughReserved { + settlement_id, + .. + } if settlement_id == settlement.settlement_id + )); + assert!(storage + .reserve_pending_send_intent_for_cut_through(&intent_id, &settlement) + .await + .expect("reserve again") + .is_none()); + + storage + .release_cut_through_reserved_intent(&intent_id, Uuid::new_v4()) + .await + .expect("wrong release"); + assert!(matches!( + storage + .get_send_intent(&intent_id) + .await + .expect("get") + .expect("intent") + .state, + SendIntentState::CutThroughReserved { .. } + )); + + storage + .release_cut_through_reserved_intent(&intent_id, settlement.settlement_id) + .await + .expect("release"); + assert!(matches!( + storage + .get_send_intent(&intent_id) + .await + .expect("get") + .expect("intent") + .state, + SendIntentState::Pending { .. } + )); + } + + #[tokio::test] + async fn test_delete_payjoin_receive_session() { + let storage = test_storage().await; + let quote_id = Uuid::new_v4().to_string(); + let record = PayjoinReceiveSessionRecord { + quote_id: quote_id.clone(), + fallback_address: "bcrt1qaddr".to_string(), + amount_sat: 1_000, + proposal_receiver_outpoints: Vec::new(), + expires_at: 1_700_000_000, + events: Vec::new(), + closed: true, + }; + + storage + .put_payjoin_receive_session(&record) + .await + .expect("store session"); + assert!(storage + .get_payjoin_receive_session("e_id) + .await + .expect("load session") + .is_some()); + + storage + .delete_payjoin_receive_session("e_id) + .await + .expect("delete session"); + + assert!(storage + .get_payjoin_receive_session("e_id) + .await + .expect("load deleted session") + .is_none()); + } + + #[tokio::test] + async fn test_mark_payjoin_inputs_seen_indexes_outpoints() { + let storage = test_storage().await; + let outpoints = vec![ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:0".to_string(), + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:1".to_string(), + ]; + let unrelated_outpoint = + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc:2".to_string(); + let before = crate::util::unix_now(); + + storage + .mark_payjoin_inputs_seen(&outpoints) + .await + .expect("mark inputs seen"); + let after = crate::util::unix_now(); + + assert!(storage + .is_payjoin_input_seen(&outpoints[0]) + .await + .expect("check seen input")); + assert!(storage + .is_payjoin_input_seen(&outpoints[1]) + .await + .expect("check seen input")); + assert!(!storage + .is_payjoin_input_seen(&unrelated_outpoint) + .await + .expect("check unrelated input")); + + let seen_at = storage + .get_payjoin_seen_input_seen_at(&outpoints[0]) + .await + .expect("load seen timestamp") + .expect("timestamp is stored"); + assert!( + (before..=after).contains(&seen_at), + "timestamp {seen_at} should be between {before} and {after}" + ); + assert_eq!( + storage + .get_payjoin_seen_input_seen_at(&unrelated_outpoint) + .await + .expect("load unrelated timestamp"), + None + ); + } + // ── Serialization round-trip tests ───────────────────────────── #[test] @@ -1470,6 +1826,7 @@ mod tests { address: "bcrt1qaddr".to_string(), txid: "abc123".to_string(), outpoint: "abc123:0".to_string(), + payment_id: Some("abc123:0".to_string()), amount_sat: 50_000, finalized_at: 1_700_000_001, }; @@ -1612,6 +1969,20 @@ mod tests { .await .expect("create first"); assert!(created1); + assert!( + storage + .has_receive_intent_for_outpoint("txid_abc:0") + .await + .expect("check active outpoint"), + "Active outpoint should be detected" + ); + assert!( + !storage + .has_receive_intent_for_outpoint("txid_abc:1") + .await + .expect("check missing outpoint"), + "Unknown outpoint should not be detected" + ); let created2 = storage .create_receive_intent_if_absent(&intent2) @@ -1657,6 +2028,7 @@ mod tests { address: "bcrt1qaddr".to_string(), txid: "txid_abc".to_string(), outpoint: "txid_abc:0".to_string(), + payment_id: Some("txid_abc:0".to_string()), amount_sat: 50_000, finalized_at: 1_700_000_001, }; @@ -1681,6 +2053,13 @@ mod tests { .expect("tombstone should exist"); assert_eq!(fetched_tombstone.intent_id, intent_id); assert_eq!(fetched_tombstone.amount_sat, 50_000); + assert!( + storage + .has_receive_intent_for_outpoint("txid_abc:0") + .await + .expect("check finalized outpoint"), + "Finalized outpoint should be detected" + ); // Outpoint should NOT be freed (cannot create a new intent with same outpoint) let intent2 = ReceiveIntentRecord { @@ -1760,6 +2139,7 @@ mod tests { address: "bcrt1qshared".to_string(), txid: format!("txid_{}", i), outpoint: outpoint.to_string(), + payment_id: Some(outpoint.to_string()), amount_sat: 10_000 * (i as u64 + 1), finalized_at: 1_700_000_010 + i as u64, }; @@ -1797,6 +2177,7 @@ mod tests { address: "bcrt1qother".to_string(), txid: "txid_c".to_string(), outpoint: "txid_c:0".to_string(), + payment_id: Some("txid_c:0".to_string()), amount_sat: 99_000, finalized_at: 1_700_000_200, }, @@ -1873,7 +2254,8 @@ mod tests { quote_id: quote_a, address: "bcrt1qshared".to_string(), txid: format!("txid_{}", i_a), - outpoint: outpoint_a, + outpoint: outpoint_a.clone(), + payment_id: Some(outpoint_a), amount_sat: 10_000 * (i_a as u64 + 1), finalized_at: 1_700_000_010 + i_a as u64, }; @@ -1889,7 +2271,8 @@ mod tests { quote_id: quote_b, address: "bcrt1qshared".to_string(), txid: format!("txid_{}", i_b), - outpoint: outpoint_b, + outpoint: outpoint_b.clone(), + payment_id: Some(outpoint_b), amount_sat: 10_000 * (i_b as u64 + 1), finalized_at: 1_700_000_010 + i_b as u64, }; diff --git a/crates/cdk-bdk/src/storage/receive.rs b/crates/cdk-bdk/src/storage/receive.rs index 5075839051..1cdee5d7f9 100644 --- a/crates/cdk-bdk/src/storage/receive.rs +++ b/crates/cdk-bdk/src/storage/receive.rs @@ -136,6 +136,41 @@ impl BdkStorage { Ok(true) } + /// Check whether an active or finalized receive intent already tracks an outpoint. + /// + /// This is a non-atomic preflight for scan paths. Callers that create + /// receive intents must still use `create_receive_intent_if_absent` for + /// race-safe duplicate protection. + pub async fn has_receive_intent_for_outpoint(&self, outpoint: &str) -> Result { + let outpoint_key = outpoint_to_key(outpoint); + + let active = self + .kv_store + .kv_read( + BDK_NAMESPACE, + RECEIVE_INTENT_OUTPOINT_NAMESPACE, + &outpoint_key, + ) + .await + .map_err(Error::from)?; + + if active.is_some() { + return Ok(true); + } + + let finalized = self + .kv_store + .kv_read( + BDK_NAMESPACE, + FINALIZED_RECEIVE_INTENT_OUTPOINT_NAMESPACE, + &outpoint_key, + ) + .await + .map_err(Error::from)?; + + Ok(finalized.is_some()) + } + /// Get a receive intent by ID. pub async fn get_receive_intent( &self, diff --git a/crates/cdk-bdk/src/storage/send.rs b/crates/cdk-bdk/src/storage/send.rs index dc43829e6e..6c351c5a4a 100644 --- a/crates/cdk-bdk/src/storage/send.rs +++ b/crates/cdk-bdk/src/storage/send.rs @@ -3,8 +3,11 @@ use std::str::FromStr; use uuid::Uuid; use super::{ - BdkStorage, FailedSendAttemptRecord, FinalizedSendIntentRecord, BDK_NAMESPACE, - FINALIZED_INTENT_NAMESPACE, FINALIZED_SEND_INTENT_QUOTE_ID_NAMESPACE, SEND_INTENT_NAMESPACE, + BdkStorage, CutThroughSettlementRecord, FailedSendAttemptRecord, FinalizedReceiveIntentRecord, + FinalizedSendIntentRecord, BDK_NAMESPACE, CUT_THROUGH_SETTLEMENT_NAMESPACE, + FINALIZED_INTENT_NAMESPACE, FINALIZED_RECEIVE_INTENT_BY_QUOTE_NAMESPACE_PREFIX, + FINALIZED_RECEIVE_INTENT_NAMESPACE, FINALIZED_RECEIVE_INTENT_OUTPOINT_NAMESPACE, + FINALIZED_SEND_INTENT_QUOTE_ID_NAMESPACE, SEND_INTENT_NAMESPACE, SEND_INTENT_QUOTE_ID_NAMESPACE, }; use crate::error::Error; @@ -224,6 +227,142 @@ impl BdkStorage { .collect()) } + /// Atomically claim still-pending send intents for a normal batch. + /// + /// Intents that are no longer pending are skipped. The returned records are + /// the claimed records with `BatchClaimed` state. + pub async fn claim_pending_send_intents_for_batch( + &self, + intent_ids: &[Uuid], + batch_id: Uuid, + ) -> Result, Error> { + let mut tx = self + .kv_store + .begin_transaction() + .await + .map_err(Error::from)?; + let mut claimed = Vec::new(); + + for intent_id in intent_ids { + let key = intent_id.to_string(); + let Some(bytes) = tx + .kv_read(BDK_NAMESPACE, SEND_INTENT_NAMESPACE, &key) + .await + .map_err(Error::from)? + else { + continue; + }; + let mut record: SendIntentRecord = serde_json::from_slice(&bytes)?; + let SendIntentState::Pending { created_at } = record.state else { + continue; + }; + + record.state = SendIntentState::BatchClaimed { + batch_id, + created_at, + }; + let serialized = serde_json::to_vec(&record)?; + tx.kv_write(BDK_NAMESPACE, SEND_INTENT_NAMESPACE, &key, &serialized) + .await + .map_err(Error::from)?; + claimed.push(record); + } + + tx.commit().await.map_err(Error::from)?; + Ok(claimed) + } + + /// Conditionally reserve a pending send intent for a cut-through settlement. + pub async fn reserve_pending_send_intent_for_cut_through( + &self, + intent_id: &Uuid, + settlement: &CutThroughSettlementRecord, + ) -> Result, Error> { + let mut tx = self + .kv_store + .begin_transaction() + .await + .map_err(Error::from)?; + + let key = intent_id.to_string(); + let Some(bytes) = tx + .kv_read(BDK_NAMESPACE, SEND_INTENT_NAMESPACE, &key) + .await + .map_err(Error::from)? + else { + tx.rollback().await.map_err(Error::from)?; + return Ok(None); + }; + let mut record: SendIntentRecord = serde_json::from_slice(&bytes)?; + let SendIntentState::Pending { created_at } = record.state else { + tx.rollback().await.map_err(Error::from)?; + return Ok(None); + }; + + record.state = SendIntentState::CutThroughReserved { + settlement_id: settlement.settlement_id, + created_at, + }; + let intent_bytes = serde_json::to_vec(&record)?; + let settlement_bytes = serde_json::to_vec(settlement)?; + tx.kv_write(BDK_NAMESPACE, SEND_INTENT_NAMESPACE, &key, &intent_bytes) + .await + .map_err(Error::from)?; + tx.kv_write( + BDK_NAMESPACE, + CUT_THROUGH_SETTLEMENT_NAMESPACE, + &settlement.settlement_id.to_string(), + &settlement_bytes, + ) + .await + .map_err(Error::from)?; + tx.commit().await.map_err(Error::from)?; + Ok(Some(record)) + } + + /// Release a cut-through-reserved intent back to pending. + pub async fn release_cut_through_reserved_intent( + &self, + intent_id: &Uuid, + settlement_id: Uuid, + ) -> Result<(), Error> { + let mut tx = self + .kv_store + .begin_transaction() + .await + .map_err(Error::from)?; + let key = intent_id.to_string(); + let Some(bytes) = tx + .kv_read(BDK_NAMESPACE, SEND_INTENT_NAMESPACE, &key) + .await + .map_err(Error::from)? + else { + tx.rollback().await.map_err(Error::from)?; + return Ok(()); + }; + let mut record: SendIntentRecord = serde_json::from_slice(&bytes)?; + let SendIntentState::CutThroughReserved { + settlement_id: current, + created_at, + } = record.state + else { + tx.rollback().await.map_err(Error::from)?; + return Ok(()); + }; + if current != settlement_id { + tx.rollback().await.map_err(Error::from)?; + return Ok(()); + } + + record.state = SendIntentState::Pending { created_at }; + let serialized = serde_json::to_vec(&record)?; + tx.kv_write(BDK_NAMESPACE, SEND_INTENT_NAMESPACE, &key, &serialized) + .await + .map_err(Error::from)?; + tx.commit().await.map_err(Error::from)?; + Ok(()) + } + /// Store a failed pre-sign send attempt tombstone. pub async fn add_failed_send_attempt( &self, @@ -390,4 +529,126 @@ impl BdkStorage { tx.commit().await.map_err(Error::from)?; Ok(()) } + + /// Store or replace a cut-through settlement. + pub async fn put_cut_through_settlement( + &self, + record: &CutThroughSettlementRecord, + ) -> Result<(), Error> { + self.put_record(record).await + } + + /// Load a cut-through settlement by id. + pub async fn get_cut_through_settlement( + &self, + settlement_id: &Uuid, + ) -> Result, Error> { + self.get_record::(&settlement_id.to_string()) + .await + } + + /// List all cut-through settlements. + pub async fn get_all_cut_through_settlements( + &self, + ) -> Result, Error> { + self.list_records::().await + } + + /// Delete a cut-through settlement. + pub async fn delete_cut_through_settlement(&self, settlement_id: &Uuid) -> Result<(), Error> { + self.delete_record::(&settlement_id.to_string()) + .await + } + + /// Atomically finalize the receive tombstone and send tombstone for a + /// confirmed cut-through proposal, then update the settlement record. + pub async fn finalize_cut_through_pair( + &self, + receive_record: &FinalizedReceiveIntentRecord, + send_record: &FinalizedSendIntentRecord, + settlement: &CutThroughSettlementRecord, + ) -> Result<(), Error> { + let Some(intent) = self.get_send_intent(&send_record.intent_id).await? else { + return Err(Error::SendIntentNotFound(send_record.intent_id)); + }; + + let serialized_receive = serde_json::to_vec(receive_record)?; + let serialized_send = serde_json::to_vec(send_record)?; + let serialized_settlement = serde_json::to_vec(settlement)?; + let mut tx = self + .kv_store + .begin_transaction() + .await + .map_err(Error::from)?; + + tx.kv_write( + BDK_NAMESPACE, + FINALIZED_RECEIVE_INTENT_NAMESPACE, + &receive_record.intent_id.to_string(), + &serialized_receive, + ) + .await + .map_err(Error::from)?; + tx.kv_write( + BDK_NAMESPACE, + FINALIZED_RECEIVE_INTENT_OUTPOINT_NAMESPACE, + &super::outpoint_to_key(&receive_record.outpoint), + receive_record.intent_id.to_string().as_bytes(), + ) + .await + .map_err(Error::from)?; + let quote_ns = format!( + "{FINALIZED_RECEIVE_INTENT_BY_QUOTE_NAMESPACE_PREFIX}__{}", + receive_record.quote_id + ); + tx.kv_write( + BDK_NAMESPACE, + "e_ns, + &receive_record.intent_id.to_string(), + receive_record.intent_id.to_string().as_bytes(), + ) + .await + .map_err(Error::from)?; + + tx.kv_write( + BDK_NAMESPACE, + FINALIZED_INTENT_NAMESPACE, + &send_record.intent_id.to_string(), + &serialized_send, + ) + .await + .map_err(Error::from)?; + tx.kv_write( + BDK_NAMESPACE, + FINALIZED_SEND_INTENT_QUOTE_ID_NAMESPACE, + &intent.quote_id, + send_record.intent_id.to_string().as_bytes(), + ) + .await + .map_err(Error::from)?; + tx.kv_remove( + BDK_NAMESPACE, + SEND_INTENT_NAMESPACE, + &send_record.intent_id.to_string(), + ) + .await + .map_err(Error::from)?; + tx.kv_remove( + BDK_NAMESPACE, + SEND_INTENT_QUOTE_ID_NAMESPACE, + &intent.quote_id, + ) + .await + .map_err(Error::from)?; + tx.kv_write( + BDK_NAMESPACE, + CUT_THROUGH_SETTLEMENT_NAMESPACE, + &settlement.settlement_id.to_string(), + &serialized_settlement, + ) + .await + .map_err(Error::from)?; + tx.commit().await.map_err(Error::from)?; + Ok(()) + } } diff --git a/crates/cdk-bdk/src/storage/types.rs b/crates/cdk-bdk/src/storage/types.rs index eb551086b8..8917573806 100644 --- a/crates/cdk-bdk/src/storage/types.rs +++ b/crates/cdk-bdk/src/storage/types.rs @@ -50,8 +50,157 @@ pub struct FinalizedReceiveIntentRecord { pub txid: String, /// Output point string (txid:vout) pub outpoint: String, + /// Stable payment identifier returned to the mint. + #[serde(default)] + pub payment_id: Option, /// Payment amount in satoshis pub amount_sat: u64, /// When finalization occurred (unix timestamp seconds) pub finalized_at: u64, } + +/// Durable state for a Payjoin cut-through settlement. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub enum CutThroughSettlementState { + /// A compatible melt was reserved, but no proposal is known to be exposed. + Reserved, + /// A cut-through proposal may have reached the sender. + ProposalExposed { + /// Consensus-serialized proposal transaction. + proposal_tx_bytes: Vec, + /// Proposal transaction id. + proposal_txid: String, + /// Consensus-serialized original sender transaction. + original_tx_bytes: Vec, + /// Original transaction id. + original_txid: String, + /// Outpoint used as the receive payment id. + receive_payment_id: String, + /// Legacy receive outpoint field. + receive_outpoint: String, + /// Paid melt output point. + melt_outpoint: String, + /// Mint incremental spend beyond melt principal. + fee_contribution_sat: u64, + }, + /// Proposal confirmed and both receive and melt were finalized. + Confirmed { + /// When finalization occurred (unix timestamp seconds) + finalized_at: u64, + }, + /// Settlement was abandoned and the melt was released or finalized by another path. + Abandoned { + /// When abandonment occurred (unix timestamp seconds) + abandoned_at: u64, + }, +} + +/// Persisted Payjoin cut-through settlement record. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct CutThroughSettlementRecord { + /// Unique settlement identifier. + pub settlement_id: Uuid, + /// Incoming mint quote id. + pub receive_quote_id: String, + /// Reserved outgoing send intent id. + pub send_intent_id: Uuid, + /// Outgoing melt quote id. + pub send_quote_id: String, + /// Original Payjoin receiver amount. + pub original_receive_amount_sat: u64, + /// Melt principal amount. + pub melt_amount_sat: u64, + /// Maximum fee accepted by the melt quote. + pub max_fee_sat: u64, + /// When the settlement was created (unix timestamp seconds). + pub created_at: u64, + /// Current settlement state. + pub state: CutThroughSettlementState, +} + +/// Persisted Payjoin v2 receive session. +#[derive(Debug, Clone, serde::Serialize)] +pub struct PayjoinReceiveSessionRecord { + /// Quote ID linking this session to an onchain mint quote. + pub quote_id: String, + /// Fallback address tracked by the normal receive flow. + pub fallback_address: String, + /// Expected receive amount in satoshis. + pub amount_sat: u64, + /// Receiver outpoints from the finalized Payjoin proposal transaction. + #[serde(default)] + pub proposal_receiver_outpoints: Vec, + /// Session expiry timestamp in unix seconds. + pub expires_at: u64, + /// Append-only Payjoin event history. + pub events: Vec, + /// Whether the session reached a terminal state. + pub closed: bool, +} + +impl<'de> serde::Deserialize<'de> for PayjoinReceiveSessionRecord { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + #[derive(serde::Deserialize)] + struct RawPayjoinReceiveSessionRecord { + quote_id: String, + fallback_address: String, + #[serde(default)] + amount_sat: u64, + #[serde(default)] + proposal_receiver_outpoints: Vec, + expires_at: u64, + #[serde(default)] + events: Vec, + #[serde(default)] + closed: bool, + } + + let raw = RawPayjoinReceiveSessionRecord::deserialize(deserializer)?; + let mut malformed_events = false; + let mut events = Vec::with_capacity(raw.events.len()); + + for event in raw.events { + match serde_json::from_value(event) { + Ok(event) => events.push(event), + Err(_) => malformed_events = true, + } + } + + Ok(Self { + quote_id: raw.quote_id, + fallback_address: raw.fallback_address, + amount_sat: raw.amount_sat, + proposal_receiver_outpoints: raw.proposal_receiver_outpoints, + expires_at: raw.expires_at, + events, + closed: raw.closed || malformed_events, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn payjoin_receive_session_keeps_empty_event_log_open() { + let value = serde_json::json!({ + "quote_id": "b50e819c-c136-4af9-9123-9c1e8c1dd9d2", + "fallback_address": "bcrt1qaddr", + "amount_sat": 0, + "expires_at": 1_780_848_540_u64, + "events": [], + "closed": false + }); + + let record: PayjoinReceiveSessionRecord = + serde_json::from_value(value).expect("record should deserialize"); + + assert!(record.events.is_empty()); + assert!(record.proposal_receiver_outpoints.is_empty()); + assert!(!record.closed); + } +} diff --git a/crates/cdk-bdk/src/types.rs b/crates/cdk-bdk/src/types.rs index 9fe4f1bdb7..1557adebb6 100644 --- a/crates/cdk-bdk/src/types.rs +++ b/crates/cdk-bdk/src/types.rs @@ -5,6 +5,63 @@ use serde::{Deserialize, Serialize}; /// Default average Bitcoin block interval used for delayed batch deadlines. pub const DEFAULT_TARGET_BLOCK_TIME_SECS: u64 = 600; +/// Default Payjoin v2 session expiry in seconds. +pub const DEFAULT_PAYJOIN_EXPIRY_SECS: u64 = 86_400; + +/// Payjoin v2 directory/OHTTP configuration. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PayjoinConfig { + /// Payjoin directory URL. + pub directory_url: String, + /// OHTTP relay URL. + pub ohttp_relay_url: String, + /// Receiver session expiry in seconds. + pub expiry_secs: u64, + /// DER-encoded localhost TLS certificate for regtest-only Payjoin services. + #[cfg(feature = "payjoin-local-https")] + pub local_tls_cert_der: Option>, +} + +impl PayjoinConfig { + /// Create and validate a Payjoin config. + pub fn new( + directory_url: String, + ohttp_relay_url: String, + expiry_secs: Option, + ) -> Result { + let expiry_secs = expiry_secs.unwrap_or(DEFAULT_PAYJOIN_EXPIRY_SECS); + if expiry_secs == 0 { + return Err("payjoin_expiry_secs must be greater than zero".to_string()); + } + + validate_http_url("payjoin_directory_url", &directory_url)?; + validate_http_url("payjoin_ohttp_relay_url", &ohttp_relay_url)?; + + Ok(Self { + directory_url, + ohttp_relay_url, + expiry_secs, + #[cfg(feature = "payjoin-local-https")] + local_tls_cert_der: None, + }) + } + + /// Configure a DER-encoded localhost TLS certificate for regtest-only Payjoin services. + #[cfg(feature = "payjoin-local-https")] + pub fn with_local_tls_cert_der(mut self, cert_der: Vec) -> Self { + self.local_tls_cert_der = Some(cert_der); + self + } +} + +fn validate_http_url(field: &str, value: &str) -> Result<(), String> { + let url = url::Url::parse(value).map_err(|err| format!("{field} is not a valid URL: {err}"))?; + match url.scheme() { + "http" | "https" => Ok(()), + scheme => Err(format!("{field} must use http or https, got {scheme}")), + } +} + /// Configuration for BDK fee estimation. /// /// Fee rates are cached per payment tier. Melt quote fees use a conservative @@ -263,3 +320,44 @@ impl PaymentMetadata { Self::default() } } + +#[cfg(test)] +mod payjoin_tests { + use super::*; + + #[test] + fn payjoin_config_requires_positive_expiry() { + let err = PayjoinConfig::new( + "https://directory.example".to_string(), + "https://relay.example".to_string(), + Some(0), + ) + .expect_err("zero expiry should fail"); + + assert!(err.contains("payjoin_expiry_secs")); + } + + #[test] + fn payjoin_config_rejects_non_http_urls() { + let err = PayjoinConfig::new( + "ftp://directory.example".to_string(), + "https://relay.example".to_string(), + Some(DEFAULT_PAYJOIN_EXPIRY_SECS), + ) + .expect_err("non-http directory URL should fail"); + + assert!(err.contains("payjoin_directory_url")); + } + + #[test] + fn payjoin_config_defaults_expiry() { + let config = PayjoinConfig::new( + "https://directory.example".to_string(), + "https://relay.example".to_string(), + None, + ) + .expect("valid config"); + + assert_eq!(config.expiry_secs, DEFAULT_PAYJOIN_EXPIRY_SECS); + } +} diff --git a/crates/cdk-bdk/src/util.rs b/crates/cdk-bdk/src/util.rs index be9980a47f..4e4ddca28b 100644 --- a/crates/cdk-bdk/src/util.rs +++ b/crates/cdk-bdk/src/util.rs @@ -1,8 +1,27 @@ +use std::str::FromStr; use std::time::{SystemTime, UNIX_EPOCH}; +use bdk_wallet::bitcoin::{Address, Network}; + +use crate::error::Error; + pub(crate) fn unix_now() -> u64 { SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_secs() } + +pub(crate) fn parse_checked_address( + address: &str, + network: Network, + map_error: F, +) -> Result +where + F: Fn(String) -> Error, +{ + Address::from_str(address) + .map_err(|err| map_error(err.to_string()))? + .require_network(network) + .map_err(|err| map_error(err.to_string())) +} diff --git a/crates/cdk-cli/src/sub_commands/melt.rs b/crates/cdk-cli/src/sub_commands/melt.rs index 96a5666c09..77a1c32cd7 100644 --- a/crates/cdk-cli/src/sub_commands/melt.rs +++ b/crates/cdk-cli/src/sub_commands/melt.rs @@ -5,9 +5,10 @@ use anyhow::{bail, Result}; use cdk::amount::{amount_for_offer, Amount, MSAT_IN_SAT}; use cdk::mint_url::MintUrl; use cdk::nuts::nut00::KnownMethod; -use cdk::nuts::{CurrencyUnit, MeltOptions, PaymentMethod}; +use cdk::nuts::{CurrencyUnit, MeltOptions, PayjoinV2, PaymentMethod}; use cdk::wallet::WalletRepository; use cdk::Bolt11Invoice; +use cdk_common::payjoin::{parse_bip21_amount_to_sats, payjoin_v2_from_bip77_endpoint}; use cdk_common::wallet::WalletKey; use clap::{Args, ValueEnum}; use lightning::offers::offer::Offer; @@ -66,7 +67,8 @@ pub struct MeltSubCommand { /// BOLT12 offer to pay (for bolt12 method) #[arg(long, conflicts_with_all = ["invoice", "address"])] offer: Option, - /// BIP353 or onchain address to pay + /// BIP353 address for --method bip353, or Bitcoin address/full bitcoin: URI for + /// --method onchain #[arg(long, conflicts_with_all = ["invoice", "offer"])] address: Option, /// Bitcoin network to use for BIP353 (bitcoin, testnet, signet, regtest) @@ -78,6 +80,19 @@ pub struct MeltSubCommand { /// MPP split entry in the form =; repeat for multiple mints #[arg(long = "mpp-split", value_name = "MINT_URL=AMOUNT", action = clap::ArgAction::Append, requires = "mpp")] mpp_split: Vec, + /// Destination Payjoin instructions as NUT-31 JSON or a bitcoin: Payjoin URI, for onchain melts + #[arg(long, value_name = "JSON_OR_URI")] + payjoin: Option, +} + +fn validate_args(sub_command_args: &MeltSubCommand) -> Result<()> { + if sub_command_args.payjoin.is_some() + && !matches!(sub_command_args.method, PaymentType::Onchain) + { + bail!("--payjoin can only be used with --method onchain"); + } + + Ok(()) } /// Helper function to check if there are enough funds and create appropriate MeltOptions @@ -118,6 +133,116 @@ fn input_or_prompt(arg: Option<&String>, prompt: &str) -> Result { } } +#[derive(Debug, Clone, Default)] +struct OnchainPaymentInput { + address: Option, + amount_sat: Option, + payjoin: Option, +} + +impl OnchainPaymentInput { + fn merge(self, other: Self) -> Result { + Ok(Self { + address: merge_optional("onchain address", self.address, other.address)?, + amount_sat: merge_optional("onchain amount", self.amount_sat, other.amount_sat)?, + payjoin: merge_optional("payjoin", self.payjoin, other.payjoin)?, + }) + } +} + +fn merge_optional(field: &str, left: Option, right: Option) -> Result> +where + T: PartialEq + std::fmt::Debug, +{ + match (left, right) { + (Some(left), Some(right)) if left != right => { + bail!("Conflicting {field} values: {left:?} and {right:?}") + } + (Some(value), _) | (_, Some(value)) => Ok(Some(value)), + (None, None) => Ok(None), + } +} + +fn parse_onchain_input_arg(value: &str) -> Result { + if value.to_ascii_lowercase().starts_with("bitcoin:") { + parse_bitcoin_payjoin_uri(value) + } else { + Ok(OnchainPaymentInput { + address: Some(value.to_string()), + ..Default::default() + }) + } +} + +fn parse_payjoin_arg(value: &str) -> Result { + if value.to_ascii_lowercase().starts_with("bitcoin:") { + parse_bitcoin_payjoin_uri(value) + } else { + Ok(OnchainPaymentInput { + payjoin: Some(serde_json::from_str::(value)?), + ..Default::default() + }) + } +} + +fn parse_bitcoin_payjoin_uri(value: &str) -> Result { + let uri = url::Url::parse(value)?; + if uri.scheme() != "bitcoin" { + bail!("Expected a bitcoin: URI"); + } + + let address = normalize_onchain_address(uri.path()); + if address.is_empty() { + bail!("bitcoin: URI is missing an onchain address"); + } + + let mut amount_sat = None; + let mut endpoint = None; + + for (key, value) in uri.query_pairs() { + match key.as_ref() { + "amount" => amount_sat = Some(parse_bip21_amount_sat(&value)?), + "pj" => endpoint = Some(value.into_owned()), + "pjos" if !matches!(value.as_ref(), "0" | "1") => { + bail!("Invalid pjos value '{}', expected 0 or 1", value); + } + "pjos" => {} + _ => {} + } + } + + let payjoin = match endpoint { + Some(endpoint) => Some(onchain_payjoin_from_endpoint(endpoint)?), + None => None, + }; + + Ok(OnchainPaymentInput { + address: Some(address), + amount_sat, + payjoin, + }) +} + +fn normalize_onchain_address(address: &str) -> String { + let lowercase = address.to_ascii_lowercase(); + if lowercase.starts_with("bc1") + || lowercase.starts_with("tb1") + || lowercase.starts_with("bcrt1") + { + lowercase + } else { + address.to_string() + } +} + +fn parse_bip21_amount_sat(amount: &str) -> Result { + parse_bip21_amount_to_sats(amount).map_err(Into::into) +} + +fn onchain_payjoin_from_endpoint(endpoint: String) -> Result { + payjoin_v2_from_bip77_endpoint(&endpoint).map_err(Into::into) +} + fn parse_mpp_split(entry: &str) -> Result<(MintUrl, Amount)> { let (mint, amount) = entry.split_once('=').ok_or_else(|| { anyhow::anyhow!("Invalid --mpp-split value '{entry}'. Expected MINT_URL=AMOUNT") @@ -175,6 +300,8 @@ pub async fn pay( sub_command_args: &MeltSubCommand, unit: &CurrencyUnit, ) -> Result<()> { + validate_args(sub_command_args)?; + // Check total balance for the requested unit let balances_by_unit = wallet_repository.total_balance().await?; let total_balance = balances_by_unit.get(unit).copied().unwrap_or(Amount::ZERO); @@ -439,10 +566,26 @@ pub async fn pay( } } PaymentType::Onchain => { - let onchain_address = - input_or_prompt(sub_command_args.address.as_ref(), "Enter onchain address")?; + let mut onchain_input = sub_command_args + .address + .as_deref() + .map(parse_onchain_input_arg) + .transpose()? + .unwrap_or_default(); + if let Some(payjoin) = sub_command_args.payjoin.as_deref() { + onchain_input = onchain_input.merge(parse_payjoin_arg(payjoin)?)?; + } + + let onchain_address = match onchain_input.address { + Some(address) => address, + None => get_user_input("Enter onchain address")?, + }; - let amount_sat = match sub_command_args.amount { + let amount_sat = match merge_optional( + "onchain amount", + onchain_input.amount_sat, + sub_command_args.amount, + )? { Some(amount_sat) if amount_sat > 0 => amount_sat, Some(_) => bail!("Onchain melt amount must be greater than zero"), None => get_number_input::("Enter the amount you would like to melt in sats")?, @@ -468,7 +611,12 @@ pub async fn pay( let wallet = get_or_create_wallet(wallet_repository, &mint_url, unit).await?; let quote_options = wallet - .quote_onchain_melt_options(&onchain_address, melt_amount, None) + .quote_onchain_melt_options_with_payjoin( + &onchain_address, + melt_amount, + None, + onchain_input.payjoin, + ) .await?; let selected_quote = select_onchain_quote("e_options)?; @@ -482,6 +630,9 @@ pub async fn pay( if let Some(estimated_blocks) = quote.estimated_blocks { println!(" Estimated Blocks: {}", estimated_blocks); } + if let Some(payjoin) = quote.payjoin.as_ref() { + println!(" Payjoin: {}", serde_json::to_string(payjoin)?); + } let melted = wallet .prepare_melt("e.id, HashMap::new()) @@ -642,3 +793,91 @@ async fn pay_mpp( Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + const PAYJOIN_URI: &str = "bitcoin:tb1qhe0dkl8tfkrp9nnufq0v0nl46yramxmzwvs83r?amount=0.00004&pjos=0&pj=HTTPS://PAYJO.IN/E73HSW759WNES%23EX12XHZ26S-OH1QYPFLM8XL59R0XV4VGPLS7FRDSSM4TUXL07TXCWC4S0GLVLNK2SE4NQ-RK1QV6WSX0UQPAEA0RH54430D0UVZWS8CZ6FEGZF4RGFCDKJLPGMYEJG"; + + fn melt_sub_command(method: PaymentType, payjoin: Option) -> MeltSubCommand { + MeltSubCommand { + mpp: false, + mint_url: None, + method, + invoice: None, + offer: None, + address: None, + network: BitcoinNetwork::Bitcoin, + amount: None, + mpp_split: Vec::new(), + payjoin, + } + } + + #[test] + fn payjoin_requires_onchain_method() { + for method in [ + PaymentType::Bolt11, + PaymentType::Bolt12, + PaymentType::Bip353, + ] { + let args = melt_sub_command(method, Some("{}".to_string())); + let err = validate_args(&args).expect_err("payjoin should require onchain method"); + + assert_eq!( + err.to_string(), + "--payjoin can only be used with --method onchain" + ); + } + } + + #[test] + fn onchain_method_allows_payjoin() { + let args = melt_sub_command(PaymentType::Onchain, Some("{}".to_string())); + + validate_args(&args).expect("onchain payjoin should pass validation"); + } + + #[test] + fn default_method_rejects_payjoin() { + let args = melt_sub_command(PaymentType::Bolt11, Some("{}".to_string())); + let err = validate_args(&args).expect_err("default method should reject payjoin"); + + assert_eq!( + err.to_string(), + "--payjoin can only be used with --method onchain" + ); + } + + #[test] + fn parses_bitcoin_payjoin_uri_for_onchain_melt() { + let input = parse_bitcoin_payjoin_uri(PAYJOIN_URI).expect("parse payjoin uri"); + let payjoin = input.payjoin.expect("payjoin params"); + + assert_eq!( + input.address.as_deref(), + Some("tb1qhe0dkl8tfkrp9nnufq0v0nl46yramxmzwvs83r") + ); + assert_eq!(input.amount_sat, Some(4_000)); + assert_eq!(payjoin.endpoint, "https://payjo.in/E73HSW759WNES"); + assert_eq!( + payjoin.ohttp_keys.to_string(), + "QYPFLM8XL59R0XV4VGPLS7FRDSSM4TUXL07TXCWC4S0GLVLNK2SE4NQ" + ); + assert_eq!( + payjoin.receiver_key.to_string(), + "QV6WSX0UQPAEA0RH54430D0UVZWS8CZ6FEGZF4RGFCDKJLPGMYEJG" + ); + assert_eq!(payjoin.expires_at, 1_780_854_353); + } + + #[test] + fn onchain_uri_amount_conflicts_are_rejected() { + let input = parse_bitcoin_payjoin_uri(PAYJOIN_URI).expect("parse payjoin uri"); + let err = merge_optional("onchain amount", input.amount_sat, Some(5_000)) + .expect_err("conflicting amount should fail"); + + assert!(err.to_string().contains("Conflicting onchain amount")); + } +} diff --git a/crates/cdk-cli/src/sub_commands/mint.rs b/crates/cdk-cli/src/sub_commands/mint.rs index 3808604405..162ff6dd6c 100644 --- a/crates/cdk-cli/src/sub_commands/mint.rs +++ b/crates/cdk-cli/src/sub_commands/mint.rs @@ -10,6 +10,7 @@ use cdk::nuts::{CurrencyUnit, PaymentMethod}; use cdk::wallet::{Wallet, WalletRepository, WalletSubscription}; use cdk::{Amount, StreamExt}; use cdk_common::nut00::KnownMethod; +use cdk_common::payjoin::{format_bip21_amount_from_sats, payjoin_v2_to_bip77_endpoint}; use cdk_common::NotificationPayload; use clap::Args; use serde::{Deserialize, Serialize}; @@ -120,6 +121,13 @@ pub async fn mint( println!("Quote: id={}, expiry={}", quote.id, quote.expiry); println!("Send sats to: {}", quote.request); + if let Some(payjoin) = quote.payjoin.as_ref() { + let endpoint = payjoin_v2_to_bip77_endpoint(payjoin)?; + println!( + "Payjoin: {}", + build_payjoin_payment_uri("e.request, amount, &endpoint) + ); + } quote } @@ -229,6 +237,37 @@ pub async fn mint( Ok(()) } +fn build_payjoin_payment_uri( + address: &str, + amount_sat: Option, + bip77_endpoint: &str, +) -> String { + let mut serializer = url::form_urlencoded::Serializer::new(String::new()); + if let Some(amount_sat) = amount_sat { + serializer.append_pair("amount", &format_bip21_amount_from_sats(amount_sat)); + } + serializer.append_pair("pjos", "0"); + serializer.append_pair("pj", bip77_endpoint); + + format!( + "BITCOIN:{}?{}", + uppercase_qr_address(address), + serializer.finish() + ) +} + +fn uppercase_qr_address(address: &str) -> String { + let lowercase = address.to_ascii_lowercase(); + if lowercase.starts_with("bc1") + || lowercase.starts_with("tb1") + || lowercase.starts_with("bcrt1") + { + return address.to_ascii_uppercase(); + } + + address.to_string() +} + /// Spawns a background task that prints human-readable progress updates for /// the given mint quote. Returns `None` if the subscription could not be /// created (e.g. for unsupported payment methods); in that case the main flow @@ -322,3 +361,62 @@ async fn spawn_progress_task( } })) } + +#[cfg(test)] +mod tests { + use cdk::nuts::nut31::PayjoinV2; + use cdk_common::payjoin::payjoin_v2_from_bip77_endpoint; + + use super::*; + + #[test] + fn payjoin_payment_uri_includes_bip77_pj_endpoint() { + let payjoin = PayjoinV2::new( + "https://payjo.in/ZGSLFXFUN7K72".to_string(), + "QYPFLM8XL59R0XV4VGPLS7FRDSSM4TUXL07TXCWC4S0GLVLNK2SE4NQ", + "Q2JJNCP7QRUVGUM64VHNMWFHLHH9NNF0NC29HUJKCDH3WNLNZCSEZ", + 1_720_547_781, + ) + .expect("valid Payjoin keys"); + let endpoint = payjoin_v2_to_bip77_endpoint(&payjoin).expect("valid Payjoin v2 endpoint"); + let parsed = payjoin_v2_from_bip77_endpoint(&endpoint).expect("valid BIP77 endpoint"); + assert_eq!(parsed, payjoin); + + let uri = build_payjoin_payment_uri( + "bcrt1q6d3a2w975yny0asuvd9a67ner4nks58ff0q8g4", + Some(50_000), + &endpoint, + ); + + assert!(uri.starts_with("BITCOIN:BCRT1Q6D3A2W975YNY0ASUVD9A67NER4NKS58FF0Q8G4?")); + let query = uri + .split_once('?') + .map(|(_, query)| query) + .expect("Payjoin payment URI must include query params"); + let params: std::collections::HashMap<_, _> = url::form_urlencoded::parse(query.as_bytes()) + .map(|(key, value)| (key.into_owned(), value.into_owned())) + .collect(); + + assert_eq!(params.get("amount").map(String::as_str), Some("0.0005")); + assert_eq!(params.get("pjos").map(String::as_str), Some("0")); + let pj = params.get("pj").expect("pj param must be present"); + assert_eq!(pj, &endpoint); + assert!(pj.contains("EX1C4UC6ES")); + assert!(pj.contains("OH1QYPFLM8XL59R0XV4VGPLS7FRDSSM4TUXL07TXCWC4S0GLVLNK2SE4NQ")); + assert!(pj.contains("RK1Q2JJNCP7QRUVGUM64VHNMWFHLHH9NNF0NC29HUJKCDH3WNLNZCSEZ")); + } + + #[test] + fn payjoin_payment_uri_allows_amountless_quote() { + let uri = build_payjoin_payment_uri( + "12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX", + None, + "HTTPS://EXAMPLE.COM/#OH1QYP", + ); + + assert_eq!( + uri, + "BITCOIN:12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX?pjos=0&pj=HTTPS%3A%2F%2FEXAMPLE.COM%2F%23OH1QYP" + ); + } +} diff --git a/crates/cdk-common/src/database/wallet/test/mod.rs b/crates/cdk-common/src/database/wallet/test/mod.rs index e03ee84358..a6e3c2a998 100644 --- a/crates/cdk-common/src/database/wallet/test/mod.rs +++ b/crates/cdk-common/src/database/wallet/test/mod.rs @@ -17,7 +17,7 @@ use web_time::{SystemTime, UNIX_EPOCH}; use super::*; use crate::mint_url::MintUrl; -use crate::nuts::{Id, KeySetInfo, Keys, MintInfo, Proof, State}; +use crate::nuts::{Id, KeySetInfo, Keys, MintInfo, PayjoinV2, Proof, State}; use crate::wallet::{ MeltQuote, MintQuote, OperationData, ProofInfo, SwapOperationData, SwapSagaState, Transaction, TransactionDirection, WalletSaga, WalletSagaState, @@ -25,6 +25,9 @@ use crate::wallet::{ static COUNTER: AtomicU64 = AtomicU64::new(0); +const PAYJOIN_OHTTP_KEYS: &str = "QYPFLM8XL59R0XV4VGPLS7FRDSSM4TUXL07TXCWC4S0GLVLNK2SE4NQ"; +const PAYJOIN_RECEIVER_KEY: &str = "QV6WSX0UQPAEA0RH54430D0UVZWS8CZ6FEGZF4RGFCDKJLPGMYEJG"; + /// Generate a unique test ID fn unique_id() -> String { let now = SystemTime::now() @@ -134,6 +137,7 @@ fn test_melt_quote() -> MeltQuote { payment_proof: None, estimated_blocks: None, fee_index: None, + payjoin: None, payment_method: cashu::PaymentMethod::Known(KnownMethod::Bolt11), used_by_operation: None, version: 0, @@ -410,6 +414,45 @@ where assert!(!quotes.is_empty()); } +/// Test adding and retrieving mint quotes with Payjoin metadata +pub async fn add_and_get_mint_quote_payjoin(db: DB) +where + DB: Database, +{ + let mint_url = test_mint_url(); + let payjoin = PayjoinV2::new( + "https://payjoin.example.com".to_string(), + PAYJOIN_OHTTP_KEYS, + PAYJOIN_RECEIVER_KEY, + 4_102_444_800, + ) + .unwrap(); + let mut quote = test_mint_quote(mint_url); + quote.payment_method = cashu::PaymentMethod::Known(KnownMethod::Onchain); + quote.request = "bc1qexamplepayjoinrequest".to_string(); + quote.estimated_blocks = Some(3); + quote.payjoin = Some(payjoin.clone()); + + db.add_mint_quote(quote.clone()).await.unwrap(); + + let retrieved = db.get_mint_quote("e.id).await.unwrap().unwrap(); + assert_eq!(retrieved.payjoin, Some(payjoin.clone())); + + let quotes = db.get_mint_quotes().await.unwrap(); + let listed = quotes + .iter() + .find(|listed_quote| listed_quote.id == quote.id) + .unwrap(); + assert_eq!(listed.payjoin, Some(payjoin.clone())); + + let unissued_quotes = db.get_unissued_mint_quotes().await.unwrap(); + let unissued = unissued_quotes + .iter() + .find(|listed_quote| listed_quote.id == quote.id) + .unwrap(); + assert_eq!(unissued.payjoin, Some(payjoin)); +} + /// Test getting mint quote in transaction pub async fn get_mint_quote_in_transaction(db: DB) where @@ -468,6 +511,35 @@ where assert!(!quotes.is_empty()); } +/// Test adding and retrieving melt quotes with Payjoin metadata +pub async fn add_and_get_melt_quote_payjoin(db: DB) +where + DB: Database, +{ + let payjoin = PayjoinV2::new( + "https://payjoin.example.com".to_string(), + PAYJOIN_OHTTP_KEYS, + PAYJOIN_RECEIVER_KEY, + 4_102_444_800, + ) + .unwrap(); + let mut quote = test_melt_quote(); + quote.payment_method = cashu::PaymentMethod::Known(KnownMethod::Onchain); + quote.payjoin = Some(payjoin.clone()); + + db.add_melt_quote(quote.clone()).await.unwrap(); + + let retrieved = db.get_melt_quote("e.id).await.unwrap().unwrap(); + assert_eq!(retrieved.payjoin, Some(payjoin.clone())); + + let quotes = db.get_melt_quotes().await.unwrap(); + let listed = quotes + .iter() + .find(|listed_quote| listed_quote.id == quote.id) + .unwrap(); + assert_eq!(listed.payjoin, Some(payjoin)); +} + /// Test getting melt quote in transaction pub async fn get_melt_quote_in_transaction(db: DB) where @@ -1551,9 +1623,11 @@ macro_rules! wallet_db_test { get_keys_in_transaction, remove_keys, add_and_get_mint_quote, + add_and_get_mint_quote_payjoin, get_mint_quote_in_transaction, remove_mint_quote, add_and_get_melt_quote, + add_and_get_melt_quote_payjoin, get_melt_quote_in_transaction, remove_melt_quote, add_mint_quote_optimistic_locking, diff --git a/crates/cdk-common/src/lib.rs b/crates/cdk-common/src/lib.rs index 87e3a35f26..ac88b54494 100644 --- a/crates/cdk-common/src/lib.rs +++ b/crates/cdk-common/src/lib.rs @@ -28,6 +28,7 @@ pub mod melt; #[cfg(feature = "mint")] pub mod mint; pub mod mint_quote; +pub mod payjoin; #[cfg(feature = "mint")] pub mod payment; pub mod pub_sub; diff --git a/crates/cdk-common/src/melt.rs b/crates/cdk-common/src/melt.rs index 8942d88259..7e070c643a 100644 --- a/crates/cdk-common/src/melt.rs +++ b/crates/cdk-common/src/melt.rs @@ -314,6 +314,7 @@ where selected_fee_index: value.selected_fee_index, outpoint: value.payment_proof.clone(), change: None, + payjoin: None, }) } ref method => Self::Custom(( @@ -387,6 +388,7 @@ mod tests { selected_fee_index: Some(0), outpoint: Some("abcd...ef:0".to_string()), change: None, + payjoin: None, } } diff --git a/crates/cdk-common/src/mint.rs b/crates/cdk-common/src/mint.rs index 7d03892742..8161b2210b 100644 --- a/crates/cdk-common/src/mint.rs +++ b/crates/cdk-common/src/mint.rs @@ -21,6 +21,7 @@ use uuid::Uuid; use crate::common::IssuerVersion; use crate::mint_quote::MintQuoteResponse; use crate::nuts::{MeltQuoteState, MintQuoteState}; +use crate::payjoin::payjoin_v2_from_extra_json; use crate::payment::PaymentIdentifier; use crate::{Amount, CurrencyUnit, Error, Id, KeySetInfo, PublicKey}; @@ -1167,6 +1168,7 @@ impl From for MeltQuoteOnchainResponse { selected_fee_index: quote.selected_fee_index, outpoint: quote.payment_proof.clone(), change: None, + payjoin: payjoin_v2_from_extra_json(quote.extra_json.as_ref()), } } } @@ -1182,6 +1184,7 @@ impl TryFrom for MintQuoteOnchainResponse { pubkey: quote.pubkey.ok_or(crate::error::Error::MissingPubkey)?, amount_paid: quote.amount_paid().into(), amount_issued: quote.amount_issued().into(), + payjoin: payjoin_v2_from_extra_json(quote.extra_json.as_ref()), }) } } diff --git a/crates/cdk-common/src/payjoin.rs b/crates/cdk-common/src/payjoin.rs new file mode 100644 index 0000000000..a2cc62ae27 --- /dev/null +++ b/crates/cdk-common/src/payjoin.rs @@ -0,0 +1,391 @@ +//! Payjoin helper functions for CDK integrations. +//! +//! Cashu uses Unix timestamp; BIP77 URI fragments use encoded `EX1`. + +use std::num::ParseIntError; + +use bitcoin::bech32::primitives::decode::{ + CharError, CheckedHrpstring, CheckedHrpstringError, UncheckedHrpstringError, +}; +use bitcoin::bech32::{self, Hrp, NoChecksum}; +use thiserror::Error; + +use crate::nuts::nut31::{PayjoinV2, PayjoinV2KeyError}; + +/// Number of satoshis in one bitcoin. +pub const SATS_PER_BTC: u64 = 100_000_000; +/// Extra JSON key used for Payjoin v2 parameters. +pub const ONCHAIN_PAYJOIN_EXTRA_KEY: &str = "payjoin"; +/// Internal extra JSON key used to persist destination Payjoin v2 parameters +/// for onchain melt recovery. +pub const ONCHAIN_PAYJOIN_DESTINATION_EXTRA_KEY: &str = "payjoin_destination"; + +/// Errors for converting BIP21 BTC amount strings to satoshis. +#[derive(Debug, Error)] +pub enum Bip21AmountError { + /// Whole BTC part could not be parsed. + #[error("invalid BIP21 amount whole BTC value '{amount}': {source}")] + InvalidWhole { + /// Original BIP21 amount string. + amount: String, + /// Integer parsing source error. + source: ParseIntError, + }, + /// Fractional BTC part could not be parsed. + #[error("invalid BIP21 amount fractional BTC value '{amount}': {source}")] + InvalidFractional { + /// Original BIP21 amount string. + amount: String, + /// Integer parsing source error. + source: ParseIntError, + }, + /// BIP21 amount contains more precision than satoshis allow. + #[error("BIP21 amount has more than 8 decimal places: {amount}")] + TooPrecise { + /// Original BIP21 amount string. + amount: String, + }, + /// BIP21 amount cannot fit in a u64 satoshi value. + #[error("BIP21 amount is too large to convert to satoshis: {amount}")] + AmountOverflow { + /// Original BIP21 amount string. + amount: String, + }, +} + +/// Errors for BIP77 Payjoin v2 parameter conversion. +#[derive(Debug, Error)] +pub enum PayjoinV2Error { + /// Endpoint URL failed to parse. + #[error("invalid Payjoin endpoint URL: {0}")] + InvalidEndpoint(#[from] url::ParseError), + /// Endpoint fragment contains both `+` and `-` delimiters. + #[error("ambiguous Payjoin fragment delimiter")] + AmbiguousFragmentDelimiter, + /// Endpoint fragment parameter is missing. + #[error("Payjoin URI is missing {prefix} fragment parameter")] + MissingFragmentParam { + /// Missing fragment parameter prefix. + prefix: &'static str, + }, + /// Fragment value is missing the expected prefix. + #[error("Payjoin fragment value is missing {prefix} prefix")] + MissingFragmentPrefix { + /// Missing fragment parameter prefix. + prefix: &'static str, + }, + /// Expiry fragment has the wrong HRP. + #[error("invalid EX1 expiry prefix")] + InvalidExpiryPrefix, + /// Expiry fragment has an invalid character. + #[error("invalid EX1 expiry character: {0}")] + InvalidExpiryCharacter(char), + /// Expiry fragment decodes to the wrong number of bytes. + #[error("invalid EX1 expiry length: {0}")] + InvalidExpiryLength(usize), + /// Expiry fragment contains non-zero padding bits. + #[error("invalid EX1 expiry padding")] + InvalidExpiryPadding, + /// Expiry timestamp cannot fit in the BIP77 u32 timestamp encoding. + #[error("Payjoin expiry exceeds BIP77 u32 range: {0}")] + ExpiryOutOfRange(u64), + /// Payjoin key material is invalid. + #[error("{0}")] + InvalidKey(#[from] PayjoinV2KeyError), +} + +/// Read Cashu Payjoin v2 parameters from an `extra_json` object. +pub fn payjoin_v2_from_extra_json(extra_json: Option<&serde_json::Value>) -> Option { + extra_json + .and_then(|extra| extra.get(ONCHAIN_PAYJOIN_EXTRA_KEY)) + .cloned() + .and_then(|payjoin| serde_json::from_value(payjoin).ok()) +} + +/// Format a satoshi amount as a BIP21 BTC decimal string. +pub fn format_bip21_amount_from_sats(amount_sat: u64) -> String { + let btc = amount_sat / SATS_PER_BTC; + let sats = amount_sat % SATS_PER_BTC; + if sats == 0 { + return btc.to_string(); + } + + format!("{btc}.{sats:08}").trim_end_matches('0').to_string() +} + +/// Parse a BIP21 BTC decimal amount string into satoshis. +pub fn parse_bip21_amount_to_sats(amount: &str) -> Result { + let (whole, fractional) = amount.split_once('.').unwrap_or((amount, "")); + let whole_sat = whole + .parse::() + .map_err(|source| Bip21AmountError::InvalidWhole { + amount: amount.to_string(), + source, + })? + .checked_mul(SATS_PER_BTC) + .ok_or_else(|| Bip21AmountError::AmountOverflow { + amount: amount.to_string(), + })?; + + if fractional.len() > 8 { + return Err(Bip21AmountError::TooPrecise { + amount: amount.to_string(), + }); + } + + let mut padded_fractional = fractional.to_string(); + while padded_fractional.len() < 8 { + padded_fractional.push('0'); + } + + let fractional_sat = if padded_fractional.is_empty() { + 0 + } else { + padded_fractional + .parse::() + .map_err(|source| Bip21AmountError::InvalidFractional { + amount: amount.to_string(), + source, + })? + }; + + whole_sat + .checked_add(fractional_sat) + .ok_or_else(|| Bip21AmountError::AmountOverflow { + amount: amount.to_string(), + }) +} + +/// Parse Cashu Payjoin v2 parameters from a BIP77 mailbox endpoint. +/// +/// BIP77 encodes expiry in the `EX1...` fragment. Cashu uses Unix timestamp; +/// BIP77 URI fragments use encoded `EX1`, so this normalizes `EX1...` into +/// [`PayjoinV2::expires_at`]. +pub fn payjoin_v2_from_bip77_endpoint(endpoint: &str) -> Result { + let mut endpoint_url = url::Url::parse(endpoint)?; + let ohttp_keys = extract_payjoin_fragment_value(&endpoint_url, "OH1")?; + let receiver_key = extract_payjoin_fragment_value(&endpoint_url, "RK1")?; + let expires_at = extract_payjoin_fragment_value(&endpoint_url, "EX1")?; + let expires_at = decode_bip77_expiry(&expires_at)?; + endpoint_url.set_fragment(None); + + PayjoinV2::new( + endpoint_url.to_string(), + strip_fragment_prefix(&ohttp_keys, "OH1")?, + strip_fragment_prefix(&receiver_key, "RK1")?, + expires_at, + ) + .map_err(Into::into) +} + +/// Build a BIP77 mailbox endpoint with `EX1`, `OH1`, and `RK1` fragment parameters. +/// +/// Cashu uses Unix timestamp; BIP77 URI fragments use encoded `EX1`. This +/// conversion is for payjoin-library sender/integration calls that require a +/// BIP21/BIP77-style `pj` URI. +pub fn payjoin_v2_to_bip77_endpoint(payjoin: &PayjoinV2) -> Result { + let mut endpoint = url::Url::parse(&payjoin.endpoint)?; + endpoint.set_fragment(Some(&format!( + "{}-OH1{}-RK1{}", + encode_bip77_expiry(payjoin.expires_at)?, + payjoin.ohttp_keys, + payjoin.receiver_key + ))); + Ok(endpoint.to_string()) +} + +/// Returns true when the Payjoin parameters are expired at `now`. +pub fn payjoin_v2_is_expired_at(payjoin: &PayjoinV2, now: u64) -> bool { + now >= payjoin.expires_at +} + +/// Decode a BIP77 `EX1` expiry fragment parameter into a Unix timestamp. +fn decode_bip77_expiry(value: &str) -> Result { + let hrp_string = CheckedHrpstring::new::(value).map_err(map_bech32_error)?; + if hrp_string.hrp() != expiry_hrp()? { + return Err(PayjoinV2Error::InvalidExpiryPrefix); + } + + let bytes = hrp_string.byte_iter().collect::>(); + if bytes.len() != 4 { + return Err(PayjoinV2Error::InvalidExpiryLength(bytes.len())); + } + + Ok(u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]) as u64) +} + +/// Encode a Unix timestamp as a BIP77 `EX1` expiry fragment parameter. +fn encode_bip77_expiry(expires_at: u64) -> Result { + let expires_at = + u32::try_from(expires_at).map_err(|_| PayjoinV2Error::ExpiryOutOfRange(expires_at))?; + + bech32::encode_upper::(expiry_hrp()?, &expires_at.to_le_bytes()) + .map_err(|_| PayjoinV2Error::InvalidExpiryPrefix) +} + +fn expiry_hrp() -> Result { + Hrp::parse("EX").map_err(|_| PayjoinV2Error::InvalidExpiryPrefix) +} + +fn map_bech32_error(error: CheckedHrpstringError) -> PayjoinV2Error { + match error { + CheckedHrpstringError::Parse(UncheckedHrpstringError::Char(CharError::InvalidChar(ch))) => { + PayjoinV2Error::InvalidExpiryCharacter(ch) + } + CheckedHrpstringError::Parse(UncheckedHrpstringError::Char( + CharError::MissingSeparator | CharError::NothingAfterSeparator | CharError::MixedCase, + )) + | CheckedHrpstringError::Parse(UncheckedHrpstringError::Hrp(_)) + | CheckedHrpstringError::Checksum(_) => PayjoinV2Error::InvalidExpiryPrefix, + _ => PayjoinV2Error::InvalidExpiryPrefix, + } +} + +fn extract_payjoin_fragment_value( + endpoint_url: &url::Url, + prefix: &'static str, +) -> Result { + let fragment = endpoint_url + .fragment() + .ok_or(PayjoinV2Error::MissingFragmentParam { prefix })?; + if fragment.contains('+') && fragment.contains('-') { + return Err(PayjoinV2Error::AmbiguousFragmentDelimiter); + } + let delimiter = if fragment.contains('+') { '+' } else { '-' }; + + fragment + .split(delimiter) + .find(|part| part.starts_with(prefix)) + .map(|part| part.to_string()) + .ok_or(PayjoinV2Error::MissingFragmentParam { prefix }) +} + +fn strip_fragment_prefix<'a>( + value: &'a str, + prefix: &'static str, +) -> Result<&'a str, PayjoinV2Error> { + value + .strip_prefix(prefix) + .ok_or(PayjoinV2Error::MissingFragmentPrefix { prefix }) +} + +#[cfg(test)] +mod tests { + use super::{ + decode_bip77_expiry, encode_bip77_expiry, format_bip21_amount_from_sats, + parse_bip21_amount_to_sats, payjoin_v2_from_bip77_endpoint, payjoin_v2_from_extra_json, + payjoin_v2_is_expired_at, payjoin_v2_to_bip77_endpoint, Bip21AmountError, + ONCHAIN_PAYJOIN_EXTRA_KEY, + }; + use crate::nuts::nut31::PayjoinV2; + + const OHTTP_KEYS: &str = "QYPFLM8XL59R0XV4VGPLS7FRDSSM4TUXL07TXCWC4S0GLVLNK2SE4NQ"; + const RECEIVER_KEY: &str = "QV6WSX0UQPAEA0RH54430D0UVZWS8CZ6FEGZF4RGFCDKJLPGMYEJG"; + + #[test] + fn bip77_expiry_roundtrips() { + assert_eq!(encode_bip77_expiry(1_720_547_781).unwrap(), "EX1C4UC6ES"); + assert_eq!(decode_bip77_expiry("EX1C4UC6ES").unwrap(), 1_720_547_781); + } + + #[test] + fn rejects_malformed_bip77_expiry() { + assert!(matches!( + decode_bip77_expiry("EY1C4UC6ES"), + Err(super::PayjoinV2Error::InvalidExpiryPrefix) + )); + assert!(matches!( + decode_bip77_expiry("EX1*"), + Err(super::PayjoinV2Error::InvalidExpiryCharacter('*')) + )); + assert!(matches!( + decode_bip77_expiry("EX1Q"), + Err(super::PayjoinV2Error::InvalidExpiryLength(0)) + )); + assert!(matches!( + encode_bip77_expiry(u64::from(u32::MAX) + 1), + Err(super::PayjoinV2Error::ExpiryOutOfRange(value)) if value == u64::from(u32::MAX) + 1 + )); + } + + #[test] + fn parses_bip77_endpoint_into_cashu_fields() { + let payjoin = payjoin_v2_from_bip77_endpoint( + "HTTPS://PAYJO.IN/E73HSW759WNES#EX12XHZ26S-OH1QYPFLM8XL59R0XV4VGPLS7FRDSSM4TUXL07TXCWC4S0GLVLNK2SE4NQ-RK1QV6WSX0UQPAEA0RH54430D0UVZWS8CZ6FEGZF4RGFCDKJLPGMYEJG", + ) + .unwrap(); + + assert_eq!(payjoin.endpoint, "https://payjo.in/E73HSW759WNES"); + assert_eq!(payjoin.ohttp_keys.to_string(), OHTTP_KEYS); + assert_eq!(payjoin.receiver_key.to_string(), RECEIVER_KEY); + assert_eq!(payjoin.expires_at, 1_780_854_353); + } + + #[test] + fn builds_bip77_endpoint_from_cashu_fields() { + let payjoin = PayjoinV2::new( + "https://payjoin.example/pj".to_string(), + OHTTP_KEYS, + RECEIVER_KEY, + 1_720_547_781, + ) + .expect("valid Payjoin keys"); + + assert_eq!( + payjoin_v2_to_bip77_endpoint(&payjoin).unwrap(), + "https://payjoin.example/pj#EX1C4UC6ES-OH1QYPFLM8XL59R0XV4VGPLS7FRDSSM4TUXL07TXCWC4S0GLVLNK2SE4NQ-RK1QV6WSX0UQPAEA0RH54430D0UVZWS8CZ6FEGZF4RGFCDKJLPGMYEJG" + ); + } + + #[test] + fn reads_payjoin_from_extra_json() { + let payjoin = PayjoinV2::new( + "https://payjoin.example/pj".to_string(), + OHTTP_KEYS, + RECEIVER_KEY, + 1_720_547_781, + ) + .expect("valid Payjoin keys"); + let extra = serde_json::json!({ ONCHAIN_PAYJOIN_EXTRA_KEY: payjoin.clone() }); + + assert_eq!(payjoin_v2_from_extra_json(Some(&extra)).unwrap(), payjoin); + assert_eq!(payjoin_v2_from_extra_json(None), None); + } + + #[test] + fn formats_bip21_amount_from_sats() { + assert_eq!(format_bip21_amount_from_sats(0), "0"); + assert_eq!(format_bip21_amount_from_sats(1), "0.00000001"); + assert_eq!(format_bip21_amount_from_sats(100_000_000), "1"); + assert_eq!(format_bip21_amount_from_sats(123_456_780), "1.2345678"); + } + + #[test] + fn parses_bip21_amount_to_sats() { + assert_eq!(parse_bip21_amount_to_sats("0").unwrap(), 0); + assert_eq!(parse_bip21_amount_to_sats("0.00000001").unwrap(), 1); + assert_eq!(parse_bip21_amount_to_sats("1").unwrap(), 100_000_000); + assert_eq!( + parse_bip21_amount_to_sats("1.2345678").unwrap(), + 123_456_780 + ); + assert!(matches!( + parse_bip21_amount_to_sats("1.000000001"), + Err(Bip21AmountError::TooPrecise { .. }) + )); + } + + #[test] + fn detects_expired_payjoin() { + let payjoin = PayjoinV2::new( + "https://payjoin.example/pj".to_string(), + OHTTP_KEYS, + RECEIVER_KEY, + 10, + ) + .expect("valid Payjoin keys"); + + assert!(!payjoin_v2_is_expired_at(&payjoin, 9)); + assert!(payjoin_v2_is_expired_at(&payjoin, 10)); + } +} diff --git a/crates/cdk-common/src/payment.rs b/crates/cdk-common/src/payment.rs index c16d1d9f7a..2215f9cee2 100644 --- a/crates/cdk-common/src/payment.rs +++ b/crates/cdk-common/src/payment.rs @@ -18,6 +18,7 @@ use thiserror::Error; use crate::mint::{MeltPaymentRequest, MeltQuote}; use crate::nuts::nut30::MeltQuoteOnchainFeeOption; use crate::nuts::{CurrencyUnit, MeltQuoteState}; +use crate::payjoin::{ONCHAIN_PAYJOIN_DESTINATION_EXTRA_KEY, ONCHAIN_PAYJOIN_EXTRA_KEY}; use crate::{Amount, QuoteId}; /// CDK Payment Error @@ -408,13 +409,20 @@ impl OutgoingPaymentOptions { max_fee_amount: Some(fee_reserve), quote_id: melt_quote.id, fee_index: melt_quote.selected_fee_index, - metadata: None, + metadata: onchain_melt_payment_metadata(melt_quote.extra_json), }), )), } } } +fn onchain_melt_payment_metadata(extra_json: Option) -> Option { + let extra_json = extra_json?; + extra_json + .get(ONCHAIN_PAYJOIN_DESTINATION_EXTRA_KEY) + .map(|payjoin| serde_json::json!({ ONCHAIN_PAYJOIN_EXTRA_KEY: payjoin }).to_string()) +} + /// Mint payment trait #[async_trait] pub trait MintPayment { @@ -482,6 +490,18 @@ pub trait MintPayment { &self, payment_identifier: &PaymentIdentifier, ) -> Result; + + /// Check the status of an outgoing payment without advancing backend state. + /// + /// Startup recovery uses this conservative path so backends can answer from + /// local durable state only. Backends that do not have side effects in + /// [`Self::check_outgoing_payment`] can use the default implementation. + async fn check_outgoing_payment_status_only( + &self, + payment_identifier: &PaymentIdentifier, + ) -> Result { + self.check_outgoing_payment(payment_identifier).await + } } /// An event emitted which should be handled by the mint @@ -837,6 +857,24 @@ where result } + + async fn check_outgoing_payment_status_only( + &self, + payment_identifier: &PaymentIdentifier, + ) -> Result { + let metrics = MintMetricGuard::new("check_outgoing_payment_status_only"); + + let result = self + .inner + .check_outgoing_payment_status_only(payment_identifier) + .await; + + let success = result.is_ok(); + + metrics.record(success); + + result + } } /// Type alias for Mint Payment trait @@ -909,6 +947,100 @@ mod tests { let result_bolt12 = PaymentIdentifier::new("bolt12_payment_hash", "00"); assert!(matches!(result_bolt12, Err(Error::InvalidHash))); } + + #[test] + fn onchain_melt_payment_metadata_uses_persisted_payjoin_destination() { + let destination = serde_json::json!({ + "endpoint": "https://payjoin.example/pj", + "ohttp_keys": "QYPFLM8XL59R0XV4VGPLS7FRDSSM4TUXL07TXCWC4S0GLVLNK2SE4NQ", + "receiver_key": "QV6WSX0UQPAEA0RH54430D0UVZWS8CZ6FEGZF4RGFCDKJLPGMYEJG", + "expires_at": 1741276520, + }); + let extra_json = serde_json::json!({ + ONCHAIN_PAYJOIN_EXTRA_KEY: destination.clone(), + ONCHAIN_PAYJOIN_DESTINATION_EXTRA_KEY: destination.clone(), + }); + + let mut quote = MeltQuote::new_onchain( + Some(QuoteId::new()), + MeltPaymentRequest::Onchain { + address: "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq".to_string(), + }, + CurrencyUnit::Sat, + Amount::new(4_000, CurrencyUnit::Sat), + 1_701_704_757, + None, + Some(extra_json), + vec![MeltQuoteOnchainFeeOption { + fee_index: 0, + fee_reserve: Amount::from(1_000), + estimated_blocks: 1, + }], + ) + .expect("well-formed onchain quote must construct"); + quote + .select_onchain_fee_option(0) + .expect("known fee option must select"); + + let options = OutgoingPaymentOptions::from_melt_quote_with_fee(quote) + .expect("onchain quote must convert to outgoing payment options"); + let onchain_options = match options { + OutgoingPaymentOptions::Onchain(options) => options, + other => panic!("expected onchain payment options, got {other:?}"), + }; + + let metadata = onchain_options + .metadata + .expect("payjoin metadata must be present"); + let metadata: serde_json::Value = + serde_json::from_str(&metadata).expect("metadata must be valid JSON"); + + assert_eq!( + metadata, + serde_json::json!({ ONCHAIN_PAYJOIN_EXTRA_KEY: destination }) + ); + assert!( + metadata + .get(ONCHAIN_PAYJOIN_DESTINATION_EXTRA_KEY) + .is_none(), + "internal persistence key should not be passed through to the backend" + ); + } + + #[test] + fn onchain_melt_payment_metadata_ignores_non_payjoin_extra_json() { + let mut quote = MeltQuote::new_onchain( + Some(QuoteId::new()), + MeltPaymentRequest::Onchain { + address: "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq".to_string(), + }, + CurrencyUnit::Sat, + Amount::new(4_000, CurrencyUnit::Sat), + 1_701_704_757, + None, + Some(serde_json::json!({ + "internal_note": "must not reach payment backend", + })), + vec![MeltQuoteOnchainFeeOption { + fee_index: 0, + fee_reserve: Amount::from(1_000), + estimated_blocks: 1, + }], + ) + .expect("well-formed onchain quote must construct"); + quote + .select_onchain_fee_option(0) + .expect("known fee option must select"); + + let options = OutgoingPaymentOptions::from_melt_quote_with_fee(quote) + .expect("onchain quote must convert to outgoing payment options"); + let onchain_options = match options { + OutgoingPaymentOptions::Onchain(options) => options, + other => panic!("expected onchain payment options, got {other:?}"), + }; + + assert_eq!(onchain_options.metadata, None); + } } #[test] diff --git a/crates/cdk-common/src/pub_sub/types.rs b/crates/cdk-common/src/pub_sub/types.rs index 7ceb169a78..417bedeac6 100644 --- a/crates/cdk-common/src/pub_sub/types.rs +++ b/crates/cdk-common/src/pub_sub/types.rs @@ -28,13 +28,7 @@ pub trait Spec: Send + Sync { + Serialize; /// Event - type Event: Event - + Send - + Sync - + Eq - + PartialEq - + DeserializeOwned - + Serialize; + type Event: Event + Send + Sync + Eq + PartialEq + Serialize; /// Subscription Id type SubscriptionId: Clone @@ -68,7 +62,7 @@ pub trait Spec: Send + Sync { } /// Event trait -pub trait Event: Clone + Send + Sync + Eq + PartialEq + DeserializeOwned + Serialize { +pub trait Event: Clone + Send + Sync + Eq + PartialEq + Serialize { /// Generic Topic /// /// It should be serializable/deserializable to be stored in the database layer and it should diff --git a/crates/cdk-common/src/wallet/mod.rs b/crates/cdk-common/src/wallet/mod.rs index 33a0b2ea83..5bd4029714 100644 --- a/crates/cdk-common/src/wallet/mod.rs +++ b/crates/cdk-common/src/wallet/mod.rs @@ -10,6 +10,7 @@ use bitcoin::hashes::{sha256, Hash, HashEngine}; use cashu::amount::{FeeAndAmounts, KeysetFeeAndAmounts, SplitTarget}; use cashu::nuts::nut07::ProofState; use cashu::nuts::nut18::PaymentRequest; +use cashu::nuts::nut31::PayjoinV2; use cashu::nuts::{AuthProof, Keys}; use cashu::util::hex; use cashu::{nut00, PaymentMethod, Proof, Proofs, PublicKey}; @@ -201,7 +202,11 @@ pub struct MintQuote { #[serde(default)] pub amount_paid: Amount, /// Estimated confirmation target in blocks for onchain quotes + #[serde(default, skip_serializing_if = "Option::is_none")] pub estimated_blocks: Option, + /// Optional onchain Payjoin instructions returned by the mint. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub payjoin: Option, /// Operation ID that has reserved this quote (for saga pattern) #[serde(default)] pub used_by_operation: Option, @@ -238,6 +243,9 @@ pub struct MeltQuote { /// Selected fee option index for onchain quotes #[serde(default, skip_serializing_if = "Option::is_none")] pub fee_index: Option, + /// Optional onchain Payjoin acceptance returned by the mint. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub payjoin: Option, /// Payment method pub payment_method: PaymentMethod, /// Operation ID that has reserved this quote (for saga pattern) @@ -274,6 +282,7 @@ impl MintQuote { amount_issued: Amount::ZERO, amount_paid: Amount::ZERO, estimated_blocks: None, + payjoin: None, used_by_operation: None, version: 0, } diff --git a/crates/cdk-ffi/src/error.rs b/crates/cdk-ffi/src/error.rs index c3b7013606..b5f5f270ae 100644 --- a/crates/cdk-ffi/src/error.rs +++ b/crates/cdk-ffi/src/error.rs @@ -68,6 +68,12 @@ impl From for FfiError { } } +impl From for FfiError { + fn from(err: cdk::nuts::nut31::PayjoinV2KeyError) -> Self { + FfiError::internal(err) + } +} + impl From for FfiError { fn from(err: serde_json::Error) -> Self { FfiError::internal(err) diff --git a/crates/cdk-ffi/src/types/quote.rs b/crates/cdk-ffi/src/types/quote.rs index 79fffcba1b..48caf33405 100644 --- a/crates/cdk-ffi/src/types/quote.rs +++ b/crates/cdk-ffi/src/types/quote.rs @@ -32,6 +32,8 @@ pub struct MintQuote { pub amount_paid: Amount, /// Estimated confirmation target in blocks for onchain quotes pub estimated_blocks: Option, + /// Optional onchain Payjoin instructions returned by the mint. + pub payjoin: Option, /// Payment method pub payment_method: PaymentMethod, /// Secret key (optional, hex-encoded) @@ -56,6 +58,7 @@ impl From for MintQuote { amount_issued: quote.amount_issued.into(), amount_paid: quote.amount_paid.into(), estimated_blocks: quote.estimated_blocks, + payjoin: quote.payjoin.map(Into::into), payment_method: quote.payment_method.into(), secret_key: quote.secret_key.map(|sk| sk.to_secret_hex()), used_by_operation: quote.used_by_operation.map(|id| id.to_string()), @@ -85,6 +88,7 @@ impl TryFrom for cdk::wallet::MintQuote { amount_issued: quote.amount_issued.into(), amount_paid: quote.amount_paid.into(), estimated_blocks: quote.estimated_blocks, + payjoin: quote.payjoin.map(TryInto::try_into).transpose()?, payment_method: quote.payment_method.into(), secret_key, used_by_operation: quote.used_by_operation, @@ -349,6 +353,45 @@ impl From for cdk::nuts::PaymentMethod { } } +/// FFI-compatible Payjoin v2 parameters. +/// +/// Cashu uses Unix timestamp; BIP77 URI fragments use encoded `EX1`. +#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] +pub struct PayjoinV2 { + /// Mailbox endpoint without BIP77 fragment parameters. + pub endpoint: String, + /// Encoded OHTTP key material. + pub ohttp_keys: String, + /// Encoded receiver session key. + pub receiver_key: String, + /// Unix timestamp until the Payjoin parameters are valid. + pub expires_at: u64, +} + +impl From for PayjoinV2 { + fn from(payjoin: cdk::nuts::PayjoinV2) -> Self { + Self { + endpoint: payjoin.endpoint, + ohttp_keys: payjoin.ohttp_keys.to_string(), + receiver_key: payjoin.receiver_key.to_string(), + expires_at: payjoin.expires_at, + } + } +} + +impl TryFrom for cdk::nuts::PayjoinV2 { + type Error = cdk::nuts::nut31::PayjoinV2KeyError; + + fn try_from(payjoin: PayjoinV2) -> Result { + Self::new( + payjoin.endpoint, + payjoin.ohttp_keys, + payjoin.receiver_key, + payjoin.expires_at, + ) + } +} + /// FFI-compatible MintQuoteOnchainResponse. #[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] pub struct MintQuoteOnchainResponse { @@ -366,6 +409,8 @@ pub struct MintQuoteOnchainResponse { pub amount_paid: Amount, /// Amount already issued for this quote pub amount_issued: Amount, + /// Optional Payjoin instructions. + pub payjoin: Option, } impl From> for MintQuoteOnchainResponse { @@ -378,6 +423,7 @@ impl From> for MintQuoteOnchainRespo pubkey: response.pubkey.to_string(), amount_paid: response.amount_paid.into(), amount_issued: response.amount_issued.into(), + payjoin: response.payjoin.map(Into::into), } } } @@ -426,6 +472,8 @@ pub struct MeltQuoteOnchainResponse { pub outpoint: Option, /// Change blind signatures as JSON, when the mint returns change pub change: Option, + /// Optional Payjoin v2 acceptance for this quote. + pub payjoin: Option, } impl From> for MeltQuoteOnchainResponse { @@ -446,6 +494,7 @@ impl From> for MeltQuoteOnchainRespo selected_fee_index: response.selected_fee_index, outpoint: response.outpoint, change, + payjoin: response.payjoin.map(Into::into), } } } @@ -475,6 +524,8 @@ pub struct MeltQuote { pub estimated_blocks: Option, /// Selected fee option index for onchain quotes pub fee_index: Option, + /// Optional onchain Payjoin acceptance returned by the mint. + pub payjoin: Option, /// Payment method pub payment_method: PaymentMethod, /// Operation ID that reserved this quote @@ -498,6 +549,7 @@ impl From for MeltQuote { payment_proof: quote.payment_proof.clone(), estimated_blocks: quote.estimated_blocks, fee_index: quote.fee_index, + payjoin: quote.payjoin.map(Into::into), payment_method: quote.payment_method.into(), used_by_operation: quote.used_by_operation.map(|id| id.to_string()), version: quote.version, @@ -521,6 +573,7 @@ impl TryFrom for cdk::wallet::MeltQuote { payment_proof: quote.payment_proof, estimated_blocks: quote.estimated_blocks, fee_index: quote.fee_index, + payjoin: quote.payjoin.map(TryInto::try_into).transpose()?, payment_method: quote.payment_method.into(), used_by_operation: quote.used_by_operation, version: quote.version, diff --git a/crates/cdk-ffi/src/wallet.rs b/crates/cdk-ffi/src/wallet.rs index f13a646e23..5387eda200 100644 --- a/crates/cdk-ffi/src/wallet.rs +++ b/crates/cdk-ffi/src/wallet.rs @@ -436,6 +436,27 @@ impl Wallet { Ok(quotes.into_iter().map(Into::into).collect()) } + /// Fetch available onchain melt quote options with Payjoin instructions. + pub async fn quote_onchain_melt_options_with_payjoin( + &self, + address: String, + amount: Amount, + max_fee_amount: Option, + payjoin: PayjoinV2, + ) -> Result, FfiError> { + let quotes = self + .inner + .quote_onchain_melt_options_with_payjoin( + &address, + amount.into(), + max_fee_amount.map(Into::into), + Some(payjoin.try_into()?), + ) + .await?; + + Ok(quotes.into_iter().map(Into::into).collect()) + } + /// Persist the selected onchain melt quote before preparing it. pub async fn select_onchain_melt_quote(&self, quote: MeltQuote) -> Result { let quote = self diff --git a/crates/cdk-integration-tests/Cargo.toml b/crates/cdk-integration-tests/Cargo.toml index 989c057cdc..c03253e50d 100644 --- a/crates/cdk-integration-tests/Cargo.toml +++ b/crates/cdk-integration-tests/Cargo.toml @@ -12,6 +12,14 @@ repository = "https://github.com/cashubtc/cdk.git" [features] http_subscription = ["cdk/http_subscription"] bdk = ["cdk-mintd/bdk"] +payjoin-regtest = [ + "cdk-mintd/payjoin-local-https", + "dep:ohttp-relay", + "dep:payjoin", + "dep:payjoin-directory", + "dep:rcgen", + "dep:rustls", +] [dependencies] async-trait.workspace = true @@ -38,6 +46,8 @@ once_cell.workspace = true uuid.workspace = true serde.workspace = true serde_json.workspace = true +reqwest.workspace = true +url.workspace = true lightning-invoice.workspace = true fedimint-tonic-lnd = "0.2.0" cln-rpc = "0.4.0" @@ -53,6 +63,11 @@ bitcoin = "0.32.0" clap = { workspace = true, features = ["derive"] } web-time.workspace = true lightning.workspace = true +ohttp-relay = { version = "=0.0.10", features = ["_test-util"], optional = true } +payjoin = { version = "0.25.0", default-features = false, features = ["v2", "io"], optional = true } +payjoin-directory = { version = "=0.0.3", features = ["_danger-local-https"], optional = true } +rcgen = { version = "0.11", optional = true } +rustls = { version = "0.22", optional = true } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] tokio.workspace = true diff --git a/crates/cdk-integration-tests/src/bin/start_regtest_mints.rs b/crates/cdk-integration-tests/src/bin/start_regtest_mints.rs index 8e69f24c3b..913583317b 100644 --- a/crates/cdk-integration-tests/src/bin/start_regtest_mints.rs +++ b/crates/cdk-integration-tests/src/bin/start_regtest_mints.rs @@ -22,6 +22,8 @@ use bip39::Mnemonic; use cashu::Amount; use cdk_integration_tests::cli::CommonArgs; use cdk_integration_tests::init_regtest::start_regtest_end; +#[cfg(feature = "payjoin-regtest")] +use cdk_integration_tests::payjoin_regtest::PayjoinRegtestServices; use cdk_integration_tests::shared; use cdk_ldk_node::{CdkLdkNode, CdkLdkNodeBuilder}; use cdk_mintd::config::LoggingConfig; @@ -68,6 +70,20 @@ struct Args { skip_ln: bool, } +#[derive(Clone)] +struct LocalPayjoinConfig { + directory_url: String, + ohttp_relay_url: String, + cert_path: String, +} + +const CLN_BDK_MNEMONIC: &str = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; +const LND_BDK_MNEMONIC: &str = + "legal winner thank year wave sausage worth useful legal winner thank yellow"; +const LDK_BDK_MNEMONIC: &str = + "letter advice cage absurd amount doctor acoustic avoid letter advice cage above"; + /// Start regtest CLN mint using the library async fn start_cln_mint( temp_dir: &Path, @@ -101,7 +117,7 @@ async fn start_cln_mint( cln_config, ); - apply_onchain_settings(&mut settings); + apply_onchain_settings(&mut settings, CLN_BDK_MNEMONIC); apply_database_settings(&mut settings, database_type, port)?; println!("Starting CLN mintd on port {port}"); @@ -171,7 +187,7 @@ async fn start_lnd_mint( "cattle gold bind busy sound reduce tone addict baby spend february strategy".to_string(), ); - apply_onchain_settings(&mut settings); + apply_onchain_settings(&mut settings, LND_BDK_MNEMONIC); apply_database_settings(&mut settings, database_type, port)?; println!("Starting LND mintd on port {port}"); @@ -251,7 +267,7 @@ async fn start_ldk_mint( // Create settings struct for LDK mint using a new shared function let mut settings = create_ldk_settings(port, ldk_config); - apply_onchain_settings(&mut settings); + apply_onchain_settings(&mut settings, LDK_BDK_MNEMONIC); apply_database_settings(&mut settings, database_type, port)?; println!("Starting LDK mintd on port {port}"); @@ -337,7 +353,7 @@ fn create_ldk_settings( } } -fn apply_onchain_settings(settings: &mut cdk_mintd::config::Settings) { +fn apply_onchain_settings(settings: &mut cdk_mintd::config::Settings, mnemonic: &str) { settings.onchain = Some(cdk_mintd::config::Onchain { onchain_backend: cdk_mintd::config::OnchainBackend::Bdk, min_mint: 1.into(), @@ -346,10 +362,7 @@ fn apply_onchain_settings(settings: &mut cdk_mintd::config::Settings) { max_melt: 500_000.into(), }); settings.bdk = Some(cdk_mintd::config::Bdk { - mnemonic: Some( - "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" - .to_string(), - ), + mnemonic: Some(mnemonic.to_string()), network: Some("regtest".to_string()), bitcoind_rpc_host: Some("127.0.0.1".to_string()), bitcoind_rpc_port: Some(18443), @@ -438,9 +451,16 @@ async fn start_onchain_mint( port: u16, database_type: &str, shutdown: Arc, + payjoin_config: Option, + bdk_mnemonic: &str, ) -> Result> { let mut settings = create_onchain_settings(port); - apply_onchain_settings(&mut settings); + apply_onchain_settings(&mut settings, bdk_mnemonic); + if let (Some(config), Some(bdk)) = (payjoin_config, settings.bdk.as_mut()) { + bdk.payjoin_directory_url = Some(config.directory_url); + bdk.payjoin_ohttp_relay_url = Some(config.ohttp_relay_url); + bdk.payjoin_local_tls_cert_path = Some(config.cert_path); + } apply_database_settings(&mut settings, database_type, port)?; println!("Starting onchain-only mintd on port {port}"); @@ -473,6 +493,34 @@ async fn start_onchain_mint( Ok(handle) } +fn write_mint_env_file( + temp_dir: &Path, + mint_url_1: &str, + mint_url_2: &str, + mint_url_3: &str, + payjoin_config: Option<&LocalPayjoinConfig>, +) -> Result<()> { + let mut env_vars = vec![ + ("CDK_TEST_MINT_URL", mint_url_1), + ("CDK_TEST_MINT_URL_2", mint_url_2), + ("CDK_TEST_MINT_URL_3", mint_url_3), + ]; + + if let Some(config) = payjoin_config { + env_vars.push(("CDK_MINTD_BDK_PAYJOIN_DIRECTORY_URL", &config.directory_url)); + env_vars.push(( + "CDK_MINTD_BDK_PAYJOIN_OHTTP_RELAY_URL", + &config.ohttp_relay_url, + )); + env_vars.push(( + "CDK_MINTD_BDK_PAYJOIN_LOCAL_TLS_CERT_PATH", + &config.cert_path, + )); + } + + shared::write_env_file(temp_dir, &env_vars) +} + fn main() -> Result<()> { let rt = Arc::new(Runtime::new()?); @@ -490,13 +538,9 @@ fn main() -> Result<()> { let mint_url_1 = format!("http://{}:{}", args.mint_addr, args.cln_port); let mint_url_2 = format!("http://{}:{}", args.mint_addr, args.lnd_port); let mint_url_3 = format!("http://{}:{}", args.mint_addr, args.ldk_port); - let env_vars: Vec<(&str, &str)> = vec![ - ("CDK_TEST_MINT_URL", &mint_url_1), - ("CDK_TEST_MINT_URL_2", &mint_url_2), - ("CDK_TEST_MINT_URL_3", &mint_url_3), - ]; - - shared::write_env_file(&temp_dir, &env_vars)?; + if !args.skip_ln { + write_mint_env_file(&temp_dir, &mint_url_1, &mint_url_2, &mint_url_3, None)?; + } // Start regtest println!("Starting regtest..."); @@ -533,11 +577,44 @@ fn main() -> Result<()> { } } + #[cfg(feature = "payjoin-regtest")] + let (_payjoin_services, payjoin_config) = { + let services = PayjoinRegtestServices::start(&temp_dir).await?; + let config = LocalPayjoinConfig { + directory_url: services.directory_url.clone(), + ohttp_relay_url: services.ohttp_relay_url.clone(), + cert_path: services.cert_path.clone(), + }; + (Some(services), Some(config)) + }; + #[cfg(not(feature = "payjoin-regtest"))] + let (_payjoin_services, payjoin_config): (Option<()>, Option) = + (None, None); + + write_mint_env_file( + &temp_dir, + &mint_url_1, + &mint_url_2, + &mint_url_3, + payjoin_config.as_ref(), + )?; + let onchain_handle = start_onchain_mint( &temp_dir.join("onchain_mint"), args.cln_port, &args.database_type, shutdown_clone.clone(), + payjoin_config.clone(), + CLN_BDK_MNEMONIC, + ) + .await?; + let onchain_second_handle = start_onchain_mint( + &temp_dir.join("onchain_mint_2"), + args.lnd_port, + &args.database_type, + shutdown_clone.clone(), + payjoin_config.clone(), + LND_BDK_MNEMONIC, ) .await?; @@ -555,18 +632,28 @@ fn main() -> Result<()> { s_u.notify_waiters(); }); - shared::wait_for_mint_ready_with_shutdown( - args.cln_port, - 100, - Arc::clone(&cancel_token), - ) - .await?; - - println!("Onchain-only mint is ready on port {}!", args.cln_port); + tokio::try_join!( + shared::wait_for_mint_ready_with_shutdown( + args.cln_port, + 100, + Arc::clone(&cancel_token), + ), + shared::wait_for_mint_ready_with_shutdown( + args.lnd_port, + 100, + Arc::clone(&cancel_token), + ), + )?; + + println!( + "Onchain-only mints are ready on ports {} and {}!", + args.cln_port, args.lnd_port + ); // Wait for shutdown shutdown_clone_one.notified().await; let _ = onchain_handle.await; + let _ = onchain_second_handle.await; return Ok(()); } diff --git a/crates/cdk-integration-tests/src/lib.rs b/crates/cdk-integration-tests/src/lib.rs index 521503bb8d..21682b2c54 100644 --- a/crates/cdk-integration-tests/src/lib.rs +++ b/crates/cdk-integration-tests/src/lib.rs @@ -37,6 +37,8 @@ pub mod init_auth_mint; pub mod init_pure_tests; pub mod init_regtest; pub mod ln_regtest; +#[cfg(feature = "payjoin-regtest")] +pub mod payjoin_regtest; pub mod shared; /// Generate standard keyset amounts as powers of 2 diff --git a/crates/cdk-integration-tests/src/ln_regtest/bitcoin_client.rs b/crates/cdk-integration-tests/src/ln_regtest/bitcoin_client.rs index 8b17f60177..673484238a 100644 --- a/crates/cdk-integration-tests/src/ln_regtest/bitcoin_client.rs +++ b/crates/cdk-integration-tests/src/ln_regtest/bitcoin_client.rs @@ -5,7 +5,8 @@ use std::str::FromStr; use std::sync::Arc; use anyhow::Result; -use bitcoincore_rpc::bitcoin::{Address, Amount}; +use bitcoincore_rpc::bitcoin::{consensus, Address, Amount, Transaction, Txid}; +use bitcoincore_rpc::json::WalletCreateFundedPsbtOptions; use bitcoincore_rpc::{Auth, Client, RpcApi}; /// Bitcoin client @@ -96,6 +97,73 @@ impl BitcoinClient { Ok(()) } + /// Create and fund a PSBT paying `amount` sats to `address`. + pub fn create_funded_psbt( + &self, + address: &str, + amount: u64, + fee_rate_sat_vb: u64, + ) -> Result { + let client = &self.client; + + let mut outputs = std::collections::HashMap::new(); + outputs.insert(address.to_string(), Amount::from_sat(amount)); + let options = WalletCreateFundedPsbtOptions { + fee_rate: Some(Amount::from_sat(fee_rate_sat_vb.saturating_mul(1000))), + replaceable: Some(false), + ..Default::default() + }; + + Ok(client + .wallet_create_funded_psbt(&[], &outputs, None, Some(options), Some(false))? + .psbt) + } + + /// Sign a PSBT with the regtest Bitcoin Core wallet. + pub fn sign_psbt(&self, psbt: &str) -> Result { + let client = &self.client; + let processed = client.wallet_process_psbt(psbt, Some(true), None, Some(false))?; + Ok(processed.psbt) + } + + /// Finalize and broadcast a PSBT with the regtest Bitcoin Core wallet. + pub fn finalize_and_broadcast_psbt(&self, psbt: &str) -> Result { + let client = &self.client; + let finalized = client.finalize_psbt(psbt, Some(true))?; + let tx_hex = finalized + .hex + .ok_or_else(|| anyhow::anyhow!("finalizepsbt did not return transaction hex"))?; + let tx: Transaction = consensus::deserialize(&tx_hex)?; + Ok(client.send_raw_transaction(&tx)?) + } + + /// Return the input count of the first mempool transaction that pays to + /// `address`, if any. + /// + /// Only the mempool is inspected, so this works without `-txindex` even for + /// transactions that do not belong to bitcoind's own wallet (e.g. a Payjoin + /// transaction built and broadcast by the mints' BDK wallets). Used by the + /// Payjoin tests to prove the combined transaction batches sender and + /// receiver inputs before it is mined. + pub fn mempool_tx_input_count_to_address(&self, address: &str) -> Result> { + let client = &self.client; + let target = Address::from_str(address)?.assume_checked().script_pubkey(); + for txid in client.get_raw_mempool()? { + let tx = match client.get_raw_transaction(&txid, None) { + Ok(tx) => tx, + Err(_) => continue, + }; + if tx + .output + .iter() + .any(|output| output.script_pubkey == target) + { + return Ok(Some(tx.input.len())); + } + } + Ok(None) + } + pub fn get_balance(&self) -> Result { let client = &self.client; diff --git a/crates/cdk-integration-tests/src/payjoin_regtest.rs b/crates/cdk-integration-tests/src/payjoin_regtest.rs new file mode 100644 index 0000000000..e271749f57 --- /dev/null +++ b/crates/cdk-integration-tests/src/payjoin_regtest.rs @@ -0,0 +1,127 @@ +//! Local Payjoin v2 services for onchain regtest. + +use std::net::{TcpListener, TcpStream}; +use std::path::Path; +use std::process::{Child, Command, Stdio}; +use std::str::FromStr; +use std::time::Duration; + +use anyhow::{bail, Context, Result}; + +/// Running local Payjoin services for regtest. +pub struct PayjoinRegtestServices { + /// Payjoin directory URL. + pub directory_url: String, + /// OHTTP relay URL. + pub ohttp_relay_url: String, + /// DER-encoded directory TLS certificate. + pub cert_der: Vec, + /// Path to the DER-encoded directory TLS certificate. + pub cert_path: String, + redis: Child, + directory_handle: + tokio::task::JoinHandle>>, + relay_handle: + tokio::task::JoinHandle>>, +} + +impl PayjoinRegtestServices { + /// Start Redis, a Payjoin Directory, and an OHTTP relay on free localhost ports. + pub async fn start(work_dir: &Path) -> Result { + let payjoin_dir = work_dir.join("payjoin"); + std::fs::create_dir_all(&payjoin_dir)?; + + let cert = rcgen::generate_simple_self_signed(vec![ + "localhost".to_string(), + "127.0.0.1".to_string(), + "0.0.0.0".to_string(), + ])?; + let cert_der = cert.serialize_der()?; + let key_der = cert.serialize_private_key_der(); + let cert_path = payjoin_dir.join("directory-cert.der"); + std::fs::write(&cert_path, &cert_der)?; + + let redis_port = free_port()?; + let mut redis = start_redis(redis_port, &payjoin_dir)?; + wait_for_tcp("127.0.0.1", redis_port, &mut redis).await?; + + let ohttp_config = payjoin_directory::gen_ohttp_server_config()?; + let (directory_port, directory_handle) = + payjoin_directory::listen_tcp_with_tls_on_free_port( + format!("127.0.0.1:{redis_port}"), + Duration::from_secs(payjoin_directory::DEFAULT_TIMEOUT_SECS), + (cert_der.clone(), key_der), + ohttp_config.into(), + ) + .await + .map_err(|err| anyhow::anyhow!("{err}"))?; + let directory_url = format!("https://localhost:{directory_port}"); + + let mut root_store = rustls::RootCertStore::empty(); + root_store + .add(rustls::pki_types::CertificateDer::from(cert_der.clone())) + .context("add local Payjoin directory cert to relay root store")?; + let gateway = ohttp_relay::GatewayUri::from_str(&directory_url) + .map_err(|err| anyhow::anyhow!("{err}"))?; + let (relay_port, relay_handle) = ohttp_relay::listen_tcp_on_free_port(gateway, root_store) + .await + .map_err(|err| anyhow::anyhow!("{err}"))?; + let ohttp_relay_url = format!("http://127.0.0.1:{relay_port}"); + + Ok(Self { + directory_url, + ohttp_relay_url, + cert_der, + cert_path: cert_path.to_string_lossy().to_string(), + redis, + directory_handle, + relay_handle, + }) + } +} + +impl Drop for PayjoinRegtestServices { + fn drop(&mut self) { + self.directory_handle.abort(); + self.relay_handle.abort(); + let _ = self.redis.kill(); + } +} + +fn free_port() -> Result { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + Ok(listener.local_addr()?.port()) +} + +fn start_redis(port: u16, work_dir: &Path) -> Result { + Command::new("redis-server") + .arg("--bind") + .arg("127.0.0.1") + .arg("--port") + .arg(port.to_string()) + .arg("--save") + .arg("") + .arg("--appendonly") + .arg("no") + .arg("--dir") + .arg(work_dir) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .context("start redis-server for Payjoin regtest") +} + +async fn wait_for_tcp(host: &str, port: u16, child: &mut Child) -> Result<()> { + let addr = format!("{host}:{port}"); + for _ in 0..50 { + if TcpStream::connect(&addr).is_ok() { + return Ok(()); + } + if let Some(status) = child.try_wait()? { + bail!("redis-server exited before becoming ready: {status}"); + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + bail!("timed out waiting for redis-server on {addr}") +} diff --git a/crates/cdk-integration-tests/tests/onchain_regtest.rs b/crates/cdk-integration-tests/tests/onchain_regtest.rs index 1d74f3a50a..a1b1bd6b0d 100644 --- a/crates/cdk-integration-tests/tests/onchain_regtest.rs +++ b/crates/cdk-integration-tests/tests/onchain_regtest.rs @@ -8,14 +8,270 @@ use std::time::Duration; use bip39::Mnemonic; use cdk::amount::SplitTarget; +#[cfg(feature = "payjoin-regtest")] +use cdk::nuts::nut00::KnownMethod; use cdk::nuts::{CurrencyUnit, NotificationPayload, PaymentMethod, Proofs, ProofsMethods}; +#[cfg(feature = "payjoin-regtest")] +use cdk::nuts::{ + MeltQuoteOnchainRequest, MeltQuoteOnchainResponse, MintQuoteOnchainRequest, + MintQuoteOnchainResponse, SecretKey, +}; use cdk::wallet::{MeltOutcome, MintConnector, Wallet, WalletSubscription}; +#[cfg(feature = "payjoin-regtest")] +use cdk_common::payjoin::{format_bip21_amount_from_sats, payjoin_v2_to_bip77_endpoint}; use cdk_integration_tests::get_mint_url_from_env; +#[cfg(feature = "payjoin-regtest")] +use cdk_integration_tests::get_second_mint_url_from_env; use cdk_integration_tests::init_regtest::init_bitcoin_client; use cdk_sqlite::wallet::memory; use futures::StreamExt; use tokio::time::timeout; +#[cfg(feature = "payjoin-regtest")] +async fn request_payjoin_mint_quote( + mint_url: &str, +) -> anyhow::Result> { + let request = MintQuoteOnchainRequest { + unit: CurrencyUnit::Sat, + pubkey: SecretKey::generate().public_key(), + }; + let url = format!("{}/v1/mint/quote/onchain", mint_url.trim_end_matches('/')); + let response = reqwest::Client::new() + .post(url) + .json(&request) + .send() + .await?; + let response = response.error_for_status()?; + Ok(response.json().await?) +} + +#[cfg(feature = "payjoin-regtest")] +async fn fetch_onchain_mint_quote( + mint_url: &str, + quote_id: &str, +) -> anyhow::Result> { + let url = format!( + "{}/v1/mint/quote/onchain/{}", + mint_url.trim_end_matches('/'), + quote_id + ); + let response = reqwest::Client::new().get(url).send().await?; + let response = response.error_for_status()?; + Ok(response.json().await?) +} + +#[cfg(feature = "payjoin-regtest")] +async fn wait_for_onchain_mint_quote_amount_paid( + mint_url: &str, + quote_id: &str, + amount_sat: u64, +) -> MintQuoteOnchainResponse { + timeout(Duration::from_secs(60), async { + loop { + let quote = fetch_onchain_mint_quote(mint_url, quote_id) + .await + .expect("fetch onchain mint quote"); + if quote.amount_paid >= amount_sat.into() { + return quote; + } + tokio::time::sleep(Duration::from_secs(1)).await; + } + }) + .await + .expect("timeout waiting for onchain mint quote amount paid") +} + +#[cfg(feature = "payjoin-regtest")] +async fn request_payjoin_melt_quote( + mint_url: &str, + destination_quote: &MintQuoteOnchainResponse, + amount_sat: u64, +) -> anyhow::Result> { + let request = MeltQuoteOnchainRequest { + request: destination_quote.request.clone(), + unit: CurrencyUnit::Sat, + amount: amount_sat.into(), + payjoin: destination_quote.payjoin.clone(), + }; + let url = format!("{}/v1/melt/quote/onchain", mint_url.trim_end_matches('/')); + let response = reqwest::Client::new() + .post(url) + .json(&request) + .send() + .await?; + let response = response.error_for_status()?; + Ok(response.json().await?) +} + +#[cfg(feature = "payjoin-regtest")] +#[derive(Debug)] +struct NoopSenderPersister; + +#[cfg(feature = "payjoin-regtest")] +impl payjoin::persist::SessionPersister for NoopSenderPersister { + type InternalStorageError = std::io::Error; + type SessionEvent = payjoin::send::v2::SessionEvent; + + fn save_event(&self, _event: Self::SessionEvent) -> Result<(), Self::InternalStorageError> { + Ok(()) + } + + fn load( + &self, + ) -> Result>, Self::InternalStorageError> { + Ok(Box::new(std::iter::empty())) + } + + fn close(&self) -> Result<(), Self::InternalStorageError> { + Ok(()) + } +} + +#[cfg(feature = "payjoin-regtest")] +async fn payjoin_http_request(request: payjoin::Request) -> anyhow::Result> { + let response = reqwest::Client::new() + .post(request.url) + .header(reqwest::header::CONTENT_TYPE, request.content_type) + .body(request.body) + .send() + .await?; + let response = response.error_for_status()?; + Ok(response.bytes().await?.to_vec()) +} + +#[cfg(feature = "payjoin-regtest")] +async fn send_payjoin_with_bitcoin_core( + bitcoin_client: &cdk_integration_tests::ln_regtest::bitcoin_client::BitcoinClient, + quote: &MintQuoteOnchainResponse, + amount_sat: u64, +) -> anyhow::Result<()> { + use payjoin::bitcoin::FeeRate; + use payjoin::persist::OptionalTransitionOutcome; + use payjoin::UriExt; + + let payjoin = quote + .payjoin + .as_ref() + .expect("Payjoin-enabled mint quote should include Payjoin params"); + let ohttp_relay_url = std::env::var("CDK_MINTD_BDK_PAYJOIN_OHTTP_RELAY_URL") + .or_else(|_| std::env::var("CDK_REGTEST_PAYJOIN_OHTTP_RELAY_URL"))?; + + let bip21 = format!( + "bitcoin:{}?amount={}&pj={}", + quote.request, + format_bip21_amount_from_sats(amount_sat), + url::form_urlencoded::byte_serialize(payjoin_v2_to_bip77_endpoint(payjoin)?.as_bytes()) + .collect::() + ); + let pj_uri = payjoin::Uri::try_from(bip21.as_str()) + .map_err(|err| anyhow::anyhow!("{err}"))? + .assume_checked() + .check_pj_supported() + .map_err(|_| anyhow::anyhow!("Payjoin URI did not contain supported pj params"))?; + + let original_psbt = bitcoin_client.create_funded_psbt("e.request, amount_sat, 1)?; + let original_psbt = bitcoin_client.sign_psbt(&original_psbt)?; + let original_psbt = payjoin::bitcoin::Psbt::from_str(&original_psbt)?; + let fee_rate = FeeRate::from_sat_per_vb_u32(1); + let persister = NoopSenderPersister; + + let sender = payjoin::send::v2::SenderBuilder::new(original_psbt, pj_uri) + .build_recommended(fee_rate) + .map_err(|err| anyhow::anyhow!("{err}"))? + .save(&persister)?; + let (post_request, post_context) = sender.create_v2_post_request(&ohttp_relay_url)?; + let post_response = payjoin_http_request(post_request).await?; + let mut sender = sender + .process_response(&post_response, post_context) + .save(&persister)?; + + let proposal_psbt = timeout(Duration::from_secs(180), async { + loop { + let (get_request, get_context) = sender.create_poll_request(&ohttp_relay_url)?; + let get_response = payjoin_http_request(get_request).await?; + match sender + .process_response(&get_response, get_context) + .save(&persister)? + { + OptionalTransitionOutcome::Progress(psbt) => return anyhow::Ok(psbt), + OptionalTransitionOutcome::Stasis(next_sender) => { + sender = next_sender; + tokio::time::sleep(Duration::from_secs(1)).await; + } + } + } + }) + .await??; + + let signed_psbt = bitcoin_client.sign_psbt(&proposal_psbt.to_string())?; + bitcoin_client.finalize_and_broadcast_psbt(&signed_psbt)?; + + Ok(()) +} + +#[cfg(feature = "payjoin-regtest")] +async fn fund_wallet_with_onchain( + wallet: &Wallet, + bitcoin_client: &cdk_integration_tests::ln_regtest::bitcoin_client::BitcoinClient, + amount_sat: u64, +) -> anyhow::Result<()> { + let mint_quote = wallet + .mint_quote( + PaymentMethod::from_str("onchain")?, + Some(amount_sat.into()), + None, + None, + ) + .await?; + + bitcoin_client.send_to_address(&mint_quote.request, amount_sat)?; + let mine_addr = bitcoin_client.get_new_address()?; + bitcoin_client.generate_blocks(&mine_addr, 1)?; + + wallet + .wait_and_mint_quote( + mint_quote, + SplitTarget::default(), + None, + Duration::from_secs(60), + ) + .await?; + + Ok(()) +} + +#[cfg(feature = "payjoin-regtest")] +fn selected_onchain_melt_quote( + wallet: &Wallet, + response: &MeltQuoteOnchainResponse, +) -> anyhow::Result { + let fee_option = response + .fee_options + .iter() + .find(|option| option.fee_index == 1) + .or_else(|| response.fee_options.first()) + .copied() + .ok_or_else(|| anyhow::anyhow!("Payjoin melt quote did not include fee options"))?; + + Ok(cdk::wallet::MeltQuote { + id: response.quote.clone(), + mint_url: Some(wallet.mint_url.clone()), + unit: response.unit.clone(), + amount: response.amount, + request: response.request.clone(), + fee_reserve: fee_option.fee_reserve, + state: response.state, + expiry: response.expiry, + payment_proof: response.outpoint.clone(), + estimated_blocks: Some(fee_option.estimated_blocks), + fee_index: Some(fee_option.fee_index), + payjoin: response.payjoin.clone(), + payment_method: PaymentMethod::Known(KnownMethod::Onchain), + used_by_operation: None, + version: 0, + }) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn test_onchain_mint() { let bitcoin_client = init_bitcoin_client().expect("Failed to init bitcoin client"); @@ -103,6 +359,361 @@ async fn test_onchain_mint() { assert_eq!(wallet.total_balance().await.unwrap(), mint_amount.into()); } +#[cfg(feature = "payjoin-regtest")] +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_onchain_payjoin_mint() { + let bitcoin_client = init_bitcoin_client().expect("Failed to init bitcoin client"); + let mint_url = get_mint_url_from_env(); + let mint_amount = 10_000_u64; + let primer_wallet = Wallet::new( + &mint_url, + CurrencyUnit::Sat, + Arc::new(memory::empty().await.unwrap()), + Mnemonic::generate(12).unwrap().to_seed_normalized(""), + None, + ) + .expect("failed to create new wallet"); + + fund_wallet_with_onchain(&primer_wallet, &bitcoin_client, mint_amount) + .await + .expect("failed to prime receiver mint with an onchain UTXO"); + + let quote = request_payjoin_mint_quote(&mint_url) + .await + .expect("mint should create a Payjoin-enabled quote"); + quote + .payjoin + .as_ref() + .expect("Payjoin-enabled quote should include Payjoin params"); + + send_payjoin_with_bitcoin_core(&bitcoin_client, "e, mint_amount) + .await + .expect("Payjoin sender flow should complete"); + + let mine_addr = bitcoin_client + .get_new_address() + .expect("Failed to get address"); + bitcoin_client + .generate_blocks(&mine_addr, 1) + .expect("Failed to mine block"); + + let paid_quote = timeout(Duration::from_secs(60), async { + loop { + let quote = fetch_onchain_mint_quote(&mint_url, "e.quote) + .await + .expect("fetch onchain mint quote"); + if quote.amount_paid >= mint_amount.into() { + return quote; + } + tokio::time::sleep(Duration::from_secs(1)).await; + } + }) + .await + .expect("timeout waiting for Payjoin mint quote to be paid"); + + assert_eq!(paid_quote.quote, quote.quote); + assert!(paid_quote.amount_paid >= mint_amount.into()); +} + +#[cfg(feature = "payjoin-regtest")] +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_onchain_payjoin_mint_address_reuse_normal_then_payjoin_credits_cap() { + let bitcoin_client = init_bitcoin_client().expect("Failed to init bitcoin client"); + let mint_url = get_mint_url_from_env(); + let primer_wallet = Wallet::new( + &mint_url, + CurrencyUnit::Sat, + Arc::new(memory::empty().await.unwrap()), + Mnemonic::generate(12).unwrap().to_seed_normalized(""), + None, + ) + .expect("failed to create new wallet"); + + fund_wallet_with_onchain(&primer_wallet, &bitcoin_client, 10_000) + .await + .expect("failed to prime receiver mint with an onchain UTXO"); + + let normal_amount = 5_000_u64; + let payjoin_amount = 10_000_u64; + let quote = request_payjoin_mint_quote(&mint_url) + .await + .expect("mint should create a Payjoin-enabled quote"); + assert!( + quote.payjoin.is_some(), + "Payjoin-enabled quote should include Payjoin params" + ); + + bitcoin_client + .send_to_address("e.request, normal_amount) + .expect("Failed to send normal bitcoin payment"); + let mine_addr = bitcoin_client + .get_new_address() + .expect("Failed to get address"); + bitcoin_client + .generate_blocks(&mine_addr, 1) + .expect("Failed to mine normal payment"); + + let normal_paid = + wait_for_onchain_mint_quote_amount_paid(&mint_url, "e.quote, normal_amount).await; + assert_eq!(normal_paid.amount_paid, normal_amount.into()); + + send_payjoin_with_bitcoin_core(&bitcoin_client, "e, payjoin_amount) + .await + .expect("Payjoin sender flow should complete"); + bitcoin_client + .generate_blocks(&mine_addr, 1) + .expect("Failed to mine Payjoin payment"); + + let expected_amount_paid = normal_amount + payjoin_amount; + let paid_quote = + wait_for_onchain_mint_quote_amount_paid(&mint_url, "e.quote, expected_amount_paid) + .await; + + assert_eq!(paid_quote.quote, quote.quote); + assert_eq!(paid_quote.amount_paid, expected_amount_paid.into()); +} + +#[cfg(feature = "payjoin-regtest")] +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_onchain_payjoin_melt_between_mints() { + let bitcoin_client = init_bitcoin_client().expect("Failed to init bitcoin client"); + let payer_mint_url = get_mint_url_from_env(); + let receiver_mint_url = get_second_mint_url_from_env(); + let payer_wallet = Wallet::new( + &payer_mint_url, + CurrencyUnit::Sat, + Arc::new(memory::empty().await.unwrap()), + Mnemonic::generate(12).unwrap().to_seed_normalized(""), + None, + ) + .expect("failed to create payer wallet"); + let receiver_wallet = Wallet::new( + &receiver_mint_url, + CurrencyUnit::Sat, + Arc::new(memory::empty().await.unwrap()), + Mnemonic::generate(12).unwrap().to_seed_normalized(""), + None, + ) + .expect("failed to create receiver wallet"); + + fund_wallet_with_onchain(&payer_wallet, &bitcoin_client, 80_000) + .await + .expect("failed to fund payer wallet"); + fund_wallet_with_onchain(&receiver_wallet, &bitcoin_client, 10_000) + .await + .expect("failed to prime receiver mint with a Payjoin contribution UTXO"); + + let melt_amount = 20_000_u64; + let receiver_quote = request_payjoin_mint_quote(&receiver_mint_url) + .await + .expect("receiver mint should create a Payjoin-enabled quote"); + assert!( + receiver_quote.payjoin.is_some(), + "receiver quote should include Payjoin parameters" + ); + + let melt_quote_response = + request_payjoin_melt_quote(&payer_mint_url, &receiver_quote, melt_amount) + .await + .expect("payer mint should accept Payjoin melt quote"); + assert!( + melt_quote_response.payjoin.is_some(), + "melt quote should confirm Payjoin acceptance" + ); + + let melt_quote = selected_onchain_melt_quote(&payer_wallet, &melt_quote_response) + .expect("failed to select onchain melt quote"); + let melt_quote = payer_wallet + .select_onchain_melt_quote(melt_quote) + .await + .expect("failed to persist selected melt quote"); + let prepared = payer_wallet + .prepare_melt(&melt_quote.id, std::collections::HashMap::new()) + .await + .expect("failed to prepare Payjoin melt"); + let mine_addr = bitcoin_client + .get_new_address() + .expect("Failed to get address"); + + let melt_result = timeout(Duration::from_secs(180), async { + let confirm_future = prepared.confirm(); + tokio::pin!(confirm_future); + loop { + tokio::select! { + res = &mut confirm_future => { + return res.expect("failed to confirm Payjoin melt"); + } + _ = tokio::time::sleep(Duration::from_secs(1)) => { + bitcoin_client.generate_blocks(&mine_addr, 1).unwrap(); + } + } + } + }) + .await + .expect("timeout waiting for Payjoin melt confirmation"); + + assert_eq!(melt_result.state(), cdk::nuts::MeltQuoteState::Paid); + + let paid_receiver_quote = timeout(Duration::from_secs(60), async { + loop { + let quote = fetch_onchain_mint_quote(&receiver_mint_url, &receiver_quote.quote) + .await + .expect("fetch receiver onchain mint quote"); + if quote.amount_paid >= melt_amount.into() { + return quote; + } + tokio::time::sleep(Duration::from_secs(1)).await; + } + }) + .await + .expect("timeout waiting for receiver Payjoin mint quote to be paid"); + + assert_eq!(paid_receiver_quote.quote, receiver_quote.quote); + assert!(paid_receiver_quote.amount_paid >= melt_amount.into()); +} + +/// Drives a Payjoin melt through the async (poller-driven) send path and proves +/// the broadcast transaction actually batches a receiver-contributed input. +/// +/// With the asynchronous send design, `make_payment` returns `Pending` +/// immediately and the mint's background poller posts the original PSBT, +/// receives the receiver mint's Payjoin proposal, and broadcasts the combined +/// transaction. We capture that transaction from the mempool *before* mining it +/// and assert it has at least two inputs: the payer mint's input plus at least +/// one input contributed by the receiver mint (the defining property of +/// Payjoin). A non-Payjoin fallback send from the payer mint would spend only +/// its own input(s). +#[cfg(feature = "payjoin-regtest")] +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_onchain_payjoin_melt_batches_sender_and_receiver_inputs() { + let bitcoin_client = init_bitcoin_client().expect("Failed to init bitcoin client"); + let payer_mint_url = get_mint_url_from_env(); + let receiver_mint_url = get_second_mint_url_from_env(); + let payer_wallet = Wallet::new( + &payer_mint_url, + CurrencyUnit::Sat, + Arc::new(memory::empty().await.unwrap()), + Mnemonic::generate(12).unwrap().to_seed_normalized(""), + None, + ) + .expect("failed to create payer wallet"); + let receiver_wallet = Wallet::new( + &receiver_mint_url, + CurrencyUnit::Sat, + Arc::new(memory::empty().await.unwrap()), + Mnemonic::generate(12).unwrap().to_seed_normalized(""), + None, + ) + .expect("failed to create receiver wallet"); + + fund_wallet_with_onchain(&payer_wallet, &bitcoin_client, 80_000) + .await + .expect("failed to fund payer wallet"); + fund_wallet_with_onchain(&receiver_wallet, &bitcoin_client, 10_000) + .await + .expect("failed to prime receiver mint with a Payjoin contribution UTXO"); + + let melt_amount = 20_000_u64; + let receiver_quote = request_payjoin_mint_quote(&receiver_mint_url) + .await + .expect("receiver mint should create a Payjoin-enabled quote"); + assert!( + receiver_quote.payjoin.is_some(), + "receiver quote should include Payjoin parameters" + ); + + let melt_quote_response = + request_payjoin_melt_quote(&payer_mint_url, &receiver_quote, melt_amount) + .await + .expect("payer mint should accept Payjoin melt quote"); + assert!( + melt_quote_response.payjoin.is_some(), + "melt quote should confirm Payjoin acceptance" + ); + + let melt_quote = selected_onchain_melt_quote(&payer_wallet, &melt_quote_response) + .expect("failed to select onchain melt quote"); + let melt_quote = payer_wallet + .select_onchain_melt_quote(melt_quote) + .await + .expect("failed to persist selected melt quote"); + let prepared = payer_wallet + .prepare_melt(&melt_quote.id, std::collections::HashMap::new()) + .await + .expect("failed to prepare Payjoin melt"); + + // The onchain melt is asynchronous: `make_payment` returns immediately and + // the background poller drives the Payjoin negotiation + broadcast. + let outcome = prepared + .confirm_prefer_async() + .await + .expect("failed to confirm Payjoin melt"); + let pending = match outcome { + MeltOutcome::Pending(pending) => pending, + MeltOutcome::Paid(_) => { + panic!("onchain Payjoin melt must be pending, not immediately paid") + } + }; + + // Capture the combined transaction from the mempool before mining it, and + // assert it batches a receiver-contributed input. + let input_count = timeout(Duration::from_secs(180), async { + loop { + if let Some(count) = bitcoin_client + .mempool_tx_input_count_to_address(&receiver_quote.request) + .expect("inspect mempool") + { + return count; + } + tokio::time::sleep(Duration::from_secs(1)).await; + } + }) + .await + .expect("timeout waiting for the Payjoin transaction to reach the mempool"); + + assert!( + input_count >= 2, + "Payjoin transaction must batch sender and receiver inputs, got {input_count} input(s)" + ); + + // Mine until the melt finalizes. + let mine_addr = bitcoin_client + .get_new_address() + .expect("Failed to get address"); + let finalized = timeout(Duration::from_secs(120), async { + let mut finalized_future = Box::pin(std::future::IntoFuture::into_future(pending)); + loop { + tokio::select! { + res = &mut finalized_future => break res.expect("failed to finalize Payjoin melt"), + _ = tokio::time::sleep(Duration::from_secs(1)) => { + bitcoin_client.generate_blocks(&mine_addr, 1).unwrap(); + } + } + } + }) + .await + .expect("timeout waiting for Payjoin melt to finalize"); + assert_eq!(finalized.state(), cdk::nuts::MeltQuoteState::Paid); + + // The receiver mint must be credited the melt amount. + let paid_receiver_quote = timeout(Duration::from_secs(60), async { + loop { + let quote = fetch_onchain_mint_quote(&receiver_mint_url, &receiver_quote.quote) + .await + .expect("fetch receiver onchain mint quote"); + if quote.amount_paid >= melt_amount.into() { + return quote; + } + tokio::time::sleep(Duration::from_secs(1)).await; + } + }) + .await + .expect("timeout waiting for receiver Payjoin mint quote to be paid"); + + assert_eq!(paid_receiver_quote.quote, receiver_quote.quote); + assert!(paid_receiver_quote.amount_paid >= melt_amount.into()); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn test_onchain_melt() { let bitcoin_client = init_bitcoin_client().expect("Failed to init bitcoin client"); diff --git a/crates/cdk-mintd/Cargo.toml b/crates/cdk-mintd/Cargo.toml index ab10df795c..0f588ee215 100644 --- a/crates/cdk-mintd/Cargo.toml +++ b/crates/cdk-mintd/Cargo.toml @@ -23,6 +23,7 @@ lnbits = ["dep:cdk-lnbits"] fakewallet = ["dep:cdk-fake-wallet"] ldk-node = ["dep:cdk-ldk-node"] bdk = ["dep:cdk-bdk", "cdk-bdk/bitcoin-rpc", "cdk-bdk/esplora"] +payjoin-local-https = ["bdk", "cdk-bdk/payjoin-local-https"] grpc-processor = ["dep:cdk-payment-processor", "cdk-signatory/grpc"] sqlcipher = ["sqlite", "cdk-sqlite/sqlcipher"] redis = ["cdk-axum/redis"] diff --git a/crates/cdk-mintd/src/config.rs b/crates/cdk-mintd/src/config.rs index 310fc52570..370336c81d 100644 --- a/crates/cdk-mintd/src/config.rs +++ b/crates/cdk-mintd/src/config.rs @@ -365,6 +365,18 @@ pub struct Bdk { /// Wallet sync interval in seconds #[serde(default = "default_bdk_sync_interval_secs")] pub sync_interval_secs: u64, + /// Payjoin v2 directory URL. + #[serde(default)] + pub payjoin_directory_url: Option, + /// Payjoin v2 OHTTP relay URL. + #[serde(default)] + pub payjoin_ohttp_relay_url: Option, + /// Payjoin v2 session expiry in seconds. + #[serde(default = "default_bdk_payjoin_expiry_secs")] + pub payjoin_expiry_secs: u64, + /// DER-encoded localhost TLS certificate path for regtest-only Payjoin services. + #[serde(default)] + pub payjoin_local_tls_cert_path: Option, } #[cfg(feature = "bdk")] @@ -387,6 +399,10 @@ impl Default for Bdk { min_receive_amount_sat: default_bdk_min_receive_amount_sat(), min_send_amount_sat: default_bdk_min_send_amount_sat(), sync_interval_secs: default_bdk_sync_interval_secs(), + payjoin_directory_url: None, + payjoin_ohttp_relay_url: None, + payjoin_expiry_secs: default_bdk_payjoin_expiry_secs(), + payjoin_local_tls_cert_path: None, } } } @@ -414,6 +430,43 @@ impl Bdk { validate_bdk_fee_options(&self.batch_config.fee_options)?; + match ( + self.payjoin_directory_url.as_ref(), + self.payjoin_ohttp_relay_url.as_ref(), + ) { + (Some(directory), Some(relay)) => { + cdk_bdk::PayjoinConfig::new( + directory.clone(), + relay.clone(), + Some(self.payjoin_expiry_secs), + )?; + } + (None, None) => {} + _ => { + return Err( + "BDK Payjoin requires both payjoin_directory_url and payjoin_ohttp_relay_url" + .to_string(), + ); + } + } + + if self.payjoin_local_tls_cert_path.is_some() { + #[cfg(not(feature = "payjoin-local-https"))] + { + return Err( + "BDK Payjoin local TLS cert path requires the payjoin-local-https feature" + .to_string(), + ); + } + #[cfg(feature = "payjoin-local-https")] + if self.payjoin_directory_url.is_none() || self.payjoin_ohttp_relay_url.is_none() { + return Err( + "BDK Payjoin local TLS cert path requires Payjoin directory and OHTTP relay URLs" + .to_string(), + ); + } + } + Ok(()) } } @@ -438,6 +491,11 @@ fn default_bdk_sync_interval_secs() -> u64 { 30 } +#[cfg(feature = "bdk")] +fn default_bdk_payjoin_expiry_secs() -> u64 { + cdk_bdk::DEFAULT_PAYJOIN_EXPIRY_SECS +} + #[cfg(feature = "bdk")] fn default_bdk_esplora_parallel_requests() -> usize { 1 @@ -1284,6 +1342,37 @@ mod tests { assert_eq!(Bdk::default().min_send_amount_sat, 546); } + #[cfg(all(feature = "bdk", not(feature = "payjoin-local-https")))] + #[test] + fn test_bdk_rejects_local_payjoin_tls_cert_without_feature() { + let config = Bdk { + payjoin_directory_url: Some("https://localhost:1234".to_string()), + payjoin_ohttp_relay_url: Some("http://127.0.0.1:5678".to_string()), + payjoin_local_tls_cert_path: Some("/tmp/payjoin-local.der".to_string()), + ..Default::default() + }; + + let err = config + .validate() + .expect_err("local TLS cert path should require payjoin-local-https"); + assert!(err.contains("payjoin-local-https")); + } + + #[cfg(feature = "payjoin-local-https")] + #[test] + fn test_bdk_accepts_local_payjoin_tls_cert_with_feature() { + let config = Bdk { + payjoin_directory_url: Some("https://localhost:1234".to_string()), + payjoin_ohttp_relay_url: Some("http://127.0.0.1:5678".to_string()), + payjoin_local_tls_cert_path: Some("/tmp/payjoin-local.der".to_string()), + ..Default::default() + }; + + config + .validate() + .expect("local TLS cert path should be accepted with payjoin-local-https"); + } + #[cfg(feature = "bdk")] #[test] fn test_bdk_config_min_send_amount_sat_override() { diff --git a/crates/cdk-mintd/src/env_vars/bdk.rs b/crates/cdk-mintd/src/env_vars/bdk.rs index ffb9142872..2e01efdcf9 100644 --- a/crates/cdk-mintd/src/env_vars/bdk.rs +++ b/crates/cdk-mintd/src/env_vars/bdk.rs @@ -32,6 +32,11 @@ pub const BDK_FEE_CACHE_TTL_SECS_ENV_VAR: &str = "CDK_MINTD_BDK_FEE_CACHE_TTL_SE pub const BDK_QUOTE_MAX_INPUT_COUNT_ENV_VAR: &str = "CDK_MINTD_BDK_QUOTE_MAX_INPUT_COUNT"; pub const BDK_QUOTE_FIXED_SAFETY_SAT_ENV_VAR: &str = "CDK_MINTD_BDK_QUOTE_FIXED_SAFETY_SAT"; pub const BDK_QUOTE_SAFETY_MULTIPLIER_ENV_VAR: &str = "CDK_MINTD_BDK_QUOTE_SAFETY_MULTIPLIER"; +pub const BDK_PAYJOIN_DIRECTORY_URL_ENV_VAR: &str = "CDK_MINTD_BDK_PAYJOIN_DIRECTORY_URL"; +pub const BDK_PAYJOIN_OHTTP_RELAY_URL_ENV_VAR: &str = "CDK_MINTD_BDK_PAYJOIN_OHTTP_RELAY_URL"; +pub const BDK_PAYJOIN_EXPIRY_SECS_ENV_VAR: &str = "CDK_MINTD_BDK_PAYJOIN_EXPIRY_SECS"; +pub const BDK_PAYJOIN_LOCAL_TLS_CERT_PATH_ENV_VAR: &str = + "CDK_MINTD_BDK_PAYJOIN_LOCAL_TLS_CERT_PATH"; impl Bdk { pub fn from_env(mut self) -> Self { @@ -178,6 +183,31 @@ impl Bdk { } } + if let Ok(directory_url) = env::var(BDK_PAYJOIN_DIRECTORY_URL_ENV_VAR) { + self.payjoin_directory_url = Some(directory_url); + } + + if let Ok(relay_url) = env::var(BDK_PAYJOIN_OHTTP_RELAY_URL_ENV_VAR) { + self.payjoin_ohttp_relay_url = Some(relay_url); + } + + if let Ok(expiry_secs) = env::var(BDK_PAYJOIN_EXPIRY_SECS_ENV_VAR) { + match expiry_secs.parse::() { + Ok(expiry_secs) => { + self.payjoin_expiry_secs = expiry_secs; + } + Err(err) => { + tracing::warn!( + "{BDK_PAYJOIN_EXPIRY_SECS_ENV_VAR} has invalid value '{expiry_secs}': {err}" + ); + } + } + } + + if let Ok(cert_path) = env::var(BDK_PAYJOIN_LOCAL_TLS_CERT_PATH_ENV_VAR) { + self.payjoin_local_tls_cert_path = Some(cert_path); + } + self } } diff --git a/crates/cdk-mintd/src/setup.rs b/crates/cdk-mintd/src/setup.rs index 2eb2eee059..099b89c98e 100644 --- a/crates/cdk-mintd/src/setup.rs +++ b/crates/cdk-mintd/src/setup.rs @@ -602,6 +602,27 @@ impl OnchainBackendSetup for crate::config::Bdk { .as_ref() .map(|onchain| onchain.min_mint.to_u64().max(self.min_receive_amount_sat)) .unwrap_or(self.min_receive_amount_sat); + let payjoin_config = match ( + self.payjoin_directory_url.as_ref(), + self.payjoin_ohttp_relay_url.as_ref(), + ) { + (Some(directory), Some(relay)) => { + let config = cdk_bdk::PayjoinConfig::new( + directory.clone(), + relay.clone(), + Some(self.payjoin_expiry_secs), + ) + .map_err(anyhow::Error::msg)?; + #[cfg(feature = "payjoin-local-https")] + let config = if let Some(cert_path) = self.payjoin_local_tls_cert_path.as_ref() { + config.with_local_tls_cert_der(std::fs::read(cert_path)?) + } else { + config + }; + Some(config) + } + _ => None, + }; let bdk = cdk_bdk::CdkBdk::new( mnemonic, @@ -617,6 +638,7 @@ impl OnchainBackendSetup for crate::config::Bdk { self.sync_interval_secs, None, None, + payjoin_config, )?; Ok(bdk) diff --git a/crates/cdk-npubcash/src/types.rs b/crates/cdk-npubcash/src/types.rs index d6056252d0..10b3cf65ee 100644 --- a/crates/cdk-npubcash/src/types.rs +++ b/crates/cdk-npubcash/src/types.rs @@ -169,6 +169,7 @@ impl From for MintQuote { Amount::ZERO }, estimated_blocks: None, + payjoin: None, used_by_operation: None, version: 0, } diff --git a/crates/cdk-sql-common/src/wallet/migrations/postgres/20260607000000_add_payjoin_to_mint_quote.sql b/crates/cdk-sql-common/src/wallet/migrations/postgres/20260607000000_add_payjoin_to_mint_quote.sql new file mode 100644 index 0000000000..d77b02bad0 --- /dev/null +++ b/crates/cdk-sql-common/src/wallet/migrations/postgres/20260607000000_add_payjoin_to_mint_quote.sql @@ -0,0 +1,2 @@ +ALTER TABLE mint_quote ADD COLUMN IF NOT EXISTS payjoin JSONB; +ALTER TABLE melt_quote ADD COLUMN IF NOT EXISTS payjoin JSONB; diff --git a/crates/cdk-sql-common/src/wallet/migrations/sqlite/20260607000000_add_payjoin_to_mint_quote.sql b/crates/cdk-sql-common/src/wallet/migrations/sqlite/20260607000000_add_payjoin_to_mint_quote.sql new file mode 100644 index 0000000000..7953864178 --- /dev/null +++ b/crates/cdk-sql-common/src/wallet/migrations/sqlite/20260607000000_add_payjoin_to_mint_quote.sql @@ -0,0 +1,2 @@ +ALTER TABLE mint_quote ADD COLUMN payjoin TEXT; +ALTER TABLE melt_quote ADD COLUMN payjoin TEXT; diff --git a/crates/cdk-sql-common/src/wallet/mod.rs b/crates/cdk-sql-common/src/wallet/mod.rs index 5af04887d6..e86fadb595 100644 --- a/crates/cdk-sql-common/src/wallet/mod.rs +++ b/crates/cdk-sql-common/src/wallet/mod.rs @@ -9,7 +9,7 @@ use async_trait::async_trait; use bitcoin::bip32::DerivationPath; use cdk_common::database::{ConversionError, Error, WalletDatabase}; use cdk_common::mint_url::MintUrl; -use cdk_common::nuts::{MeltQuoteState, MintQuoteState}; +use cdk_common::nuts::{MeltQuoteState, MintQuoteState, PayjoinV2}; use cdk_common::secret::Secret; use cdk_common::util::unix_time; use cdk_common::wallet::{ @@ -170,6 +170,7 @@ where payment_method, estimated_blocks, fee_index, + CAST(payjoin AS TEXT) AS payjoin, used_by_operation, version, mint_url @@ -348,6 +349,7 @@ where amount_issued, amount_paid, estimated_blocks, + CAST(payjoin AS TEXT) AS payjoin, used_by_operation, version FROM @@ -385,6 +387,7 @@ where amount_issued, amount_paid, estimated_blocks, + CAST(payjoin AS TEXT) AS payjoin, used_by_operation, version FROM @@ -420,6 +423,7 @@ where amount_issued, amount_paid, estimated_blocks, + CAST(payjoin AS TEXT) AS payjoin, used_by_operation, version FROM @@ -461,6 +465,7 @@ where payment_method, estimated_blocks, fee_index, + CAST(payjoin AS TEXT) AS payjoin, used_by_operation, version, mint_url @@ -1180,13 +1185,19 @@ where let expected_version = quote.version; let new_version = expected_version.wrapping_add(1); + let payjoin = quote + .payjoin + .as_ref() + .map(serde_json::to_string) + .transpose() + .map_err(Error::from)?; - let rows_affected = query( - r#" + let query_str = if RM::Connection::name() == "postgres" { + r#" INSERT INTO mint_quote - (id, mint_url, amount, unit, request, state, expiry, secret_key, payment_method, amount_issued, amount_paid, estimated_blocks, version, used_by_operation) + (id, mint_url, amount, unit, request, state, expiry, secret_key, payment_method, amount_issued, amount_paid, estimated_blocks, payjoin, version, used_by_operation) VALUES - (:id, :mint_url, :amount, :unit, :request, :state, :expiry, :secret_key, :payment_method, :amount_issued, :amount_paid, :estimated_blocks, :version, :used_by_operation) + (:id, :mint_url, :amount, :unit, :request, :state, :expiry, :secret_key, :payment_method, :amount_issued, :amount_paid, :estimated_blocks, (:payjoin)::jsonb, :version, :used_by_operation) ON CONFLICT(id) DO UPDATE SET mint_url = excluded.mint_url, amount = excluded.amount, @@ -1199,12 +1210,39 @@ where amount_issued = excluded.amount_issued, amount_paid = excluded.amount_paid, estimated_blocks = excluded.estimated_blocks, + payjoin = excluded.payjoin, version = :new_version, used_by_operation = excluded.used_by_operation WHERE mint_quote.version = :expected_version ; - "#, - )? + "# + } else { + r#" + INSERT INTO mint_quote + (id, mint_url, amount, unit, request, state, expiry, secret_key, payment_method, amount_issued, amount_paid, estimated_blocks, payjoin, version, used_by_operation) + VALUES + (:id, :mint_url, :amount, :unit, :request, :state, :expiry, :secret_key, :payment_method, :amount_issued, :amount_paid, :estimated_blocks, :payjoin, :version, :used_by_operation) + ON CONFLICT(id) DO UPDATE SET + mint_url = excluded.mint_url, + amount = excluded.amount, + unit = excluded.unit, + request = excluded.request, + state = excluded.state, + expiry = excluded.expiry, + secret_key = excluded.secret_key, + payment_method = excluded.payment_method, + amount_issued = excluded.amount_issued, + amount_paid = excluded.amount_paid, + estimated_blocks = excluded.estimated_blocks, + payjoin = excluded.payjoin, + version = :new_version, + used_by_operation = excluded.used_by_operation + WHERE mint_quote.version = :expected_version + ; + "# + }; + + let rows_affected = query(query_str)? .bind("id", quote.id.to_string()) .bind("mint_url", quote.mint_url.to_string()) .bind("amount", quote.amount.map(|a| a.to_i64())) @@ -1217,11 +1255,13 @@ where .bind("amount_issued", quote.amount_issued.to_i64()) .bind("amount_paid", quote.amount_paid.to_i64()) .bind("estimated_blocks", quote.estimated_blocks.map(i64::from)) + .bind("payjoin", payjoin) .bind("version", quote.version as i64) .bind("new_version", new_version as i64) .bind("expected_version", expected_version as i64) .bind("used_by_operation", quote.used_by_operation) - .execute(&*conn).await?; + .execute(&*conn) + .await?; if rows_affected == 0 { return Err(database::Error::ConcurrentUpdate); @@ -1256,13 +1296,19 @@ where let expected_version = quote.version; let new_version = expected_version.wrapping_add(1); + let payjoin = quote + .payjoin + .as_ref() + .map(serde_json::to_string) + .transpose() + .map_err(Error::from)?; - let rows_affected = query( + let query_str = if RM::Connection::name() == "postgres" { r#" INSERT INTO melt_quote - (id, unit, amount, request, fee_reserve, state, expiry, payment_method, estimated_blocks, fee_index, version, mint_url, used_by_operation) + (id, unit, amount, request, fee_reserve, state, expiry, payment_method, estimated_blocks, fee_index, payjoin, version, mint_url, used_by_operation) VALUES - (:id, :unit, :amount, :request, :fee_reserve, :state, :expiry, :payment_method, :estimated_blocks, :fee_index, :version, :mint_url, :used_by_operation) + (:id, :unit, :amount, :request, :fee_reserve, :state, :expiry, :payment_method, :estimated_blocks, :fee_index, (:payjoin)::jsonb, :version, :mint_url, :used_by_operation) ON CONFLICT(id) DO UPDATE SET unit = excluded.unit, amount = excluded.amount, @@ -1273,30 +1319,57 @@ where payment_method = excluded.payment_method, estimated_blocks = excluded.estimated_blocks, fee_index = excluded.fee_index, + payjoin = excluded.payjoin, version = :new_version, mint_url = excluded.mint_url, used_by_operation = excluded.used_by_operation WHERE melt_quote.version = :expected_version ; - "#, - )? - .bind("id", quote.id.to_string()) - .bind("unit", quote.unit.to_string()) - .bind("amount", u64::from(quote.amount) as i64) - .bind("request", quote.request) - .bind("fee_reserve", u64::from(quote.fee_reserve) as i64) - .bind("state", quote.state.to_string()) - .bind("expiry", quote.expiry as i64) - .bind("payment_method", quote.payment_method.to_string()) - .bind("estimated_blocks", quote.estimated_blocks.map(i64::from)) - .bind("fee_index", quote.fee_index.map(i64::from)) - .bind("version", quote.version as i64) - .bind("new_version", new_version as i64) - .bind("expected_version", expected_version as i64) - .bind("mint_url", quote.mint_url.map(|m| m.to_string())) - .bind("used_by_operation", quote.used_by_operation) - .execute(&*conn) - .await?; + "# + } else { + r#" + INSERT INTO melt_quote + (id, unit, amount, request, fee_reserve, state, expiry, payment_method, estimated_blocks, fee_index, payjoin, version, mint_url, used_by_operation) + VALUES + (:id, :unit, :amount, :request, :fee_reserve, :state, :expiry, :payment_method, :estimated_blocks, :fee_index, :payjoin, :version, :mint_url, :used_by_operation) + ON CONFLICT(id) DO UPDATE SET + unit = excluded.unit, + amount = excluded.amount, + request = excluded.request, + fee_reserve = excluded.fee_reserve, + state = excluded.state, + expiry = excluded.expiry, + payment_method = excluded.payment_method, + estimated_blocks = excluded.estimated_blocks, + fee_index = excluded.fee_index, + payjoin = excluded.payjoin, + version = :new_version, + mint_url = excluded.mint_url, + used_by_operation = excluded.used_by_operation + WHERE melt_quote.version = :expected_version + ; + "# + }; + + let rows_affected = query(query_str)? + .bind("id", quote.id.to_string()) + .bind("unit", quote.unit.to_string()) + .bind("amount", u64::from(quote.amount) as i64) + .bind("request", quote.request) + .bind("fee_reserve", u64::from(quote.fee_reserve) as i64) + .bind("state", quote.state.to_string()) + .bind("expiry", quote.expiry as i64) + .bind("payment_method", quote.payment_method.to_string()) + .bind("estimated_blocks", quote.estimated_blocks.map(i64::from)) + .bind("fee_index", quote.fee_index.map(i64::from)) + .bind("payjoin", payjoin) + .bind("version", quote.version as i64) + .bind("new_version", new_version as i64) + .bind("expected_version", expected_version as i64) + .bind("mint_url", quote.mint_url.map(|m| m.to_string())) + .bind("used_by_operation", quote.used_by_operation) + .execute(&*conn) + .await?; if rows_affected == 0 { return Err(database::Error::ConcurrentUpdate); @@ -2002,6 +2075,7 @@ fn sql_row_to_mint_quote(row: Vec) -> Result { row_amount_minted, row_amount_paid, estimated_blocks, + payjoin, used_by_operation, version ) = row @@ -2015,6 +2089,10 @@ fn sql_row_to_mint_quote(row: Vec) -> Result { let version_val: u32 = column_as_number!(version); let payment_method = PaymentMethod::from_str(&column_as_string!(row_method)).map_err(Error::from)?; + let payjoin: Option = column_as_nullable_string!(payjoin) + .map(|value| serde_json::from_str(&value)) + .transpose() + .map_err(Error::from)?; Ok(MintQuote { id: column_as_string!(id), @@ -2029,6 +2107,7 @@ fn sql_row_to_mint_quote(row: Vec) -> Result { amount_issued: Amount::from(amount_minted), amount_paid: Amount::from(amount_paid), estimated_blocks: column_as_nullable_number!(estimated_blocks), + payjoin, used_by_operation: column_as_nullable_string!(used_by_operation), version: version_val, }) @@ -2048,6 +2127,7 @@ fn sql_row_to_melt_quote(row: Vec) -> Result { row_method, estimated_blocks, fee_index, + payjoin, used_by_operation, version, mint_url @@ -2061,6 +2141,10 @@ fn sql_row_to_melt_quote(row: Vec) -> Result { let fee_reserve_val: u64 = column_as_number!(fee_reserve); let expiry_val: u64 = column_as_number!(expiry); let version_val: u32 = column_as_number!(version); + let payjoin: Option = column_as_nullable_string!(payjoin) + .map(|value| serde_json::from_str(&value)) + .transpose() + .map_err(Error::from)?; Ok(wallet::MeltQuote { id: column_as_string!(id), @@ -2074,6 +2158,7 @@ fn sql_row_to_melt_quote(row: Vec) -> Result { payment_proof: column_as_nullable_string!(payment_proof), estimated_blocks: column_as_nullable_number!(estimated_blocks), fee_index: column_as_nullable_number!(fee_index), + payjoin, payment_method, used_by_operation: column_as_nullable_string!(used_by_operation), version: version_val, diff --git a/crates/cdk-sqlite/src/wallet/mod.rs b/crates/cdk-sqlite/src/wallet/mod.rs index 66ebab87bd..e68c3e609f 100644 --- a/crates/cdk-sqlite/src/wallet/mod.rs +++ b/crates/cdk-sqlite/src/wallet/mod.rs @@ -178,6 +178,7 @@ mod tests { amount_issued: Amount::from(0), amount_paid: Amount::from(0), estimated_blocks: None, + payjoin: None, used_by_operation: None, version: 0, }; @@ -323,6 +324,7 @@ mod tests { amount_issued: Amount::from(100), amount_paid: Amount::from(100), estimated_blocks: None, + payjoin: None, used_by_operation: None, version: 0, }; @@ -341,6 +343,7 @@ mod tests { amount_issued: Amount::from(0), amount_paid: Amount::from(100), estimated_blocks: None, + payjoin: None, used_by_operation: None, version: 0, }; @@ -359,6 +362,7 @@ mod tests { amount_issued: Amount::from(0), amount_paid: Amount::from(0), estimated_blocks: None, + payjoin: None, used_by_operation: None, version: 0, }; @@ -377,6 +381,7 @@ mod tests { amount_issued: Amount::from(0), amount_paid: Amount::from(0), estimated_blocks: None, + payjoin: None, used_by_operation: None, version: 0, }; diff --git a/crates/cdk-supabase/migrations/supabase/migrations/20260607000000_add_payjoin_to_mint_quote.sql b/crates/cdk-supabase/migrations/supabase/migrations/20260607000000_add_payjoin_to_mint_quote.sql new file mode 100644 index 0000000000..e86e236fe7 --- /dev/null +++ b/crates/cdk-supabase/migrations/supabase/migrations/20260607000000_add_payjoin_to_mint_quote.sql @@ -0,0 +1,6 @@ +ALTER TABLE mint_quote ADD COLUMN IF NOT EXISTS payjoin JSONB; +ALTER TABLE melt_quote ADD COLUMN IF NOT EXISTS payjoin JSONB; + +-- Bump schema version +INSERT INTO schema_info (key, value) VALUES ('schema_version', '7') +ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value; diff --git a/crates/cdk-supabase/src/wallet.rs b/crates/cdk-supabase/src/wallet.rs index 26baa357af..6550043b7a 100644 --- a/crates/cdk-supabase/src/wallet.rs +++ b/crates/cdk-supabase/src/wallet.rs @@ -16,7 +16,8 @@ use cdk_common::database::wallet::Database; use cdk_common::database::{Error as DatabaseError, KVStoreDatabase}; use cdk_common::mint_url::MintUrl; use cdk_common::nuts::{ - CurrencyUnit, Id, KeySet, KeySetInfo, Keys, MintInfo, PublicKey, SpendingConditions, State, + CurrencyUnit, Id, KeySet, KeySetInfo, Keys, MintInfo, PayjoinV2, PublicKey, SpendingConditions, + State, }; use cdk_common::secret::Secret; use cdk_common::util::hex; @@ -2568,6 +2569,8 @@ struct MintQuoteTable { amount_issued: i64, amount_paid: i64, #[serde(default)] + payjoin: Option, + #[serde(default)] used_by_operation: Option, #[serde(default)] version: Option, @@ -2602,6 +2605,7 @@ impl TryInto for MintQuoteTable { amount_issued: cdk_common::Amount::from(self.amount_issued as u64), amount_paid: cdk_common::Amount::from(self.amount_paid as u64), estimated_blocks: None, + payjoin: self.payjoin, used_by_operation: self.used_by_operation, version: self.version.unwrap_or(0) as u32, }) @@ -2623,6 +2627,7 @@ impl TryFrom for MintQuoteTable { payment_method: q.payment_method.to_string(), amount_issued: q.amount_issued.to_u64() as i64, amount_paid: q.amount_paid.to_u64() as i64, + payjoin: q.payjoin, used_by_operation: q.used_by_operation, version: Some(q.version as i32), _extra: Default::default(), @@ -2646,6 +2651,8 @@ struct MeltQuoteTable { #[serde(default)] fee_index: Option, #[serde(default)] + payjoin: Option, + #[serde(default)] mint_url: Option, #[serde(default)] used_by_operation: Option, @@ -2688,6 +2695,7 @@ impl TryInto for MeltQuoteTable { .map(u32::try_from) .transpose() .map_err(|_| DatabaseError::Internal("Invalid fee_index".into()))?, + payjoin: self.payjoin, used_by_operation: self.used_by_operation, version: self.version.unwrap_or(0) as u32, }) @@ -2710,6 +2718,7 @@ impl TryFrom for MeltQuoteTable { payment_method: q.payment_method.to_string(), estimated_blocks: q.estimated_blocks.map(i64::from), fee_index: q.fee_index.map(i64::from), + payjoin: q.payjoin, used_by_operation: q.used_by_operation, version: Some(q.version as i32), _extra: Default::default(), diff --git a/crates/cdk/src/event.rs b/crates/cdk/src/event.rs index 25a33e0ea8..b868f79989 100644 --- a/crates/cdk/src/event.rs +++ b/crates/cdk/src/event.rs @@ -10,11 +10,11 @@ use cdk_common::{ MintQuoteBolt12Response, MintQuoteOnchainResponse, NotificationPayload, ProofState, }; use serde::de::DeserializeOwned; -use serde::{Deserialize, Serialize}; +use serde::Serialize; /// Simple wrapper over `NotificationPayload` which is a foreign type -#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] -#[serde(bound = "T: Serialize + DeserializeOwned")] +#[derive(Debug, Clone, Eq, PartialEq, Serialize)] +#[serde(bound(serialize = "T: Serialize + DeserializeOwned"))] pub struct MintEvent(NotificationPayload) where T: Clone + Eq + PartialEq; diff --git a/crates/cdk/src/mint/issue/mod.rs b/crates/cdk/src/mint/issue/mod.rs index 1707c28008..8a25fa9235 100644 --- a/crates/cdk/src/mint/issue/mod.rs +++ b/crates/cdk/src/mint/issue/mod.rs @@ -220,7 +220,6 @@ impl Mint { let ln = self.get_payment_processor(unit.clone(), payment_method.clone())?; let quote_id = QuoteId::new(); - let payment_options = match mint_quote_request { MintQuoteRequest::Bolt11(bolt11_request) => { if let Some(ref desc) = bolt11_request.description { diff --git a/crates/cdk/src/mint/melt/mod.rs b/crates/cdk/src/mint/melt/mod.rs index 99c0b1668b..324a3b53f8 100644 --- a/crates/cdk/src/mint/melt/mod.rs +++ b/crates/cdk/src/mint/melt/mod.rs @@ -10,6 +10,10 @@ use cdk_common::mint::MeltPaymentRequest; use cdk_common::nut00::KnownMethod; use cdk_common::nut05::MeltMethodOptions; use cdk_common::nuts::nut17::{Kind, NotificationPayload}; +use cdk_common::payjoin::{ + payjoin_v2_from_extra_json, payjoin_v2_is_expired_at, payjoin_v2_to_bip77_endpoint, + ONCHAIN_PAYJOIN_DESTINATION_EXTRA_KEY, ONCHAIN_PAYJOIN_EXTRA_KEY, +}; use cdk_common::payment::{ Bolt11OutgoingPaymentOptions, Bolt12OutgoingPaymentOptions, CustomOutgoingPaymentOptions, OutgoingPaymentOptions, PaymentIdentifier, @@ -41,6 +45,25 @@ mod tests; use melt_saga::{MeltSaga, PaymentOutcome}; +fn onchain_melt_quote_extra_json( + payment_extra_json: Option, + destination_payjoin: Option<&cdk_common::nuts::nut31::PayjoinV2>, +) -> Option { + let mut extra = payment_extra_json?; + if let Some(payjoin) = destination_payjoin { + if payjoin_v2_from_extra_json(Some(&extra)).is_some() { + if let Some(object) = extra.as_object_mut() { + object.insert( + ONCHAIN_PAYJOIN_DESTINATION_EXTRA_KEY.to_string(), + serde_json::to_value(payjoin).ok()?, + ); + } + } + } + + Some(extra) +} + fn pending_melt_wait_timeout() -> Duration { if cfg!(test) { // Bumped from 100ms to 250ms to reduce flake on loaded CI while @@ -567,6 +590,21 @@ impl Mint { // no longer self-referential via the backend response. let quote_id = QuoteId::new(); + if let Some(payjoin) = melt_request.payjoin.as_ref() { + if payjoin_v2_is_expired_at(payjoin, unix_time()) { + return Err(Error::Custom("Payjoin parameters are expired".to_string())); + } + payjoin_v2_to_bip77_endpoint(payjoin) + .map_err(|err| Error::Custom(format!("Invalid Payjoin parameters: {}", err)))?; + } + + let payjoin_metadata = melt_request.payjoin.as_ref().map(|payjoin| { + serde_json::json!({ + ONCHAIN_PAYJOIN_EXTRA_KEY: payjoin, + }) + .to_string() + }); + let outgoing_payment_options = cdk_common::payment::OnchainOutgoingPaymentOptions { address: melt_request.request.clone(), amount: melt_request.amount.with_unit(unit.clone()), @@ -576,7 +614,7 @@ impl Mint { // available `fee_options` and the wallet picks one (echoed back // as `fee_index`) when executing the melt. fee_index: None, - metadata: None, + metadata: payjoin_metadata, }; let payment_quote = ln @@ -639,6 +677,11 @@ impl Mint { // `MeltQuote::new_onchain` applies the NUT validation. Failures are // returned before the quote is persisted, so a backend that violates // the contract never leaves state behind in the mint. + let extra_json = onchain_melt_quote_extra_json( + payment_quote.extra_json, + melt_request.payjoin.as_ref(), + ); + let quote = MeltQuote::new_onchain( Some(quote_id), MeltPaymentRequest::Onchain { @@ -648,7 +691,7 @@ impl Mint { payment_quote.amount, unix_time() + melt_ttl, request_lookup_id, - payment_quote.extra_json, + extra_json, fee_options, )?; diff --git a/crates/cdk/src/mint/melt/tests/onchain_quote_id_tests.rs b/crates/cdk/src/mint/melt/tests/onchain_quote_id_tests.rs index 5f4906dd3f..034d33602e 100644 --- a/crates/cdk/src/mint/melt/tests/onchain_quote_id_tests.rs +++ b/crates/cdk/src/mint/melt/tests/onchain_quote_id_tests.rs @@ -7,7 +7,9 @@ use async_trait::async_trait; use cdk_common::melt::MeltQuoteRequest; use cdk_common::nut00::KnownMethod; use cdk_common::nuts::nut30::MeltQuoteOnchainFeeOption; +use cdk_common::nuts::nut31::PayjoinV2; use cdk_common::nuts::{CurrencyUnit, MeltQuoteState}; +use cdk_common::payjoin::{ONCHAIN_PAYJOIN_DESTINATION_EXTRA_KEY, ONCHAIN_PAYJOIN_EXTRA_KEY}; use cdk_common::payment::{ self, CreateIncomingPaymentResponse, Event, IncomingPaymentOptions, MakePaymentResponse, MintPayment, OnchainSettings, OutgoingPaymentOptions, PaymentIdentifier, PaymentQuoteResponse, @@ -21,6 +23,9 @@ use crate::mint::{Mint, MintBuilder, MintMeltLimits}; use crate::types::QuoteTTL; use crate::Error; +const PAYJOIN_OHTTP_KEYS: &str = "QYPFLM8XL59R0XV4VGPLS7FRDSSM4TUXL07TXCWC4S0GLVLNK2SE4NQ"; +const PAYJOIN_RECEIVER_KEY: &str = "QV6WSX0UQPAEA0RH54430D0UVZWS8CZ6FEGZF4RGFCDKJLPGMYEJG"; + /// What to put in [`PaymentQuoteResponse::request_lookup_id`] when the test /// backend is asked for an onchain quote. #[derive(Debug, Clone)] @@ -59,6 +64,7 @@ struct OnchainQuoteMock { confirmations: u32, echo: EchoBehavior, fee_options: FeeOptionsBehavior, + accept_payjoin: bool, } impl OnchainQuoteMock { @@ -71,8 +77,14 @@ impl OnchainQuoteMock { confirmations: 1, echo, fee_options, + accept_payjoin: false, } } + + fn with_payjoin_acceptance(mut self) -> Self { + self.accept_payjoin = true; + self + } } #[async_trait] @@ -124,12 +136,23 @@ impl MintPayment for OnchainQuoteMock { FeeOptionsBehavior::Explicit(options) => (None, Some(options.clone())), }; + let extra_json = if self.accept_payjoin { + onchain_options + .metadata + .as_ref() + .and_then(|metadata| serde_json::from_str::(metadata).ok()) + .and_then(|value| value.get(ONCHAIN_PAYJOIN_EXTRA_KEY).cloned()) + .map(|payjoin| serde_json::json!({ ONCHAIN_PAYJOIN_EXTRA_KEY: payjoin })) + } else { + None + }; + Ok(PaymentQuoteResponse { request_lookup_id, amount: self.amount.clone(), fee: self.fee.clone(), state: MeltQuoteState::Unpaid, - extra_json: None, + extra_json, estimated_blocks, fee_options, }) @@ -186,9 +209,12 @@ async fn create_onchain_test_mint_with_fee_options( echo: EchoBehavior, fee_options: FeeOptionsBehavior, ) -> Result { - let backend: Arc + Send + Sync> = - Arc::new(OnchainQuoteMock::with_fee_options(echo, fee_options)); + create_onchain_test_mint_with_backend(OnchainQuoteMock::with_fee_options(echo, fee_options)) + .await +} +async fn create_onchain_test_mint_with_backend(backend: OnchainQuoteMock) -> Result { + let backend: Arc + Send + Sync> = Arc::new(backend); let db = Arc::new(cdk_sqlite::mint::memory::empty().await?); let mut mint_builder = MintBuilder::new(db.clone()); @@ -220,6 +246,61 @@ fn onchain_melt_request() -> MeltQuoteRequest { request: "bcrt1qexampleaddr0000000000000000000000000000".to_string(), unit: CurrencyUnit::Sat, amount: Amount::from(1_000), + payjoin: None, + }) +} + +fn optional_payjoin_melt_request() -> (MeltQuoteRequest, PayjoinV2) { + let payjoin = PayjoinV2::new( + "https://payjoin.example/pj".to_string(), + PAYJOIN_OHTTP_KEYS, + PAYJOIN_RECEIVER_KEY, + 4_000_000_000, + ) + .expect("valid Payjoin keys"); + + ( + MeltQuoteRequest::Onchain(MeltQuoteOnchainRequest { + request: "bcrt1qexampleaddr0000000000000000000000000000".to_string(), + unit: CurrencyUnit::Sat, + amount: Amount::from(1_000), + payjoin: Some(payjoin.clone()), + }), + payjoin, + ) +} + +fn invalid_payjoin_melt_request() -> MeltQuoteRequest { + let payjoin = PayjoinV2::new( + "not a url".to_string(), + PAYJOIN_OHTTP_KEYS, + PAYJOIN_RECEIVER_KEY, + 4_000_000_000, + ) + .expect("valid Payjoin keys"); + + MeltQuoteRequest::Onchain(MeltQuoteOnchainRequest { + request: "bcrt1qexampleaddr0000000000000000000000000000".to_string(), + unit: CurrencyUnit::Sat, + amount: Amount::from(1_000), + payjoin: Some(payjoin), + }) +} + +fn expired_payjoin_melt_request() -> MeltQuoteRequest { + let payjoin = PayjoinV2::new( + "https://payjoin.example/pj".to_string(), + PAYJOIN_OHTTP_KEYS, + PAYJOIN_RECEIVER_KEY, + 1, + ) + .expect("valid Payjoin keys"); + + MeltQuoteRequest::Onchain(MeltQuoteOnchainRequest { + request: "bcrt1qexampleaddr0000000000000000000000000000".to_string(), + unit: CurrencyUnit::Sat, + amount: Amount::from(1_000), + payjoin: Some(payjoin), }) } @@ -259,6 +340,116 @@ async fn onchain_quote_uses_mint_generated_id_when_backend_echoes() { ); } +#[tokio::test] +async fn accepted_payjoin_melt_quote_persists_destination_for_saga_recovery() { + let backend = OnchainQuoteMock::with_fee_options( + EchoBehavior::Echo, + FeeOptionsBehavior::Explicit(vec![MeltQuoteOnchainFeeOption { + fee_index: 0, + fee_reserve: Amount::from(10), + estimated_blocks: 6, + }]), + ) + .with_payjoin_acceptance(); + let mint = create_onchain_test_mint_with_backend(backend) + .await + .unwrap(); + let (request, payjoin) = optional_payjoin_melt_request(); + + let response = mint.get_melt_quote(request).await.unwrap(); + let options = match response { + cdk_common::MeltQuoteCreateResponse::Onchain(o) => o, + other => panic!("expected onchain quote response, got {other:?}"), + }; + + assert_eq!( + options.payjoin, + Some(payjoin.clone()), + "public response should expose the accepted Payjoin v2 parameters" + ); + + let stored = mint + .localstore() + .get_melt_quote(&options.quote) + .await + .unwrap() + .expect("quote must be persisted"); + let extra_json = stored.extra_json.expect("Payjoin extra JSON must persist"); + + assert_eq!( + extra_json.get(ONCHAIN_PAYJOIN_EXTRA_KEY), + Some(&serde_json::to_value(payjoin.clone()).expect("Payjoin must serialize")), + "public Payjoin v2 acceptance should remain present" + ); + assert_eq!( + extra_json.get(ONCHAIN_PAYJOIN_DESTINATION_EXTRA_KEY), + Some(&serde_json::to_value(payjoin).expect("Payjoin destination must serialize")), + "full Payjoin destination must be persisted for saga execution and recovery" + ); +} + +#[tokio::test] +async fn invalid_payjoin_melt_quote_is_rejected_before_persisting() { + let backend = OnchainQuoteMock::with_fee_options( + EchoBehavior::Echo, + FeeOptionsBehavior::Explicit(vec![MeltQuoteOnchainFeeOption { + fee_index: 0, + fee_reserve: Amount::from(10), + estimated_blocks: 6, + }]), + ) + .with_payjoin_acceptance(); + let mint = create_onchain_test_mint_with_backend(backend) + .await + .unwrap(); + + let err = mint + .get_melt_quote(invalid_payjoin_melt_request()) + .await + .expect_err("invalid Payjoin params must be rejected"); + + assert!( + matches!(err, Error::Custom(message) if message.contains("Invalid Payjoin parameters")) + ); + assert!(mint + .localstore() + .get_melt_quotes() + .await + .unwrap() + .is_empty()); +} + +#[tokio::test] +async fn expired_payjoin_melt_quote_is_rejected_before_persisting() { + let backend = OnchainQuoteMock::with_fee_options( + EchoBehavior::Echo, + FeeOptionsBehavior::Explicit(vec![MeltQuoteOnchainFeeOption { + fee_index: 0, + fee_reserve: Amount::from(10), + estimated_blocks: 6, + }]), + ) + .with_payjoin_acceptance(); + let mint = create_onchain_test_mint_with_backend(backend) + .await + .unwrap(); + + let err = mint + .get_melt_quote(expired_payjoin_melt_request()) + .await + .expect_err("expired Payjoin params must be rejected"); + + assert!( + matches!(err, Error::Custom(message) if message.contains("Payjoin parameters are expired")) + ); + assert!(mint + .localstore() + .get_melt_quotes() + .await + .unwrap() + .is_empty()); +} + /// Backend omits `request_lookup_id` entirely — must reject with /// `OnchainQuoteLookupIdMismatch { got: None, .. }` and persist no quote. #[tokio::test] diff --git a/crates/cdk/src/mint/start_up_check.rs b/crates/cdk/src/mint/start_up_check.rs index df4a262987..87ebcec074 100644 --- a/crates/cdk/src/mint/start_up_check.rs +++ b/crates/cdk/src/mint/start_up_check.rs @@ -62,19 +62,20 @@ impl Mint { Error::Internal })?; - // Check payment status with LN backend - let pay_invoice_response = - ln_backend - .check_outgoing_payment(lookup_id) - .await - .map_err(|err| { - tracing::error!( - "Failed to check payment status for quote {}: {}", - quote.id, - err - ); - Error::Internal - })?; + // Check payment status with the backend's status-only path. Startup + // recovery must not advance Payjoin negotiations or perform fallback + // broadcasts inline. + let pay_invoice_response = ln_backend + .check_outgoing_payment_status_only(lookup_id) + .await + .map_err(|err| { + tracing::error!( + "Failed to check payment status for quote {}: {}", + quote.id, + err + ); + Error::Internal + })?; tracing::info!( "Payment status for melt quote {}: {}", diff --git a/crates/cdk/src/wallet/issue/mod.rs b/crates/cdk/src/wallet/issue/mod.rs index dd09693f16..86f1a7d5dc 100644 --- a/crates/cdk/src/wallet/issue/mod.rs +++ b/crates/cdk/src/wallet/issue/mod.rs @@ -41,6 +41,7 @@ fn apply_mint_quote_response(quote: &mut MintQuote, response: &MintQuoteResponse MintQuoteResponse::Onchain(response) => { quote.amount_paid = response.amount_paid; quote.amount_issued = response.amount_issued; + quote.payjoin = response.payjoin.clone(); quote.update_state_from_amounts(); } MintQuoteResponse::Custom { response, .. } => { @@ -634,6 +635,7 @@ mod tests { pubkey: SecretKey::generate().public_key(), amount_paid: Amount::from(1_000), amount_issued: Amount::from(250), + payjoin: None, }); assert_eq!(mint_quote_response_amount(&response), None); diff --git a/crates/cdk/src/wallet/issue/saga/mod.rs b/crates/cdk/src/wallet/issue/saga/mod.rs index 96d5e259da..7486e995c8 100644 --- a/crates/cdk/src/wallet/issue/saga/mod.rs +++ b/crates/cdk/src/wallet/issue/saga/mod.rs @@ -287,7 +287,7 @@ impl<'a> MintSaga<'a, Initial> { premint_secrets, mint_request: PreparedMintRequest::Single { quote_id: quote_id.to_string(), - quote_info: quote_info.clone(), + quote_info: Box::new(quote_info.clone()), request, }, payment_method: quote_info.payment_method.clone(), @@ -631,7 +631,7 @@ impl<'a> MintSaga<'a, Prepared> { quote_id, quote_info, .. - } => (vec![quote_id.clone()], vec![quote_info.clone()], None), + } => (vec![quote_id.clone()], vec![(**quote_info).clone()], None), PreparedMintRequest::Batch { quote_ids, quote_infos, diff --git a/crates/cdk/src/wallet/issue/saga/state.rs b/crates/cdk/src/wallet/issue/saga/state.rs index cb96469a91..bfe5995079 100644 --- a/crates/cdk/src/wallet/issue/saga/state.rs +++ b/crates/cdk/src/wallet/issue/saga/state.rs @@ -28,7 +28,7 @@ pub enum PreparedMintRequest { /// Quote ID being minted quote_id: String, /// Quote information - quote_info: MintQuote, + quote_info: Box, /// Mint request ready to send request: MintRequestString, }, diff --git a/crates/cdk/src/wallet/melt/bolt11.rs b/crates/cdk/src/wallet/melt/bolt11.rs index 18ff9c65b3..82b9670733 100644 --- a/crates/cdk/src/wallet/melt/bolt11.rs +++ b/crates/cdk/src/wallet/melt/bolt11.rs @@ -68,8 +68,8 @@ impl Wallet { payment_proof: quote_res.payment_preimage, estimated_blocks: None, fee_index: None, + payjoin: None, payment_method: PaymentMethod::Known(KnownMethod::Bolt11), - used_by_operation: None, version: 0, }; diff --git a/crates/cdk/src/wallet/melt/bolt12.rs b/crates/cdk/src/wallet/melt/bolt12.rs index 4d74c66439..c8f243e7bd 100644 --- a/crates/cdk/src/wallet/melt/bolt12.rs +++ b/crates/cdk/src/wallet/melt/bolt12.rs @@ -71,8 +71,8 @@ impl Wallet { payment_proof: quote_res.payment_preimage, estimated_blocks: None, fee_index: None, + payjoin: None, payment_method: PaymentMethod::Known(KnownMethod::Bolt12), - used_by_operation: None, version: 0, }; diff --git a/crates/cdk/src/wallet/melt/custom.rs b/crates/cdk/src/wallet/melt/custom.rs index b166e8b636..95542eddbe 100644 --- a/crates/cdk/src/wallet/melt/custom.rs +++ b/crates/cdk/src/wallet/melt/custom.rs @@ -55,8 +55,8 @@ impl Wallet { payment_proof: quote_res.payment_preimage, estimated_blocks: None, fee_index: None, + payjoin: None, payment_method: PaymentMethod::Custom(method.to_string()), - used_by_operation: None, version: 0, }; diff --git a/crates/cdk/src/wallet/melt/onchain.rs b/crates/cdk/src/wallet/melt/onchain.rs index 7132274f83..b4c8a1141e 100644 --- a/crates/cdk/src/wallet/melt/onchain.rs +++ b/crates/cdk/src/wallet/melt/onchain.rs @@ -3,7 +3,7 @@ use cdk_common::wallet::MeltQuote; use cdk_common::{MeltQuoteCreateResponse, MeltQuoteRequest, PaymentMethod}; use tracing::instrument; -use crate::nuts::MeltQuoteOnchainRequest; +use crate::nuts::{MeltQuoteOnchainRequest, PayjoinV2}; use crate::{Amount, Error, Wallet}; fn wallet_melt_quote_from_onchain_response( @@ -27,6 +27,7 @@ fn wallet_melt_quote_from_onchain_response( payment_method: PaymentMethod::Known(KnownMethod::Onchain), used_by_operation: None, version: 0, + payjoin: response.payjoin.clone(), } } @@ -38,11 +39,26 @@ impl Wallet { address: &str, amount: Amount, max_fee_amount: Option, + ) -> Result, Error> { + self.quote_onchain_melt_options_with_payjoin(address, amount, max_fee_amount, None) + .await + } + + /// Fetch available onchain melt quote options, optionally forwarding + /// destination Payjoin instructions from an onchain mint quote. + #[instrument(skip(self, max_fee_amount, payjoin))] + pub async fn quote_onchain_melt_options_with_payjoin( + &self, + address: &str, + amount: Amount, + max_fee_amount: Option, + payjoin: Option, ) -> Result, Error> { let quote_request = MeltQuoteOnchainRequest { request: address.to_string(), unit: self.unit.clone(), amount, + payjoin, }; let quote_res = self diff --git a/crates/cdk/src/wallet/melt/saga/compensation.rs b/crates/cdk/src/wallet/melt/saga/compensation.rs index bf0cbd471d..7bdcc504a1 100644 --- a/crates/cdk/src/wallet/melt/saga/compensation.rs +++ b/crates/cdk/src/wallet/melt/saga/compensation.rs @@ -95,6 +95,7 @@ mod tests { payment_proof: None, estimated_blocks: None, fee_index: None, + payjoin: None, payment_method: PaymentMethod::Known(KnownMethod::Bolt11), used_by_operation: None, version: 0, diff --git a/crates/cdk/src/wallet/melt/saga/mod.rs b/crates/cdk/src/wallet/melt/saga/mod.rs index 253da5163b..3cbdf5e5c9 100644 --- a/crates/cdk/src/wallet/melt/saga/mod.rs +++ b/crates/cdk/src/wallet/melt/saga/mod.rs @@ -1404,6 +1404,7 @@ mod tests { selected_fee_index: Some(0), outpoint: None, change: None, + payjoin: None, }, ))); let wallet = create_test_wallet_with_mock(db.clone(), mock_client.clone()).await; @@ -1474,6 +1475,7 @@ mod tests { selected_fee_index: quote.fee_index, outpoint: None, change: None, + payjoin: None, }) } diff --git a/crates/cdk/src/wallet/test_utils.rs b/crates/cdk/src/wallet/test_utils.rs index f6a1f663e6..cac58b8476 100644 --- a/crates/cdk/src/wallet/test_utils.rs +++ b/crates/cdk/src/wallet/test_utils.rs @@ -350,6 +350,7 @@ pub fn test_melt_quote() -> MeltQuote { payment_proof: None, estimated_blocks: None, fee_index: None, + payjoin: None, payment_method: PaymentMethod::Known(KnownMethod::Bolt11), used_by_operation: None, version: 0, diff --git a/docs/nuts/payjoin-onchain.md b/docs/nuts/payjoin-onchain.md new file mode 100644 index 0000000000..b00655c0fc --- /dev/null +++ b/docs/nuts/payjoin-onchain.md @@ -0,0 +1,143 @@ +# NUT-31: Payjoin for Onchain Payment Method + +`optional` + +`depends on: NUT-04 NUT-05 NUT-20 NUT-30` + +This draft extends the NUT-30 `onchain` payment method with structured +Payjoin fields. The existing `request` field remains a Bitcoin address and is +kept as the fallback destination for wallets and mints that do not support +Payjoin. + +This draft specifies BIP77 Payjoin v2 only. + +## Types + +### `PayjoinV2` + +```json +{ + "endpoint": "https://payjoin.example/pj", + "ohttp_keys": "QYPFLM8XL59R0XV4VGPLS7FRDSSM4TUXL07TXCWC4S0GLVLNK2SE4NQ", + "receiver_key": "QV6WSX0UQPAEA0RH54430D0UVZWS8CZ6FEGZF4RGFCDKJLPGMYEJG", + "expires_at": 1701704757 +} +``` + +- `endpoint`: string, BIP77 mailbox endpoint URL without the receiver fragment + parameters. +- `ohttp_keys`: string, BIP77-encoded OHTTP key material needed by the sender, + without the `OH1` prefix. It decodes to one key identifier byte followed by a + 33-byte compressed secp256k1 public key. +- `receiver_key`: string, BIP77-encoded receiver session key, without the + `RK1` prefix. It decodes to a 33-byte compressed secp256k1 public key. +- `expires_at`: Unix timestamp after which the Payjoin parameters should not + be used. + +The OHTTP relay is intentionally not part of this structure. Per BIP77, the +sender wallet chooses the relay it will use. + +Implementations MUST NOT require wallets to pass a BIP21 or BIP321 URI. If an +implementation library internally requires a URI, it is assembled from +`request`, `amount` when present, and `payjoin`. + +## Mint Quote Request + +`PostMintQuoteOnchainRequest` is unchanged from NUT-30: + +```json +{ + "unit": "sat", + "pubkey": "03d56ce4e446a85bbdaa547b4ec2b073d40ff802831352b8272b7dd7a4de5a7cac" +} +``` + +If a mint is configured for Payjoin receive support, it MAY automatically +include Payjoin-capable deposit instructions in the response. Wallets do not +negotiate Payjoin support in mint quote requests. + +## Mint Quote Response + +`PostMintQuoteOnchainResponse` MAY include: + +```json +{ + "quote": "DSGLX9kevM...", + "request": "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh", + "unit": "sat", + "expiry": 1701704757, + "pubkey": "03d56ce4e446a85bbdaa547b4ec2b073d40ff802831352b8272b7dd7a4de5a7cac", + "amount_paid": 0, + "amount_issued": 0, + "payjoin": { + "endpoint": "https://payjoin.example/pj", + "ohttp_keys": "QYPFLM8XL59R0XV4VGPLS7FRDSSM4TUXL07TXCWC4S0GLVLNK2SE4NQ", + "receiver_key": "QV6WSX0UQPAEA0RH54430D0UVZWS8CZ6FEGZF4RGFCDKJLPGMYEJG", + "expires_at": 1701704757 + } +} +``` + +`request` remains the fallback Bitcoin address. Wallets MAY attempt Payjoin, but +MAY also pay `request` directly. + +## Melt Quote Request + +`PostMeltQuoteOnchainRequest` MAY include: + +```json +{ + "request": "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh", + "unit": "sat", + "amount": 100000, + "payjoin": { + "endpoint": "https://payjoin.example/pj", + "ohttp_keys": "QYPFLM8XL59R0XV4VGPLS7FRDSSM4TUXL07TXCWC4S0GLVLNK2SE4NQ", + "receiver_key": "QV6WSX0UQPAEA0RH54430D0UVZWS8CZ6FEGZF4RGFCDKJLPGMYEJG", + "expires_at": 1701704757 + } +} +``` + +`request` remains the fallback destination address. `amount` remains the amount +to send. + +## Melt Quote Response + +`PostMeltQuoteOnchainResponse` MAY include: + +```json +{ + "quote": "TRmjduhIsPxd...", + "amount": 100000, + "unit": "sat", + "state": "UNPAID", + "expiry": 1701704757, + "request": "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh", + "fee_options": [ + { + "fee_index": 0, + "fee_reserve": 5000, + "estimated_blocks": 1 + } + ], + "selected_fee_index": null, + "outpoint": null, + "payjoin": { + "endpoint": "https://payjoin.example/pj", + "ohttp_keys": "QYPFLM8XL59R0XV4VGPLS7FRDSSM4TUXL07TXCWC4S0GLVLNK2SE4NQ", + "receiver_key": "QV6WSX0UQPAEA0RH54430D0UVZWS8CZ6FEGZF4RGFCDKJLPGMYEJG", + "expires_at": 1701704757 + } +} +``` + +The presence of `payjoin` confirms that the mint accepted Payjoin v2 parameters +for this quote. + +## Fallback Handling + +If `payjoin` is absent from any response, behavior is exactly NUT-30. + +If Payjoin cannot be completed, the sender MAY fall back to the direct onchain +payment described by NUT-30. diff --git a/flake.nix b/flake.nix index fd75e6c7b0..bbb5ea8481 100644 --- a/flake.nix +++ b/flake.nix @@ -880,6 +880,7 @@ ]) ++ (with pkgs; [ mprocs + redis ]); commonShellHook = '' @@ -1116,7 +1117,7 @@ itestBinaries = { start-fake-mint = mkItestBinary "start-fake-mint" "--bin start_fake_mint"; - start-regtest-mints = mkItestBinary "start-regtest-mints" "--bin start_regtest_mints"; + start-regtest-mints = mkItestBinary "start-regtest-mints" "--bin start_regtest_mints --features payjoin-regtest"; start-fake-auth-mint = mkItestBinary "start-fake-auth-mint" "--bin start_fake_auth_mint"; start-regtest = mkItestBinary "start-regtest" "--bin start_regtest"; signatory = mkItestBinary "signatory" "--bin signatory"; @@ -1144,6 +1145,26 @@ } ); + itestArchivePayjoin = craneLib.mkCargoDerivation ( + commonCraneArgs + // { + pname = "cdk-itest-archive-payjoin"; + cargoArtifacts = workspaceDeps; + nativeBuildInputs = commonCraneArgs.nativeBuildInputs ++ [ + pkgs.cargo-nextest + ]; + buildPhaseCargoCommand = '' + mkdir -p $out + cargo nextest archive \ + -p cdk-integration-tests \ + --features payjoin-regtest \ + --archive-file $out/itest-archive.tar.zst + ''; + doCheck = false; + installPhaseCommand = ""; + } + ); + # Common arguments can be set here to avoid repeating them later nativeBuildInputs = [ #Add additional build inputs here @@ -1210,6 +1231,7 @@ // itestBinaries // { itest-archive = itestArchive; + itest-archive-payjoin = itestArchivePayjoin; } # Example packages (binaries that can be run outside sandbox with network access) // (builtins.listToAttrs ( diff --git a/misc/interactive_regtest_mprocs.sh b/misc/interactive_regtest_mprocs.sh index bcf7fae795..075fa90763 100644 --- a/misc/interactive_regtest_mprocs.sh +++ b/misc/interactive_regtest_mprocs.sh @@ -118,6 +118,18 @@ echo export CDK_MINTD_DATABASE="$CDK_MINTD_DATABASE" +if [[ -n "${CDK_MINTD_BDK_PAYJOIN_DIRECTORY_URL:-}" || -n "${CDK_REGTEST_PAYJOIN_DIRECTORY_URL:-}" ]]; then + export CDK_MINTD_BDK_PAYJOIN_DIRECTORY_URL="${CDK_MINTD_BDK_PAYJOIN_DIRECTORY_URL:-${CDK_REGTEST_PAYJOIN_DIRECTORY_URL:-}}" + export CDK_MINTD_BDK_PAYJOIN_OHTTP_RELAY_URL="${CDK_MINTD_BDK_PAYJOIN_OHTTP_RELAY_URL:-${CDK_REGTEST_PAYJOIN_OHTTP_RELAY_URL:-}}" + if [[ -z "$CDK_MINTD_BDK_PAYJOIN_DIRECTORY_URL" || -z "$CDK_MINTD_BDK_PAYJOIN_OHTTP_RELAY_URL" ]]; then + echo "❌ Payjoin regtest requires both directory and OHTTP relay URLs" + exit 1 + fi + echo "Payjoin enabled for cdk-mintd" + echo "Directory: $CDK_MINTD_BDK_PAYJOIN_DIRECTORY_URL" + echo "OHTTP relay: $CDK_MINTD_BDK_PAYJOIN_OHTTP_RELAY_URL" +fi + # Build the necessary binaries echo "Building binaries..." cargo build -p cdk-integration-tests --bin start_regtest @@ -173,6 +185,10 @@ echo "export CDK_TEST_MINT_URL=\"$CDK_TEST_MINT_URL\"" >> "$ENV_FILE" echo "export CDK_TEST_MINT_URL_2=\"$CDK_TEST_MINT_URL_2\"" >> "$ENV_FILE" echo "export CDK_TEST_MINT_URL_3=\"$CDK_TEST_MINT_URL_3\"" >> "$ENV_FILE" echo "export CDK_REGTEST_PID=\"$CDK_REGTEST_PID\"" >> "$ENV_FILE" +if [[ -n "${CDK_MINTD_BDK_PAYJOIN_DIRECTORY_URL:-}" ]]; then + echo "export CDK_MINTD_BDK_PAYJOIN_DIRECTORY_URL=\"$CDK_MINTD_BDK_PAYJOIN_DIRECTORY_URL\"" >> "$ENV_FILE" + echo "export CDK_MINTD_BDK_PAYJOIN_OHTTP_RELAY_URL=\"$CDK_MINTD_BDK_PAYJOIN_OHTTP_RELAY_URL\"" >> "$ENV_FILE" +fi # Get the project root directory (where justfile is located) PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" @@ -202,7 +218,6 @@ export CDK_MINTD_BDK_BITCOIND_RPC_PASSWORD="testpass" export CDK_MINTD_BDK_NETWORK="regtest" export CDK_MINTD_BDK_CHAIN_SOURCE_TYPE="bitcoinrpc" export CDK_MINTD_BDK_NUM_CONFS=1 - echo "Starting CLN Mint on port 8085..." echo "Project root: $PROJECT_ROOT" echo "Working directory: \$CDK_MINTD_WORK_DIR" @@ -240,7 +255,6 @@ export CDK_MINTD_BDK_BITCOIND_RPC_PASSWORD="testpass" export CDK_MINTD_BDK_NETWORK="regtest" export CDK_MINTD_BDK_CHAIN_SOURCE_TYPE="bitcoinrpc" export CDK_MINTD_BDK_NUM_CONFS=1 - echo "Starting LND Mint on port 8087..." echo "Project root: $PROJECT_ROOT" echo "Working directory: \$CDK_MINTD_WORK_DIR" @@ -288,6 +302,7 @@ export CDK_MINTD_LDK_NODE_LDK_NODE_PORT=8090 export CDK_MINTD_LDK_NODE_GOSSIP_SOURCE_TYPE="p2p" export CDK_MINTD_LDK_NODE_FEE_PERCENT=0.02 export CDK_MINTD_LDK_NODE_RESERVE_FEE_MIN=2 +CDK_MINTD_RUN_FEATURE_ARGS="ldk-node" echo "Starting LDK Node Mint on port 8089..." echo "Project root: $PROJECT_ROOT" @@ -298,7 +313,7 @@ echo "Storage directory: \$CDK_MINTD_LDK_NODE_STORAGE_DIR_PATH" echo "Database type: \$CDK_MINTD_DATABASE" echo "---" -exec cargo run --bin cdk-mintd --features ldk-node +exec cargo run --bin cdk-mintd --features \$CDK_MINTD_RUN_FEATURE_ARGS EOF # Make scripts executable diff --git a/misc/itest_helpers.sh b/misc/itest_helpers.sh index eda5419b52..cc8986e37d 100755 --- a/misc/itest_helpers.sh +++ b/misc/itest_helpers.sh @@ -14,7 +14,11 @@ run_bin() { "$bin_name" "$@" else echo "Pre-built binary not found, falling back to: cargo run --bin $bin_name" - cargo run --bin "$bin_name" -- "$@" + if [ -n "${CDK_BIN_FEATURES:-}" ]; then + cargo run --features "$CDK_BIN_FEATURES" --bin "$bin_name" -- "$@" + else + cargo run --bin "$bin_name" -- "$@" + fi fi } @@ -26,7 +30,11 @@ run_bin_bg() { "$bin_name" "$@" & else echo "Pre-built binary not found, falling back to: cargo run --bin $bin_name" - cargo run --bin "$bin_name" -- "$@" & + if [ -n "${CDK_BIN_FEATURES:-}" ]; then + cargo run --features "$CDK_BIN_FEATURES" --bin "$bin_name" -- "$@" & + else + cargo run --bin "$bin_name" -- "$@" & + fi fi } @@ -78,6 +86,10 @@ run_test() { cargo nextest run --archive-file "$CDK_ITEST_ARCHIVE" --workspace-remap . -E "binary(/^${test_name}$/)" "${nextest_args[@]}" else echo "Running test '$test_name' via cargo test" - cargo test -p cdk-integration-tests --test "$test_name" "$@" + if [ -n "${CDK_ITEST_FEATURES:-}" ]; then + cargo test -p cdk-integration-tests --features "$CDK_ITEST_FEATURES" --test "$test_name" "$@" + else + cargo test -p cdk-integration-tests --test "$test_name" "$@" + fi fi } diff --git a/misc/itests.sh b/misc/itests.sh index 08f07d24f7..1b8e000122 100755 --- a/misc/itests.sh +++ b/misc/itests.sh @@ -86,6 +86,8 @@ fi EXTRA_ARGS="" if [[ "$SUITE" == "onchain" ]]; then EXTRA_ARGS="--skip-ln" + export CDK_BIN_FEATURES="payjoin-regtest" + export CDK_ITEST_FEATURES="payjoin-regtest" fi echo "Starting regtest and mints" @@ -128,7 +130,7 @@ echo "CDK_ITESTS_DIR=$CDK_ITESTS_DIR" # Validate that we sourced the variables if [[ "$SUITE" == "onchain" ]]; then - if [ -z "$CDK_TEST_MINT_URL" ] || [ -z "$CDK_ITESTS_DIR" ]; then + if [ -z "$CDK_TEST_MINT_URL" ] || [ -z "$CDK_TEST_MINT_URL_2" ] || [ -z "$CDK_ITESTS_DIR" ]; then echo "ERROR: Failed to source environment variables from the .env file" exit 1 fi @@ -207,6 +209,30 @@ if [[ "$SUITE" != "onchain" ]]; then sleep 2 # Wait for 2 seconds before retrying fi done +else + URL="$CDK_TEST_MINT_URL_2/v1/info" + + TIMEOUT=100 + START_TIME=$(date +%s) + while true; do + CURRENT_TIME=$(date +%s) + ELAPSED_TIME=$((CURRENT_TIME - START_TIME)) + + if [ $ELAPSED_TIME -ge $TIMEOUT ]; then + echo "Timeout of $TIMEOUT seconds reached. Exiting..." + exit 1 + fi + + HTTP_STATUS=$(curl -o /dev/null -s -w "%{http_code}" $URL) + + if [ "$HTTP_STATUS" -eq 200 ]; then + echo "Received 200 OK from $URL" + break + else + echo "Waiting for 200 OK response, current status: $HTTP_STATUS" + sleep 2 + fi + done fi # Run cargo test @@ -241,13 +267,11 @@ if [[ "$SUITE" == "onchain" ]]; then echo "onchain_regtest failed, exiting" exit 1 fi - echo "Onchain tests passed successfully" - exit 0 fi if [[ "$SUITE" == "all" ]]; then echo "Running onchain_regtest test with CLN mint" - run_test onchain_regtest + run_test onchain_regtest -- --nocapture --test-threads 1 if [ $? -ne 0 ]; then echo "onchain_regtest with cln mint test failed, exiting" exit 1 @@ -280,7 +304,7 @@ fi if [[ "$SUITE" == "all" || "$SUITE" == "onchain" ]]; then echo "Running onchain_regtest test with LND mint" - run_test onchain_regtest + run_test onchain_regtest -- --nocapture --test-threads 1 if [ $? -ne 0 ]; then echo "onchain_regtest test with LND mint failed, exiting" exit 1 @@ -349,9 +373,9 @@ if [[ "$SUITE" == "all" || "$SUITE" == "ln" ]]; then fi fi -if [[ "$SUITE" == "all" || "$SUITE" == "onchain" ]]; then +if [[ "$SUITE" == "all" ]]; then echo "Running onchain_regtest test with LDK mint" - run_test onchain_regtest + run_test onchain_regtest -- --nocapture --test-threads 1 if [ $? -ne 0 ]; then echo "onchain_regtest test with LDK mint failed, exiting" exit 1