From a28076af9b1016d5456de6c2b81d502beb05737d Mon Sep 17 00:00:00 2001 From: Mehmet Efe Umit Date: Tue, 18 Nov 2025 22:14:22 -0800 Subject: [PATCH 1/5] refactor: move sync to a helper function This is a pre-requisite for adding Payjoin support. When the receiver sends the Payjoin proposal to the sender to be broadcasted, they need to sync the blockchain before checking if the Payjoin has indeed been broadcasted. To do that, the sync function will need to be shared between the two online commands. --- src/handlers.rs | 169 ++++++++++++++++++++++++++---------------------- 1 file changed, 92 insertions(+), 77 deletions(-) diff --git a/src/handlers.rs b/src/handlers.rs index 6c58a83..1882b33 100644 --- a/src/handlers.rs +++ b/src/handlers.rs @@ -683,83 +683,7 @@ pub(crate) async fn handle_online_wallet_subcommand( Ok(serde_json::to_string_pretty(&json!({}))?) } Sync => { - #[cfg(any(feature = "electrum", feature = "esplora"))] - let request = wallet - .start_sync_with_revealed_spks() - .inspect(|item, progress| { - let pc = (100 * progress.consumed()) as f32 / progress.total() as f32; - eprintln!("[ SCANNING {pc:03.0}% ] {item}"); - }); - match client { - #[cfg(feature = "electrum")] - Electrum { client, batch_size } => { - // Populate the electrum client's transaction cache so it doesn't re-download transaction we - // already have. - client - .populate_tx_cache(wallet.tx_graph().full_txs().map(|tx_node| tx_node.tx)); - - let update = client.sync(request, batch_size, false)?; - wallet.apply_update(update)?; - } - #[cfg(feature = "esplora")] - Esplora { - client, - parallel_requests, - } => { - let update = client - .sync(request, parallel_requests) - .await - .map_err(|e| *e)?; - wallet.apply_update(update)?; - } - #[cfg(feature = "rpc")] - RpcClient { client } => { - let blockchain_info = client.get_blockchain_info()?; - let wallet_cp = wallet.latest_checkpoint(); - - // reload the last 200 blocks in case of a reorg - let emitter_height = wallet_cp.height().saturating_sub(200); - let mut emitter = Emitter::new( - &*client, - wallet_cp, - emitter_height, - wallet - .tx_graph() - .list_canonical_txs( - wallet.local_chain(), - wallet.local_chain().tip().block_id(), - CanonicalizationParams::default(), - ) - .filter(|tx| tx.chain_position.is_unconfirmed()), - ); - - while let Some(block_event) = emitter.next_block()? { - if block_event.block_height() % 10_000 == 0 { - let percent_done = f64::from(block_event.block_height()) - / f64::from(blockchain_info.headers as u32) - * 100f64; - println!( - "Applying block at height: {}, {:.2}% done.", - block_event.block_height(), - percent_done - ); - } - - wallet.apply_block_connected_to( - &block_event.block, - block_event.block_height(), - block_event.connected_to(), - )?; - } - - let mempool_txs = emitter.mempool()?; - wallet.apply_unconfirmed_txs(mempool_txs.update); - } - #[cfg(feature = "cbf")] - KyotoClient { client } => { - sync_kyoto_client(wallet, client).await?; - } - } + sync_wallet(client, wallet).await?; Ok(serde_json::to_string_pretty(&json!({}))?) } Broadcast { psbt, tx } => { @@ -1325,6 +1249,97 @@ async fn respond( } } +#[cfg(any( + feature = "electrum", + feature = "esplora", + feature = "cbf", + feature = "rpc" +))] +/// Syncs a given wallet using the blockchain client. +pub async fn sync_wallet(client: BlockchainClient, wallet: &mut Wallet) -> Result<(), Error> { + #[cfg(any(feature = "electrum", feature = "esplora"))] + let request = wallet + .start_sync_with_revealed_spks() + .inspect(|item, progress| { + let pc = (100 * progress.consumed()) as f32 / progress.total() as f32; + eprintln!("[ SCANNING {pc:03.0}% ] {item}"); + }); + match client { + #[cfg(feature = "electrum")] + Electrum { client, batch_size } => { + // Populate the electrum client's transaction cache so it doesn't re-download transaction we + // already have. + client.populate_tx_cache(wallet.tx_graph().full_txs().map(|tx_node| tx_node.tx)); + + let update = client.sync(request, batch_size, false)?; + wallet + .apply_update(update) + .map_err(|e| Error::Generic(e.to_string())) + } + #[cfg(feature = "esplora")] + Esplora { + client, + parallel_requests, + } => { + let update = client + .sync(request, parallel_requests) + .await + .map_err(|e| *e)?; + wallet + .apply_update(update) + .map_err(|e| Error::Generic(e.to_string())) + } + #[cfg(feature = "rpc")] + RpcClient { client } => { + let blockchain_info = client.get_blockchain_info()?; + let wallet_cp = wallet.latest_checkpoint(); + + // reload the last 200 blocks in case of a reorg + let emitter_height = wallet_cp.height().saturating_sub(200); + let mut emitter = Emitter::new( + &*client, + wallet_cp, + emitter_height, + wallet + .tx_graph() + .list_canonical_txs( + wallet.local_chain(), + wallet.local_chain().tip().block_id(), + CanonicalizationParams::default(), + ) + .filter(|tx| tx.chain_position.is_unconfirmed()), + ); + + while let Some(block_event) = emitter.next_block()? { + if block_event.block_height() % 10_000 == 0 { + let percent_done = f64::from(block_event.block_height()) + / f64::from(blockchain_info.headers as u32) + * 100f64; + println!( + "Applying block at height: {}, {:.2}% done.", + block_event.block_height(), + percent_done + ); + } + + wallet.apply_block_connected_to( + &block_event.block, + block_event.block_height(), + block_event.connected_to(), + )?; + } + + let mempool_txs = emitter.mempool()?; + wallet.apply_unconfirmed_txs(mempool_txs.update); + Ok(()) + } + #[cfg(feature = "cbf")] + KyotoClient { client } => sync_kyoto_client(wallet, client) + .await + .map_err(|e| Error::Generic(e.to_string())), + } +} + #[cfg(feature = "repl")] fn readline() -> Result { write!(std::io::stdout(), "> ").map_err(|e| Error::Generic(e.to_string()))?; From f62287aff383555a301fd62be40064e1c54ec119 Mon Sep 17 00:00:00 2001 From: Mehmet Efe Umit Date: Tue, 18 Nov 2025 22:18:38 -0800 Subject: [PATCH 2/5] refactor: move broadcast to a helper function Prior to the Payjoin integration, we need to have the broadcast logic outside the broadcast command so that it can be shared between the existing online command and the Payjoin sender. --- src/handlers.rs | 134 ++++++++++++++++++++++++++---------------------- 1 file changed, 74 insertions(+), 60 deletions(-) diff --git a/src/handlers.rs b/src/handlers.rs index 1882b33..b863e25 100644 --- a/src/handlers.rs +++ b/src/handlers.rs @@ -703,66 +703,7 @@ pub(crate) async fn handle_online_wallet_subcommand( (Some(_), Some(_)) => panic!("Both `psbt` and `tx` options not allowed"), (None, None) => panic!("Missing `psbt` and `tx` option"), }; - let txid = match client { - #[cfg(feature = "electrum")] - Electrum { - client, - batch_size: _, - } => client - .transaction_broadcast(&tx) - .map_err(|e| Error::Generic(e.to_string()))?, - #[cfg(feature = "esplora")] - Esplora { - client, - parallel_requests: _, - } => client - .broadcast(&tx) - .await - .map(|()| tx.compute_txid()) - .map_err(|e| Error::Generic(e.to_string()))?, - #[cfg(feature = "rpc")] - RpcClient { client } => client - .send_raw_transaction(&tx) - .map_err(|e| Error::Generic(e.to_string()))?, - - #[cfg(feature = "cbf")] - KyotoClient { client } => { - let LightClient { - requester, - mut info_subscriber, - mut warning_subscriber, - update_subscriber: _, - node, - } = *client; - - let subscriber = tracing_subscriber::FmtSubscriber::new(); - tracing::subscriber::set_global_default(subscriber) - .map_err(|e| Error::Generic(format!("SetGlobalDefault error: {e}")))?; - - tokio::task::spawn(async move { node.run().await }); - tokio::task::spawn(async move { - select! { - info = info_subscriber.recv() => { - if let Some(info) = info { - tracing::info!("{info}"); - } - }, - warn = warning_subscriber.recv() => { - if let Some(warn) = warn { - tracing::warn!("{warn}"); - } - } - } - }); - let txid = tx.compute_txid(); - let wtxid = requester.broadcast_random(tx.clone()).await.map_err(|_| { - tracing::warn!("Broadcast was unsuccessful"); - Error::Generic("Transaction broadcast timed out after 30 seconds".into()) - })?; - tracing::info!("Successfully broadcast WTXID: {wtxid}"); - txid - } - }; + let txid = broadcast_transaction(client, tx).await?; Ok(serde_json::to_string_pretty(&json!({ "txid": txid }))?) } } @@ -1340,6 +1281,79 @@ pub async fn sync_wallet(client: BlockchainClient, wallet: &mut Wallet) -> Resul } } +#[cfg(any( + feature = "electrum", + feature = "esplora", + feature = "cbf", + feature = "rpc" +))] +/// Broadcasts a given transaction using the blockchain client. +pub async fn broadcast_transaction( + client: BlockchainClient, + tx: Transaction, +) -> Result { + match client { + #[cfg(feature = "electrum")] + Electrum { + client, + batch_size: _, + } => client + .transaction_broadcast(&tx) + .map_err(|e| Error::Generic(e.to_string())), + #[cfg(feature = "esplora")] + Esplora { + client, + parallel_requests: _, + } => client + .broadcast(&tx) + .await + .map(|()| tx.compute_txid()) + .map_err(|e| Error::Generic(e.to_string())), + #[cfg(feature = "rpc")] + RpcClient { client } => client + .send_raw_transaction(&tx) + .map_err(|e| Error::Generic(e.to_string())), + + #[cfg(feature = "cbf")] + KyotoClient { client } => { + let LightClient { + requester, + mut info_subscriber, + mut warning_subscriber, + update_subscriber: _, + node, + } = *client; + + let subscriber = tracing_subscriber::FmtSubscriber::new(); + tracing::subscriber::set_global_default(subscriber) + .map_err(|e| Error::Generic(format!("SetGlobalDefault error: {e}")))?; + + tokio::task::spawn(async move { node.run().await }); + tokio::task::spawn(async move { + select! { + info = info_subscriber.recv() => { + if let Some(info) = info { + tracing::info!("{info}"); + } + }, + warn = warning_subscriber.recv() => { + if let Some(warn) = warn { + tracing::warn!("{warn}"); + } + } + } + }); + let txid = tx.compute_txid(); + let wtxid = requester.broadcast_random(tx.clone()).await.map_err(|_| { + tracing::warn!("Broadcast was unsuccessful"); + Error::Generic("Transaction broadcast timed out after 30 seconds".into()) + })?; + tracing::info!("Successfully broadcast WTXID: {wtxid}"); + Ok(txid) + } + } +} + #[cfg(feature = "repl")] fn readline() -> Result { write!(std::io::stdout(), "> ").map_err(|e| Error::Generic(e.to_string()))?; From dca57c0cecd02293fd3f5fce326f062fe6618221 Mon Sep 17 00:00:00 2001 From: Mehmet Efe Umit Date: Tue, 18 Nov 2025 22:22:44 -0800 Subject: [PATCH 3/5] feat: add payjoin send support --- Cargo.lock | 686 ++++++++++++++++++++++++++++++++++++++++++- Cargo.toml | 18 +- src/commands.rs | 18 ++ src/error.rs | 9 + src/handlers.rs | 22 +- src/main.rs | 7 + src/payjoin/mod.rs | 160 ++++++++++ src/payjoin/ohttp.rs | 110 +++++++ 8 files changed, 1008 insertions(+), 22 deletions(-) create mode 100644 src/payjoin/mod.rs create mode 100644 src/payjoin/ohttp.rs diff --git a/Cargo.lock b/Cargo.lock index 4ff8e29..5e615ea 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,52 @@ 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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e8b47f52ea9bae42228d07ec09eb676433d7c4ed1ebdf0f1d1c29ed446f1ab8" +dependencies = [ + "cfg-if", + "cipher 0.3.0", + "cpufeatures 0.2.17", + "opaque-debug", +] + +[[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", + "cipher 0.3.0", + "ctr", + "ghash", + "subtle", +] + [[package]] name = "ahash" version = "0.8.12" @@ -192,12 +238,15 @@ dependencies = [ "dirs", "env_logger", "log", + "payjoin", + "reqwest", "serde_json", "shlex", - "thiserror", + "thiserror 2.0.12", "tokio", "tracing", "tracing-subscriber", + "url", ] [[package]] @@ -278,7 +327,7 @@ dependencies = [ "ciborium", "redb", "tempfile", - "thiserror", + "thiserror 2.0.12", ] [[package]] @@ -291,7 +340,7 @@ dependencies = [ "bip39", "bitcoin", "miniscript", - "rand_core", + "rand_core 0.6.4", "serde", "serde_json", ] @@ -302,6 +351,15 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d965446196e3b7decd44aa7ee49e31d630118f90ef12f97900f262eb915c951d" +[[package]] +name = "bhttp" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16fc24bc615b9fd63148f59b218ea58a444b55762f8845da910e23aca686398b" +dependencies = [ + "thiserror 1.0.69", +] + [[package]] name = "bindgen" version = "0.69.5" @@ -319,7 +377,7 @@ dependencies = [ "proc-macro2", "quote", "regex", - "rustc-hash", + "rustc-hash 1.1.0", "shlex", "syn", "which", @@ -346,7 +404,7 @@ dependencies = [ "bitcoin", "bitcoin_hashes 0.15.0", "chacha20-poly1305", - "rand", + "rand 0.8.5", "tokio", ] @@ -389,6 +447,25 @@ dependencies = [ "bitcoin", ] +[[package]] +name = "bitcoin-hpke" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d37a54c486727c1d1ae9cc28dcf78b6e6ba20dcb88e8c892f1437d9ce215dc8c" +dependencies = [ + "aead 0.5.2", + "chacha20poly1305 0.10.1", + "digest 0.10.7", + "generic-array", + "hkdf 0.12.4", + "hmac 0.12.1", + "rand_core 0.6.4", + "secp256k1", + "sha2 0.10.9", + "subtle", + "zeroize", +] + [[package]] name = "bitcoin-internals" version = "0.2.0" @@ -425,6 +502,29 @@ dependencies = [ "bitcoin-internals 0.4.0", ] +[[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", + "bitcoin-hpke", + "byteorder", + "chacha20poly1305 0.8.0", + "hex", + "hkdf 0.11.0", + "lazy_static", + "log", + "rand 0.8.5", + "serde", + "serde_derive", + "sha2 0.9.9", + "thiserror 1.0.69", + "toml", +] + [[package]] name = "bitcoin-units" version = "0.1.2" @@ -466,6 +566,16 @@ dependencies = [ "hex-conservative 0.3.0", ] +[[package]] +name = "bitcoin_uri" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0a228e083d1702f83389b0ac71eb70078dc8d7fcbb6cde864d1cbca145f5cc" +dependencies = [ + "bitcoin", + "percent-encoding-rfc3986", +] + [[package]] name = "bitcoincore-rpc" version = "0.19.0" @@ -496,6 +606,24 @@ version = "2.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "bumpalo" version = "3.19.0" @@ -540,12 +668,67 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268" +[[package]] +name = "cfg_aliases" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher 0.4.4", + "cpufeatures 0.2.17", +] + [[package]] name = "chacha20-poly1305" 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 0.5.2", + "chacha20 0.9.1", + "cipher 0.4.4", + "poly1305 0.8.0", + "zeroize", +] + [[package]] name = "ciborium" version = "0.2.2" @@ -573,6 +756,26 @@ 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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", + "zeroize", +] + [[package]] name = "clang-sys" version = "1.8.1" @@ -678,12 +881,51 @@ 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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "crunchy" version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "typenum", +] + +[[package]] +name = "crypto-mac" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25fab6889090c8133f3deb8f73ba3c65a7f456f66436fc012a1b1e272b1e103e" +dependencies = [ + "generic-array", + "subtle", +] + [[package]] name = "csv" version = "1.3.1" @@ -705,6 +947,35 @@ dependencies = [ "memchr", ] +[[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 = "digest" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066" +dependencies = [ + "generic-array", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common", + "subtle", +] + [[package]] name = "dirs" version = "6.0.0" @@ -762,7 +1033,7 @@ dependencies = [ "rustls 0.23.31", "serde", "serde_json", - "webpki-roots", + "webpki-roots 0.25.4", "winapi", ] @@ -957,6 +1228,16 @@ dependencies = [ "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.2.16" @@ -964,8 +1245,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi 0.11.1+wasi-snapshot-preview1", + "wasm-bindgen", ] [[package]] @@ -975,9 +1258,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi", "wasi 0.14.2+wasi-0.2.4", + "wasm-bindgen", +] + +[[package]] +name = "ghash" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1583cc1656d7839fd3732b80cf4f38850336cdb9b8ded1cd399ca62958de3c99" +dependencies = [ + "opaque-debug", + "polyval", ] [[package]] @@ -1027,6 +1322,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "hex-conservative" version = "0.1.2" @@ -1057,6 +1358,44 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3011d1213f159867b13cfd6ac92d2cd5f1345762c63be3554e84092d85a50bbd" +[[package]] +name = "hkdf" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01706d578d5c281058480e673ae4086a9f4710d8df1ad80a5b03e39ece5f886b" +dependencies = [ + "digest 0.9.0", + "hmac 0.11.0", +] + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac 0.12.1", +] + +[[package]] +name = "hmac" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a2a2320eb7ec0ebe8da8f744d7812d9fc4cb4d09344ac01898dbcb6a20ae69b" +dependencies = [ + "crypto-mac", + "digest 0.9.0", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + [[package]] name = "home" version = "0.5.11" @@ -1125,6 +1464,23 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls 0.23.31", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots 1.0.4", +] + [[package]] name = "hyper-tls" version = "0.6.0" @@ -1272,6 +1628,15 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "io-uring" version = "0.7.9" @@ -1459,6 +1824,12 @@ version = "0.4.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "memchr" version = "2.7.5" @@ -1503,7 +1874,7 @@ dependencies = [ "rustls-webpki 0.101.7", "serde", "serde_json", - "webpki-roots", + "webpki-roots 0.25.4", ] [[package]] @@ -1574,6 +1945,12 @@ version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "openssl" version = "0.10.73" @@ -1647,12 +2024,37 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "payjoin" +version = "1.0.0-rc.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e20b76ae28f1420a918e8051681fc9669ed7273e542e515baa329be78c3255a" +dependencies = [ + "bhttp", + "bitcoin", + "bitcoin-hpke", + "bitcoin-ohttp", + "bitcoin_uri", + "http", + "reqwest", + "serde", + "serde_json", + "tracing", + "url", +] + [[package]] name = "percent-encoding" version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" +[[package]] +name = "percent-encoding-rfc3986" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3637c05577168127568a64e9dc5a6887da720efef07b3d9472d45f63ab191166" + [[package]] name = "pin-project-lite" version = "0.2.16" @@ -1671,6 +2073,40 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "poly1305" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "048aeb476be11a4b6ca432ca569e375810de9294ae78f4774e78ea98a9246ede" +dependencies = [ + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash 0.4.0", +] + +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash 0.5.1", +] + +[[package]] +name = "polyval" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8419d2b623c7c0896ff2d5d96e2cb4ede590fed28fcc34934f4c33c036e620a1" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash 0.4.0", +] + [[package]] name = "portable-atomic" version = "1.11.1" @@ -1723,6 +2159,61 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash 2.1.1", + "rustls 0.23.31", + "socket2", + "thiserror 2.0.12", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +dependencies = [ + "bytes", + "getrandom 0.3.3", + "lru-slab", + "rand 0.9.2", + "ring", + "rustc-hash 2.1.1", + "rustls 0.23.31", + "rustls-pki-types", + "slab", + "thiserror 2.0.12", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + [[package]] name = "quote" version = "1.0.40" @@ -1745,8 +2236,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ "libc", - "rand_chacha", - "rand_core", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.3", ] [[package]] @@ -1756,7 +2257,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.3", ] [[package]] @@ -1768,6 +2279,15 @@ dependencies = [ "getrandom 0.2.16", ] +[[package]] +name = "rand_core" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +dependencies = [ + "getrandom 0.3.3", +] + [[package]] name = "redb" version = "2.6.0" @@ -1794,7 +2314,7 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.16", "libredox", - "thiserror", + "thiserror 2.0.12", ] [[package]] @@ -1828,9 +2348,9 @@ checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" [[package]] name = "reqwest" -version = "0.12.22" +version = "0.12.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbc931937e6ca3a06e3b6c0aa7841849b160a90351d6ab467a8b9b9959767531" +checksum = "9d0946410b9f7b082a427e4ef5c8ff541a88b357bc6c637c40db3a68ac70a36f" dependencies = [ "base64 0.22.1", "bytes", @@ -1839,6 +2359,7 @@ dependencies = [ "http-body", "http-body-util", "hyper", + "hyper-rustls", "hyper-tls", "hyper-util", "js-sys", @@ -1846,6 +2367,8 @@ dependencies = [ "native-tls", "percent-encoding", "pin-project-lite", + "quinn", + "rustls 0.23.31", "rustls-pki-types", "serde", "serde_json", @@ -1853,6 +2376,7 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-native-tls", + "tokio-rustls", "tower", "tower-http", "tower-service", @@ -1860,6 +2384,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", + "webpki-roots 1.0.4", ] [[package]] @@ -1902,6 +2427,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + [[package]] name = "rustix" version = "0.38.44" @@ -1949,6 +2480,7 @@ dependencies = [ "aws-lc-rs", "log", "once_cell", + "ring", "rustls-pki-types", "rustls-webpki 0.103.4", "subtle", @@ -1961,6 +2493,7 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79" dependencies = [ + "web-time", "zeroize", ] @@ -2030,7 +2563,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113" dependencies = [ "bitcoin_hashes 0.14.0", - "rand", + "rand 0.8.5", "secp256k1-sys", "serde", ] @@ -2111,6 +2644,30 @@ dependencies = [ "serde", ] +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -2228,13 +2785,33 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.12", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -2323,6 +2900,25 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls 0.23.31", + "tokio", +] + +[[package]] +name = "toml" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234" +dependencies = [ + "serde", +] + [[package]] name = "tower" version = "0.5.2" @@ -2431,6 +3027,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + [[package]] name = "unicode-ident" version = "1.0.18" @@ -2452,6 +3054,26 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a1a07cc7db3810833284e8d372ccdc6da29741639ecc70c9ec107df0fa6154c" +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + [[package]] name = "untrusted" version = "0.9.0" @@ -2467,6 +3089,7 @@ dependencies = [ "form_urlencoded", "idna", "percent-encoding", + "serde", ] [[package]] @@ -2604,12 +3227,31 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "webpki-roots" version = "0.25.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1" +[[package]] +name = "webpki-roots" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2878ef029c47c6e8cf779119f20fcf52bde7ad42a731b2a304bc221df17571e" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "which" version = "4.4.2" @@ -2900,6 +3542,20 @@ name = "zeroize" version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] name = "zerotrie" diff --git a/Cargo.toml b/Cargo.toml index d5767f3..9aacd87 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,8 @@ serde_json = "1.0" thiserror = "2.0.11" tokio = { version = "1", features = ["full"] } cli-table = "0.5.0" +tracing = "0.1.41" +tracing-subscriber = "0.3.20" # Optional dependencies bdk_bitcoind_rpc = { version = "0.21.0", features = ["std"], optional = true } @@ -29,8 +31,9 @@ bdk_esplora = { version = "0.22.1", features = ["async-https", "tokio"], optiona bdk_kyoto = { version = "0.15.1", optional = true } bdk_redb = { version = "0.1.0", optional = true } shlex = { version = "1.3.0", optional = true } -tracing = "0.1.41" -tracing-subscriber = "0.3.20" +payjoin = { version = "1.0.0-rc.0", features = ["v1", "v2", "io", "_test-utils"], optional = true} +reqwest = { version = "0.12.23", default-features = false, optional = true } +url = { version = "2.5.4", optional = true } [features] default = ["repl", "sqlite"] @@ -43,10 +46,13 @@ sqlite = ["bdk_wallet/rusqlite"] redb = ["bdk_redb"] # Available blockchain client options -cbf = ["bdk_kyoto"] -electrum = ["bdk_electrum"] -esplora = ["bdk_esplora"] -rpc = ["bdk_bitcoind_rpc"] +cbf = ["bdk_kyoto", "_payjoin-dependencies"] +electrum = ["bdk_electrum", "_payjoin-dependencies"] +esplora = ["bdk_esplora", "_payjoin-dependencies"] +rpc = ["bdk_bitcoind_rpc", "_payjoin-dependencies"] + +# Internal features +_payjoin-dependencies = ["payjoin", "reqwest", "url"] # Use this to consensus verify transactions at sync time verify = [] diff --git a/src/commands.rs b/src/commands.rs index d3f2d98..e730c0f 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -430,6 +430,24 @@ pub enum OnlineWalletSubCommand { )] tx: Option, }, + /// Sends an original PSBT to a BIP 21 URI and broadcasts the returned Payjoin PSBT. + SendPayjoin { + /// BIP 21 URI for the Payjoin. + #[arg(env = "PAYJOIN_URI", long = "uri", required = true)] + uri: String, + /// URL of the Payjoin OHTTP relay. Can be repeated multiple times to attempt the + /// operation with multiple relays for redundancy. + #[arg(env = "PAYJOIN_OHTTP_RELAY", long = "ohttp_relay", required = true)] + ohttp_relay: Vec, + /// Fee rate to use in sat/vbyte. + #[arg( + env = "PAYJOIN_SENDER_FEE_RATE", + short = 'f', + long = "fee_rate", + required = true + )] + fee_rate: u64, + }, } /// Subcommands for Key operations. diff --git a/src/error.rs b/src/error.rs index 1b8b5b4..064a928 100644 --- a/src/error.rs +++ b/src/error.rs @@ -103,6 +103,15 @@ pub enum BDKCliError { #[cfg(feature = "cbf")] #[error("BDK-Kyoto update error: {0}")] KyotoUpdateError(#[from] bdk_kyoto::UpdateError), + + #[cfg(any( + feature = "electrum", + feature = "esplora", + feature = "rpc", + feature = "cbf", + ))] + #[error("Reqwest error: {0}")] + ReqwestError(#[from] reqwest::Error), } impl From for BDKCliError { diff --git a/src/handlers.rs b/src/handlers.rs index b863e25..d9b214c 100644 --- a/src/handlers.rs +++ b/src/handlers.rs @@ -58,7 +58,14 @@ use std::convert::TryFrom; #[cfg(any(feature = "repl", feature = "electrum", feature = "esplora"))] use std::io::Write; use std::str::FromStr; -#[cfg(any(feature = "redb", feature = "compiler"))] +#[cfg(any( + feature = "redb", + feature = "compiler", + feature = "electrum", + feature = "esplora", + feature = "cbf", + feature = "rpc" +))] use std::sync::Arc; #[cfg(any( feature = "electrum", @@ -68,7 +75,9 @@ use std::sync::Arc; ))] use { crate::commands::OnlineWalletSubCommand::*, + crate::payjoin::{PayjoinManager, ohttp::RelayManager}, bdk_wallet::bitcoin::{Transaction, consensus::Decodable, hex::FromHex}, + std::sync::Mutex, }; #[cfg(feature = "esplora")] use {crate::utils::BlockchainClient::Esplora, bdk_esplora::EsploraAsyncExt}; @@ -706,6 +715,17 @@ pub(crate) async fn handle_online_wallet_subcommand( let txid = broadcast_transaction(client, tx).await?; Ok(serde_json::to_string_pretty(&json!({ "txid": txid }))?) } + SendPayjoin { + uri, + ohttp_relay, + fee_rate, + } => { + let relay_manager = Arc::new(Mutex::new(RelayManager::new())); + let mut payjoin_manager = PayjoinManager::new(wallet, relay_manager); + return payjoin_manager + .send_payjoin(uri, fee_rate, ohttp_relay, client) + .await; + } } } diff --git a/src/main.rs b/src/main.rs index c69aecc..81190bf 100644 --- a/src/main.rs +++ b/src/main.rs @@ -13,6 +13,13 @@ mod commands; mod error; mod handlers; +#[cfg(any( + feature = "electrum", + feature = "esplora", + feature = "cbf", + feature = "rpc" +))] +mod payjoin; #[cfg(any(feature = "sqlite", feature = "redb"))] mod persister; mod utils; diff --git a/src/payjoin/mod.rs b/src/payjoin/mod.rs new file mode 100644 index 0000000..1f09e0f --- /dev/null +++ b/src/payjoin/mod.rs @@ -0,0 +1,160 @@ +use crate::error::BDKCliError as Error; +use crate::handlers::{broadcast_transaction, sync_wallet}; +use crate::utils::BlockchainClient; +use bdk_wallet::{ + SignOptions, Wallet, + bitcoin::{FeeRate, Psbt, Txid, consensus::encode::serialize_hex}, +}; +use payjoin::bitcoin::TxIn; +use payjoin::persist::{OptionalTransitionOutcome, SessionPersister}; +use payjoin::receive::InputPair; +use payjoin::receive::v2::{ + HasReplyableError, Initialized, MaybeInputsOwned, MaybeInputsSeen, Monitor, OutputsUnknown, + PayjoinProposal, ProvisionalProposal, ReceiveSession, Receiver, + SessionEvent as ReceiverSessionEvent, UncheckedOriginalPayload, WantsFeeRange, WantsInputs, + WantsOutputs, +}; +use payjoin::send::v2::{ + PollingForProposal, SendSession, Sender, SessionEvent as SenderSessionEvent, + SessionOutcome as SenderSessionOutcome, WithReplyKey, +}; +use payjoin::{ImplementationError, UriExt}; +use serde_json::{json, to_string_pretty}; +use std::sync::{Arc, Mutex}; + +use crate::payjoin::ohttp::{RelayManager, fetch_ohttp_keys}; + +pub mod ohttp; + +/// Implements all of the functions required to go through the Payjoin receive and send processes. +/// +/// TODO: At the time of writing, this struct is written to make a Persister implementation easier +/// but the persister is not implemented yet! For instance [`PayjoinManager::proceed_sender_session`] and +/// [`PayjoinManager::proceed_receiver_session`] are designed such that the manager can enable +/// resuming ongoing payjoins are well. So... this is a TODO for implementing persister. +pub(crate) struct PayjoinManager<'a> { + wallet: &'a mut Wallet, + relay_manager: Arc>, +} + +impl<'a> PayjoinManager<'a> { + pub fn new(wallet: &'a mut Wallet, relay_manager: Arc>) -> Self { + Self { + wallet, + relay_manager, + } + } + + async fn proceed_sender_session( + &self, + session: SendSession, + persister: &impl SessionPersister, + relay: url::Url, + blockchain_client: BlockchainClient, + ) -> Result { + match session { + SendSession::WithReplyKey(context) => { + self.post_original_proposal(context, relay, persister, blockchain_client) + .await + } + SendSession::PollingForProposal(context) => { + self.get_proposed_payjoin_proposal(context, relay, persister, blockchain_client) + .await + } + SendSession::Closed(SenderSessionOutcome::Success(psbt)) => { + self.process_payjoin_proposal(psbt, blockchain_client).await + } + _ => Err(Error::Generic("Unexpected SendSession state!".to_string())), + } + } + + async fn post_original_proposal( + &self, + sender: Sender, + relay: url::Url, + persister: &impl SessionPersister, + blockchain_client: BlockchainClient, + ) -> Result { + let (req, ctx) = sender.create_v2_post_request(relay.as_str()).map_err(|e| { + Error::Generic(format!( + "Failed to create a post request for a Payjoin send: {}", + e + )) + })?; + let response = self.send_payjoin_post_request(req).await?; + let sender = sender + .process_response(&response.bytes().await?, ctx) + .save(persister) + .map_err(|e| { + Error::Generic(format!("Failed to persist the Payjoin send after successfully sending original proposal: {}", e)) + })?; + self.get_proposed_payjoin_proposal(sender, relay, persister, blockchain_client) + .await + } + + async fn get_proposed_payjoin_proposal( + &self, + sender: Sender, + relay: url::Url, + persister: &impl SessionPersister, + blockchain_client: BlockchainClient, + ) -> Result { + let mut sender = sender.clone(); + loop { + let (req, ctx) = sender.create_poll_request(relay.as_str()).map_err(|e| { + Error::Generic(format!( + "Failed to create a poll request during a Payjoin send: {}", + e + )) + })?; + let response = self.send_payjoin_post_request(req).await?; + let processed_response = sender + .process_response(&response.bytes().await?, ctx) + .save(persister); + match processed_response { + Ok(OptionalTransitionOutcome::Progress(psbt)) => { + println!("Proposal received. Processing..."); + return self.process_payjoin_proposal(psbt, blockchain_client).await; + } + Ok(OptionalTransitionOutcome::Stasis(current_state)) => { + println!("No response yet. Continuing polling..."); + sender = current_state; + continue; + } + Err(e) => { + break Err(Error::Generic(format!( + "Error occurred when polling for Payjoin v2 proposal: {}", + e + ))); + } + } + } + } + + async fn process_payjoin_proposal( + &self, + mut psbt: Psbt, + blockchain_client: BlockchainClient, + ) -> Result { + if !self.wallet.sign(&mut psbt, SignOptions::default())? { + return Err(Error::Generic( + "Failed to sign and finalize the Payjoin proposal PSBT.".to_string(), + )); + } + + broadcast_transaction(blockchain_client, psbt.extract_tx_fee_rate_limit()?).await + } + + async fn send_payjoin_post_request( + &self, + req: payjoin::Request, + ) -> reqwest::Result { + let client = reqwest::Client::new(); + client + .post(req.url) + .header("Content-Type", req.content_type) + .body(req.body) + .send() + .await + } +} diff --git a/src/payjoin/ohttp.rs b/src/payjoin/ohttp.rs new file mode 100644 index 0000000..1cc0935 --- /dev/null +++ b/src/payjoin/ohttp.rs @@ -0,0 +1,110 @@ +use crate::error::BDKCliError as Error; +use std::sync::{Arc, Mutex}; + +#[derive(Debug, Clone)] +pub(crate) struct RelayManager { + selected_relay: Option, + failed_relays: Vec, +} + +impl RelayManager { + pub fn new() -> Self { + RelayManager { + selected_relay: None, + failed_relays: Vec::new(), + } + } + + pub fn set_selected_relay(&mut self, relay: url::Url) { + self.selected_relay = Some(relay); + } + + pub fn get_selected_relay(&self) -> Option { + self.selected_relay.clone() + } + + pub fn add_failed_relay(&mut self, relay: url::Url) { + self.failed_relays.push(relay); + } + + pub fn get_failed_relays(&self) -> Vec { + self.failed_relays.clone() + } +} + +pub(crate) struct ValidatedOhttpKeys { + pub(crate) ohttp_keys: payjoin::OhttpKeys, + pub(crate) relay_url: url::Url, +} + +pub(crate) async fn fetch_ohttp_keys( + relays: Vec, + payjoin_directory: impl payjoin::IntoUrl, + relay_manager: Arc>, +) -> Result { + use payjoin::bitcoin::secp256k1::rand::prelude::SliceRandom; + + loop { + let failed_relays = relay_manager + .lock() + .expect("Lock should not be poisoned") + .get_failed_relays(); + + let remaining_relays: Vec<_> = relays + .iter() + .filter(|r| !failed_relays.contains(r)) + .cloned() + .collect(); + + if remaining_relays.is_empty() { + return Err(Error::Generic( + "No valid OHTTP relays available".to_string(), + )); + } + + let selected_relay = + match remaining_relays.choose(&mut payjoin::bitcoin::key::rand::thread_rng()) { + Some(relay) => relay.clone(), + None => { + return Err(Error::Generic( + "Failed to select from remaining relays".to_string(), + )); + } + }; + + relay_manager + .lock() + .expect("Lock should not be poisoned") + .set_selected_relay(selected_relay.clone()); + + let ohttp_keys = + payjoin::io::fetch_ohttp_keys(selected_relay.as_str(), payjoin_directory.as_str()) + .await; + + match ohttp_keys { + Ok(keys) => { + return Ok(ValidatedOhttpKeys { + ohttp_keys: keys, + relay_url: selected_relay, + }); + } + Err(payjoin::io::Error::UnexpectedStatusCode(e)) => { + return Err(Error::Generic(format!( + "Unexpected error occurred when fetching OHTTP keys: {}", + e + ))); + } + Err(e) => { + tracing::debug!( + "Failed to connect to OHTTP relay: {}, {}", + selected_relay, + e + ); + relay_manager + .lock() + .expect("Lock should not be poisoned") + .add_failed_relay(selected_relay); + } + } + } +} From 004bf4d993d222357c2b51dc909543d1fa3dac1b Mon Sep 17 00:00:00 2001 From: Mehmet Efe Umit Date: Tue, 18 Nov 2025 22:23:10 -0800 Subject: [PATCH 4/5] feat: add payjoin receive support --- src/commands.rs | 17 ++ src/handlers.rs | 13 +- src/payjoin/mod.rs | 642 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 671 insertions(+), 1 deletion(-) diff --git a/src/commands.rs b/src/commands.rs index e730c0f..7e65af2 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -430,6 +430,23 @@ pub enum OnlineWalletSubCommand { )] tx: Option, }, + // Generates a Payjoin receive URI and processes the sender's Payjoin proposal. + ReceivePayjoin { + /// Amount to be received in sats. + #[arg(env = "PAYJOIN_AMOUNT", long = "amount", required = true)] + amount: u64, + /// Payjoin directory which will be used to store the PSBTs which are pending action + /// from one of the parties. + #[arg(env = "PAYJOIN_DIRECTORY", long = "directory", required = true)] + directory: String, + /// URL of the Payjoin OHTTP relay. Can be repeated multiple times to attempt the + /// operation with multiple relays for redundancy. + #[arg(env = "PAYJOIN_OHTTP_RELAY", long = "ohttp_relay", required = true)] + ohttp_relay: Vec, + /// Maximum effective fee rate the receiver is willing to pay for their own input/output contributions. + #[arg(env = "PAYJOIN_RECEIVER_MAX_FEE_RATE", long = "max_fee_rate")] + max_fee_rate: Option, + }, /// Sends an original PSBT to a BIP 21 URI and broadcasts the returned Payjoin PSBT. SendPayjoin { /// BIP 21 URI for the Payjoin. diff --git a/src/handlers.rs b/src/handlers.rs index d9b214c..4631186 100644 --- a/src/handlers.rs +++ b/src/handlers.rs @@ -9,7 +9,6 @@ //! Command Handlers //! //! This module describes all the command handling logic used by bdk-cli. - use crate::commands::OfflineWalletSubCommand::*; use crate::commands::*; use crate::error::BDKCliError as Error; @@ -715,6 +714,18 @@ pub(crate) async fn handle_online_wallet_subcommand( let txid = broadcast_transaction(client, tx).await?; Ok(serde_json::to_string_pretty(&json!({ "txid": txid }))?) } + ReceivePayjoin { + amount, + directory, + ohttp_relay, + max_fee_rate, + } => { + let relay_manager = Arc::new(Mutex::new(RelayManager::new())); + let mut payjoin_manager = PayjoinManager::new(wallet, relay_manager); + return payjoin_manager + .receive_payjoin(amount, directory, max_fee_rate, ohttp_relay, client) + .await; + } SendPayjoin { uri, ohttp_relay, diff --git a/src/payjoin/mod.rs b/src/payjoin/mod.rs index 1f09e0f..b2ffe0a 100644 --- a/src/payjoin/mod.rs +++ b/src/payjoin/mod.rs @@ -45,6 +45,648 @@ impl<'a> PayjoinManager<'a> { } } + pub async fn receive_payjoin( + &mut self, + amount: u64, + directory: String, + max_fee_rate: Option, + ohttp_relays: Vec, + blockchain_client: BlockchainClient, + ) -> Result { + let address = self + .wallet + .next_unused_address(bdk_wallet::KeychainKind::External); + + let ohttp_relays: Vec = ohttp_relays + .into_iter() // if ohttp_relay: Option> + .map(|s| url::Url::parse(&s)) + .collect::>() + .map_err(|e| { + Error::Generic(format!("Failed to parse one or more OHTTP URLs: {}", e)) + })?; + + if ohttp_relays.is_empty() { + return Err(Error::Generic( + "At least one valid OHTTP relay must be provided.".into(), + )); + } + + let ohttp_keys = + fetch_ohttp_keys(ohttp_relays, &directory, self.relay_manager.clone()).await?; + // TODO: Implement proper persister. + let persister = payjoin::persist::NoopSessionPersister::::default(); + + let checked_max_fee_rate = max_fee_rate + .map(|rate| FeeRate::from_sat_per_kwu(rate)) + .unwrap_or(FeeRate::BROADCAST_MIN); + + let receiver = payjoin::receive::v2::ReceiverBuilder::new( + address.address, + directory, + ohttp_keys.ohttp_keys, + ) + .map_err(|e| { + Error::Generic(format!( + "Failed to initialize a Payjoin ReceieverBuilder: {}", + e + )) + })? + .with_amount(payjoin::bitcoin::Amount::from_sat(amount)) + .with_max_fee_rate(checked_max_fee_rate) + .build() + .save(&persister) + .map_err(|e| { + Error::Generic(format!( + "Failed to persister the receiver after initialization: {}", + e + )) + })?; + + let pj_uri = receiver.pj_uri(); + println!("Request Payjoin by sharing this Payjoin Uri:"); + println!("{pj_uri}"); + + self.proceed_receiver_session( + ReceiveSession::Initialized(receiver.clone()), + &persister, + ohttp_keys.relay_url, + blockchain_client, + ) + .await?; + + Ok(to_string_pretty(&json!({}))?) + } + + pub async fn send_payjoin( + &mut self, + uri: String, + fee_rate: u64, + ohttp_relays: Vec, + blockchain_client: BlockchainClient, + ) -> Result { + let uri = payjoin::Uri::try_from(uri) + .map_err(|e| Error::Generic(format!("Failed parsing to Payjoin URI: {}", e)))?; + let uri = uri.require_network(self.wallet.network()).map_err(|e| { + Error::Generic(format!( + "Failed setting the right network for the URI: {}", + e + )) + })?; + let uri = uri + .check_pj_supported() + .map_err(|e| Error::Generic(format!("URI does not support Payjoin: {}", e)))?; + + let sats = uri + .amount + .ok_or_else(|| Error::Generic("Amount is not specified in the URI.".to_string()))?; + + let fee_rate = FeeRate::from_sat_per_vb(fee_rate).expect("Provided fee rate is not valid."); + + // Build and sign the original PSBT which pays to the receiver. + let mut original_psbt = { + let mut tx_builder = self.wallet.build_tx(); + tx_builder + .add_recipient(uri.address.script_pubkey(), sats) + .fee_rate(fee_rate); + + tx_builder.finish().map_err(|e| { + Error::Generic(format!( + "Error occurred when building original Payjoin transaction: {}", + e + )) + })? + }; + if !self + .wallet + .sign(&mut original_psbt, SignOptions::default())? + { + return Err(Error::Generic( + "Failed to sign and finalize the original PSBT.".to_string(), + )); + } + + let txid = match uri.extras.pj_param() { + payjoin::PjParam::V1(_) => { + let (req, ctx) = payjoin::send::v1::SenderBuilder::new(original_psbt.clone(), uri) + .build_recommended(fee_rate) + .map_err(|e| { + Error::Generic(format!("Failed to build a Payjoin v1 sender: {}", e)) + })? + .create_v1_post_request(); + + let response = self + .send_payjoin_post_request(req) + .await + .map_err(|e| Error::Generic(format!("Failed to send request: {}", e)))?; + + let psbt = ctx + .process_response(&response.bytes().await?) + .map_err(|e| Error::Generic(format!("Failed to send a Payjoin v1: {}", e)))?; + + self.process_payjoin_proposal(psbt, blockchain_client) + .await? + } + payjoin::PjParam::V2(_) => { + let ohttp_relays: Vec = ohttp_relays + .into_iter() // if ohttp_relay: Option> + .map(|s| url::Url::parse(&s)) + .collect::>() + .map_err(|e| { + Error::Generic(format!("Failed to parse one or more OHTTP URLs: {}", e)) + })?; + + if ohttp_relays.is_empty() { + return Err(Error::Generic( + "At least one valid OHTTP relay must be provided.".into(), + )); + } + + // TODO: Implement proper persister. + let persister = + payjoin::persist::NoopSessionPersister::::default(); + + let sender = payjoin::send::v2::SenderBuilder::new(original_psbt.clone(), uri) + .build_recommended(fee_rate) + .map_err(|e| { + Error::Generic(format!("Failed to build a Payjoin v2 sender: {}", e)) + })? + .save(&persister) + .map_err(|e| { + Error::Generic(format!( + "Failed to save the Payjoin v2 sender in the persister: {}", + e + )) + })?; + + let selected_relay = + fetch_ohttp_keys(ohttp_relays, &sender.endpoint(), self.relay_manager.clone()) + .await? + .relay_url; + + self.proceed_sender_session( + SendSession::WithReplyKey(sender), + &persister, + selected_relay, + blockchain_client, + ) + .await? + } + _ => { + unimplemented!("Payjoin version not recognized."); + } + }; + + Ok(to_string_pretty(&json!({ "txid": txid }))?) + } + + async fn proceed_receiver_session( + &mut self, + session: ReceiveSession, + persister: &impl SessionPersister, + relay: url::Url, + blockchain_client: BlockchainClient, + ) -> Result<(), Error> { + match session { + ReceiveSession::Initialized(proposal) => { + self.read_from_directory(proposal, persister, relay, blockchain_client) + .await + } + ReceiveSession::UncheckedOriginalPayload(proposal) => { + self.check_proposal(proposal, persister, blockchain_client) + .await + } + ReceiveSession::MaybeInputsOwned(proposal) => { + self.check_inputs_not_owned(proposal, persister, blockchain_client) + .await + } + ReceiveSession::MaybeInputsSeen(proposal) => { + self.check_no_inputs_seen_before(proposal, persister, blockchain_client) + .await + } + ReceiveSession::OutputsUnknown(proposal) => { + self.identify_receiver_outputs(proposal, persister, blockchain_client) + .await + } + ReceiveSession::WantsOutputs(proposal) => { + self.commit_outputs(proposal, persister, blockchain_client) + .await + } + ReceiveSession::WantsInputs(proposal) => { + self.contribute_inputs(proposal, persister, blockchain_client) + .await + } + ReceiveSession::WantsFeeRange(proposal) => { + self.apply_fee_range(proposal, persister, blockchain_client) + .await + } + ReceiveSession::ProvisionalProposal(proposal) => { + self.finalize_proposal(proposal, persister, blockchain_client) + .await + } + ReceiveSession::PayjoinProposal(proposal) => { + self.send_payjoin_proposal(proposal, persister, blockchain_client) + .await + } + ReceiveSession::Monitor(proposal) => { + self.monitor_payjoin_proposal(proposal, persister, blockchain_client) + .await + } + ReceiveSession::HasReplyableError(error) => self.handle_error(error, persister).await, + ReceiveSession::Closed(_) => return Err(Error::Generic("Session closed".to_string())), + } + } + + async fn read_from_directory( + &mut self, + receiver: Receiver, + persister: &impl SessionPersister, + relay: url::Url, + blockchain_client: BlockchainClient, + ) -> Result<(), Error> { + let mut current_receiver_typestate = receiver; + let next_receiver_typestate = loop { + let (req, context) = current_receiver_typestate + .create_poll_request(relay.as_str()) + .map_err(|e| { + Error::Generic(format!( + "Failed to create a poll request to read from the Payjoin directory: {}", + e + )) + })?; + println!("Polling receive request..."); + let response = self.send_payjoin_post_request(req).await?; + let state_transition = current_receiver_typestate + .process_response(response.bytes().await?.to_vec().as_slice(), context) + .save(persister); + match state_transition { + Ok(OptionalTransitionOutcome::Progress(next_state)) => { + println!("Got a request from the sender. Responding with a Payjoin proposal."); + break next_state; + } + Ok(OptionalTransitionOutcome::Stasis(current_state)) => { + current_receiver_typestate = current_state; + continue; + } + Err(e) => { + return Err(Error::Generic(format!( + "Error occurred when polling for Payjoin proposal from the directory: {}", + e.to_string() + ))); + } + } + }; + self.check_proposal(next_receiver_typestate, persister, blockchain_client) + .await + } + + async fn check_proposal( + &mut self, + receiver: Receiver, + persister: &impl SessionPersister, + blockchain_client: BlockchainClient, + ) -> Result<(), Error> { + let next_receiver_typestate = receiver + .check_broadcast_suitability(None, |_| return Ok(true)) + .save(persister) + .map_err(|e| { + Error::Generic(format!( + "Error occurred when saving after checking if the original proposal can be broadcasted: {}", + e + )) + })?; + + println!( + "Checking whether the original proposal can be broadcasted itself is not supported. If the Payjoin fails, manually fall back to the transaction below." + ); + println!( + "{}", + serialize_hex(&next_receiver_typestate.extract_tx_to_schedule_broadcast()) + ); + + self.check_inputs_not_owned(next_receiver_typestate, persister, blockchain_client) + .await + } + + async fn check_inputs_not_owned( + &mut self, + receiver: Receiver, + persister: &impl SessionPersister, + blockchain_client: BlockchainClient, + ) -> Result<(), Error> { + let next_receiver_typestate = receiver + .check_inputs_not_owned(&mut |input| { + Ok(self.wallet.is_mine(input.to_owned())) + }) + .save(persister) + .map_err(|e| { + Error::Generic(format!("Error occurred when saving after checking if inputs in the original proposal are not owned: {}", e)) + })?; + + self.check_no_inputs_seen_before(next_receiver_typestate, persister, blockchain_client) + .await + } + + async fn check_no_inputs_seen_before( + &mut self, + receiver: Receiver, + persister: &impl SessionPersister, + blockchain_client: BlockchainClient, + ) -> Result<(), Error> { + // This is not supported as there is no persistence of previous Payjoin attempts in BDK CLI + // yet. If there is support either in the BDK persister or Payjoin persister, this can be + // implemented, but it is not a concern as the use cases of the CLI does not warrant + // protection against probing attacks. + println!( + "Checking whether the inputs in the proposal were seen before to protect from probing attacks is not supported. Skipping the check..." + ); + let next_receiver_typestate = receiver.check_no_inputs_seen_before(&mut |_| Ok(false)).save(persister).map_err(|e| { + Error::Generic(format!("Error occurred when saving after checking if the inputs in the proposal were seen before: {}", e)) + })?; + self.identify_receiver_outputs(next_receiver_typestate, persister, blockchain_client) + .await + } + + async fn identify_receiver_outputs( + &mut self, + receiver: Receiver, + persister: &impl SessionPersister, + blockchain_client: BlockchainClient, + ) -> Result<(), Error> { + let next_receiver_typestate = receiver.identify_receiver_outputs(&mut |output_script| { + Ok(self.wallet.is_mine(output_script.to_owned())) + }).save(persister).map_err(|e| { + Error::Generic(format!("Error occurred when saving after checking if the outputs in the original proposal are owned by the receiver: {}", e)) + })?; + + self.commit_outputs(next_receiver_typestate, persister, blockchain_client) + .await + } + + async fn commit_outputs( + &mut self, + receiver: Receiver, + persister: &impl SessionPersister, + blockchain_client: BlockchainClient, + ) -> Result<(), Error> { + // This is a typestate to modify existing receiver-owned outputs in case the receiver wants + // to do that. This is a very simple implementation of Payjoin so we are just going + // to commit to the existing outputs which the sender included in the original proposal. + let next_receiver_typestate = receiver.commit_outputs().save(persister).map_err(|e| { + Error::Generic(format!( + "Error occurred when saving after committing to the outputs in the proposal: {}", + e + )) + })?; + self.contribute_inputs(next_receiver_typestate, persister, blockchain_client) + .await + } + + async fn contribute_inputs( + &mut self, + receiver: Receiver, + persister: &impl SessionPersister, + blockchain_client: BlockchainClient, + ) -> Result<(), Error> { + let candidate_inputs: Vec = self + .wallet + .list_unspent() + .map(|output| { + let psbtin = self + .wallet + .get_psbt_input(output.clone(), None, false) + .expect( + "Failed to get the PSBT Input using the output of the unspent transaction", + ); + let txin = TxIn { + previous_output: output.outpoint, + ..Default::default() + }; + InputPair::new(txin, psbtin, None) + .expect("Failed to create InputPair when contributing outputs to the proposal") + }) + .collect(); + let selected_input = receiver + .try_preserving_privacy(candidate_inputs) + .map_err(|e| { + Error::Generic(format!( + "Error occurred when trying to pick an unspent UTXO for input contribution: {}", + e + )) + })?; + + let next_receiver_typestate = receiver.contribute_inputs(vec![selected_input]) + .map_err(|e| { + Error::Generic(format!("Error occurred when contributing the selected input to the proposal: {}", e)) + })?.commit_inputs().save(persister) + .map_err(|e| { + Error::Generic(format!("Error occurred when saving after committing to the inputs after receiver contribution: {}", e)) + })?; + + self.apply_fee_range(next_receiver_typestate, persister, blockchain_client) + .await + } + + async fn apply_fee_range( + &mut self, + receiver: Receiver, + persister: &impl SessionPersister, + blockchain_client: BlockchainClient, + ) -> Result<(), Error> { + let next_receiver_typestate = receiver.apply_fee_range(None, None).save(persister).map_err(|e| { + Error::Generic(format!("Error occurred when saving after applying the receiver fee range to the transaction: {}", e)) + })?; + self.finalize_proposal(next_receiver_typestate, persister, blockchain_client) + .await + } + + async fn finalize_proposal( + &mut self, + receiver: Receiver, + persister: &impl SessionPersister, + blockchain_client: BlockchainClient, + ) -> Result<(), Error> { + let next_receiver_typestate = receiver + .finalize_proposal(|psbt| { + let mut psbt_clone = psbt.clone(); + if !self + .wallet + .sign(&mut psbt_clone, SignOptions::default()) + .map_err(|e| { + ImplementationError::from( + format!("Error occurred when signing the Payjoin PSBT: {}", e).as_str(), + ) + })? + { + return Err(ImplementationError::from( + "Failed to sign and finalize the Payjoin proposal PSBT.", + )); + } + + Ok(psbt_clone) + }) + .save(persister) + .map_err(|e| { + Error::Generic(format!( + "Error occurred when saving after signing the Payjoin proposal: {}", + e + )) + })?; + + self.send_payjoin_proposal(next_receiver_typestate, persister, blockchain_client) + .await + } + + async fn send_payjoin_proposal( + &mut self, + receiver: Receiver, + persister: &impl SessionPersister, + blockchain_client: BlockchainClient, + ) -> Result<(), Error> { + let (req, ctx) = receiver.create_post_request( + self.relay_manager + .lock() + .expect("Lock should not be poisoned") + .get_selected_relay() + .expect("A relay should already be selected") + .as_str(), + ).map_err(|e| { + Error::Generic(format!("Error occurred when creating a post request for sending final Payjoin proposal: {}", e)) + })?; + + let res = self.send_payjoin_post_request(req).await?; + let payjoin_psbt = receiver.psbt().clone(); + let next_receiver_typestate = receiver.process_response(&res.bytes().await?, ctx).save(persister).map_err(|e| { + Error::Generic(format!("Error occurred when saving after processing the response to the Payjoin proposal send: {}", e)) + })?; + println!( + "Response successful. TXID: {}", + payjoin_psbt.extract_tx_unchecked_fee_rate().compute_txid() + ); + return self + .monitor_payjoin_proposal(next_receiver_typestate, persister, blockchain_client) + .await; + } + + /// Syncs the blockchain once and then checks whether the Payjoin was broadcasted by the + /// sender. + /// + /// The currenty implementation does not support checking for the PAyjoin broadcast in a loop + /// and returning only when it is detected or if a timeout is reached because the [`sync_wallet`] + /// function consumes the BlockchainClient. BDK CLI supports multiple blockchain clients, and + /// at the time of writing, Kyoto consumes the client since BDK CLI is not designed for long-running + /// tasks. + async fn monitor_payjoin_proposal( + &mut self, + receiver: Receiver, + persister: &impl SessionPersister, + blockchain_client: BlockchainClient, + ) -> Result<(), Error> { + let wait_time_for_sync = 3; + let poll_internal = tokio::time::Duration::from_secs(3); + + let mut interval = tokio::time::interval(poll_internal); + + println!( + "Waiting for {wait_time_for_sync} seconds before syncing the blockchain and checking if the transaction has been broadcast..." + ); + interval.tick().await; + sync_wallet(blockchain_client, self.wallet).await?; + + let check_result = receiver + .check_payment( + |txid| { + let tx_details = self.wallet.tx_details(txid).expect("Cannot find transaction in the wallet"); + let is_seen = match tx_details.chain_position { + bdk_wallet::chain::ChainPosition::Confirmed { .. } => true, + bdk_wallet::chain::ChainPosition::Unconfirmed { first_seen: Some(_), .. } => true, + _ => false + }; + if is_seen { + return Ok(Some(tx_details.tx.as_ref().clone())); + } + return Err(ImplementationError::from("Cannot find the transaction in the mempool or the blockchain")); + }, + |outpoint| { + let utxo = self.wallet.get_utxo(outpoint); + match utxo { + Some(_) => Ok(false), + None => Ok(true), + } + } + ) + .save(persister) + .map_err(|e| { + Error::Generic(format!("Error occurred when saving after checking that sender has broadcasted the Payjoin transaction: {}", e)) + }); + + match check_result { + Ok(_) => { + println!("Payjoin transaction detected in the mempool!"); + } + Err(_) => { + println!( + "Transaction was not found in the mempool after {wait_time_for_sync}. Check the state of the transaction manually after running the sync command." + ); + } + } + + Ok(()) + } + + async fn handle_error( + &self, + receiver: Receiver, + persister: &impl SessionPersister, + ) -> Result<(), Error> { + let (err_req, err_ctx) = receiver + .create_error_request( + self.relay_manager + .lock() + .expect("Lock should not be poisoned") + .get_selected_relay() + .expect("A relay should already be selected") + .as_str(), + ) + .map_err(|e| { + Error::Generic(format!( + "Error occurred when creating a receiver error request: {}", + e + )) + })?; + + let err_response = match self.send_payjoin_post_request(err_req).await { + Ok(response) => response, + Err(e) => { + return Err(Error::Generic(format!( + "Failed to post error request: {}", + e + ))); + } + }; + + let err_bytes = match err_response.bytes().await { + Ok(bytes) => bytes, + Err(e) => { + return Err(Error::Generic(format!( + "Failed to get error response bytes: {}", + e + ))); + } + }; + + if let Err(e) = receiver + .process_error_response(&err_bytes, err_ctx) + .save(persister) + { + return Err(Error::Generic(format!( + "Failed to process error response: {}", + e + ))); + } + + Ok(()) + } + async fn proceed_sender_session( &self, session: SendSession, From 2af219c5bd060fa4eb6a6fecc6776ef73525127b Mon Sep 17 00:00:00 2001 From: Mehmet Efe Umit Date: Tue, 18 Nov 2025 22:48:45 -0800 Subject: [PATCH 5/5] docs: add Payjoin capability to the README --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index bca4e00..adf5a8c 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ And yes, it can do Taproot!! This crate can be used for the following purposes: - Instantly create a miniscript based wallet and connect to your backend of choice (Electrum, Esplora, Core RPC, Kyoto etc) and quickly play around with your own complex bitcoin scripting workflow. With one or many wallets, connected with one or many backends. - The `tests/integration.rs` module is used to document high level complex workflows between BDK and different Bitcoin infrastructure systems, like Core, Electrum and Lightning(soon TM). + - Receive and send Async Payjoins. Note that even though Async Payjoin as a protocol allows the receiver and sender to go offline during the payjoin, the BDK CLI implementation currently does not support persisting. - (Planned) Expose the basic command handler via `wasm` to integrate `bdk-cli` functionality natively into the web platform. See also the [playground](https://bitcoindevkit.org/bdk-cli/playground/) page. If you are considering using BDK in your own wallet project bdk-cli is a nice playground to get started with. It allows easy testnet and regtest wallet operations, to try out what's possible with descriptors, miniscript, and BDK APIs. For more information on BDK refer to the [website](https://bitcoindevkit.org/) and the [rust docs](https://docs.rs/bdk_wallet/1.0.0/bdk_wallet/index.html)