From ff71f0809e5a5700fcfe527d4e310174bb8eff20 Mon Sep 17 00:00:00 2001 From: jbesraa Date: Fri, 24 May 2024 15:44:55 +0300 Subject: [PATCH] Add ability to receive payjoin transactions Allows the node wallet to receive payjoin transactions as specified in BIP78. --- Cargo.toml | 2 +- bindings/ldk_node.udl | 3 + src/builder.rs | 75 +++++- src/error.rs | 15 ++ src/io/utils.rs | 7 + src/lib.rs | 29 +++ src/payjoin_receiver.rs | 398 +++++++++++++++++++++++++++++ src/payjoin_sender.rs | 16 +- src/payment/payjoin.rs | 24 +- src/wallet.rs | 105 +++++++- tests/common/mod.rs | 52 +++- tests/integration_tests_payjoin.rs | 65 +++++ 12 files changed, 772 insertions(+), 19 deletions(-) create mode 100644 src/payjoin_receiver.rs create mode 100644 tests/integration_tests_payjoin.rs diff --git a/Cargo.toml b/Cargo.toml index 934b5d984..0d2589a94 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -59,7 +59,7 @@ bdk = { version = "0.29.0", default-features = false, features = ["std", "async- reqwest = { version = "0.11", default-features = false, features = ["json", "rustls-tls"] } rusqlite = { version = "0.28.0", features = ["bundled"] } -bitcoin = "0.30.2" +bitcoin = { version = "0.30.2", features = ["bitcoinconsensus"] } bip39 = "2.0.0" rand = "0.8.5" diff --git a/bindings/ldk_node.udl b/bindings/ldk_node.udl index 5e34bba07..37a19811d 100644 --- a/bindings/ldk_node.udl +++ b/bindings/ldk_node.udl @@ -157,6 +157,9 @@ enum NodeError { "PayjoinRequestCreationFailed", "PayjoinResponseProcessingFailed", "PayjoinRequestTimeout", + "PayjoinReceiverUnavailable", + "PayjoinReceiverRequestValidationFailed", + "PayjoinReceiverEnrollementFailed" }; dictionary NodeStatus { diff --git a/src/builder.rs b/src/builder.rs index f318ea4bd..ba65b717e 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -11,6 +11,7 @@ use crate::io::sqlite_store::SqliteStore; use crate::liquidity::LiquiditySource; use crate::logger::{log_error, log_info, FilesystemLogger, Logger}; use crate::message_handler::NodeCustomMessageHandler; +use crate::payjoin_receiver::PayjoinReceiver; use crate::payment::store::PaymentStore; use crate::peer_store::PeerStore; use crate::tx_broadcaster::TransactionBroadcaster; @@ -99,6 +100,13 @@ struct PayjoinSenderConfig { payjoin_relay: String, } +#[derive(Debug, Clone)] +struct PayjoinReceiverConfig { + payjoin_relay: String, + payjoin_directory: String, + ohttp_keys: Option, +} + impl Default for LiquiditySourceConfig { fn default() -> Self { Self { lsps2_service: None } @@ -179,6 +187,7 @@ pub struct NodeBuilder { gossip_source_config: Option, liquidity_source_config: Option, payjoin_sender_config: Option, + payjoin_receiver_config: Option, } impl NodeBuilder { @@ -195,6 +204,7 @@ impl NodeBuilder { let gossip_source_config = None; let liquidity_source_config = None; let payjoin_sender_config = None; + let payjoin_receiver_config = None; Self { config, entropy_source_config, @@ -202,6 +212,7 @@ impl NodeBuilder { gossip_source_config, liquidity_source_config, payjoin_sender_config, + payjoin_receiver_config, } } @@ -262,6 +273,15 @@ impl NodeBuilder { self } + /// Configures the [`Node`] instance to enable receiving payjoin transactions. + pub fn set_payjoin_receiver_config( + &mut self, payjoin_relay: String, payjoin_directory: String, ohttp_keys: Option, + ) -> &mut Self { + self.payjoin_receiver_config = + Some(PayjoinReceiverConfig { payjoin_relay, payjoin_directory, ohttp_keys }); + self + } + /// Configures the [`Node`] instance to source its inbound liquidity from the given /// [LSPS2](https://github.com/BitcoinAndLightningLayerSpecs/lsp/blob/main/LSPS2/README.md) /// service. @@ -381,6 +401,7 @@ impl NodeBuilder { self.gossip_source_config.as_ref(), self.liquidity_source_config.as_ref(), self.payjoin_sender_config.as_ref(), + self.payjoin_receiver_config.as_ref(), seed_bytes, logger, vss_store, @@ -403,6 +424,7 @@ impl NodeBuilder { self.gossip_source_config.as_ref(), self.liquidity_source_config.as_ref(), self.payjoin_sender_config.as_ref(), + self.payjoin_receiver_config.as_ref(), seed_bytes, logger, kv_store, @@ -475,6 +497,17 @@ impl ArcedNodeBuilder { self.inner.write().unwrap().set_payjoin_sender_config(payjoin_relay); } + /// Configures the [`Node`] instance to enable receiving payjoin transactions. + pub fn set_payjoin_receiver_config( + &mut self, payjoin_relay: String, payjoin_directory: String, ohttp_keys: Option, + ) { + self.inner.write().unwrap().set_payjoin_receiver_config( + payjoin_relay, + payjoin_directory, + ohttp_keys, + ); + } + /// Configures the [`Node`] instance to source its gossip data from the given RapidGossipSync /// server. pub fn set_gossip_source_rgs(&self, rgs_server_url: String) { @@ -544,7 +577,8 @@ fn build_with_store_internal( config: Arc, chain_data_source_config: Option<&ChainDataSourceConfig>, gossip_source_config: Option<&GossipSourceConfig>, liquidity_source_config: Option<&LiquiditySourceConfig>, - payjoin_sender_config: Option<&PayjoinSenderConfig>, seed_bytes: [u8; 64], + payjoin_sender_config: Option<&PayjoinSenderConfig>, + payjoin_receiver_config: Option<&PayjoinReceiverConfig>, seed_bytes: [u8; 64], logger: Arc, kv_store: Arc, ) -> Result { // Initialize the on-chain wallet and chain access @@ -1010,6 +1044,44 @@ fn build_with_store_internal( } }); + let payjoin_receiver = payjoin_receiver_config.as_ref().and_then(|prc| { + match (payjoin::Url::parse(&prc.payjoin_directory), payjoin::Url::parse(&prc.payjoin_relay)) + { + (Ok(directory), Ok(relay)) => { + let ohttp_keys = match prc.ohttp_keys.clone() { + Some(keys) => { + let keys = match bitcoin::base64::decode(keys) { + Ok(keys) => keys, + Err(e) => { + log_info!(logger, "Failed to decode ohttp keys: the provided key is not a valid Base64 string {}", e); + return None; + }, + }; + match payjoin::OhttpKeys::decode(&keys) { + Ok(ohttp_keys) => Some(ohttp_keys), + Err(e) => { + log_info!(logger, "Failed to decode ohttp keys, make sure you provided a valid Ohttp Key as provided by the payjoin directory: {}", e); + return None; + }, + } + }, + None => None, + }; + Some(Arc::new(PayjoinReceiver::new( + Arc::clone(&logger), + Arc::clone(&wallet), + directory, + relay, + ohttp_keys, + ))) + }, + _ => { + log_info!(logger, "The provided payjoin relay URL is invalid."); + None + }, + } + }); + let is_listening = Arc::new(AtomicBool::new(false)); let latest_wallet_sync_timestamp = Arc::new(RwLock::new(None)); let latest_onchain_wallet_sync_timestamp = Arc::new(RwLock::new(None)); @@ -1030,6 +1102,7 @@ fn build_with_store_internal( chain_monitor, output_sweeper, payjoin_sender, + payjoin_receiver, peer_manager, connection_manager, keys_manager, diff --git a/src/error.rs b/src/error.rs index cae333610..34b809009 100644 --- a/src/error.rs +++ b/src/error.rs @@ -83,6 +83,12 @@ pub enum Error { PayjoinResponseProcessingFailed, /// Payjoin request timed out. PayjoinRequestTimeout, + /// Failed to access payjoin receiver object. + PayjoinReceiverUnavailable, + /// Failed to enroll payjoin receiver. + PayjoinReceiverEnrollementFailed, + /// Failed to validate an incoming payjoin request. + PayjoinReceiverRequestValidationFailed, } impl fmt::Display for Error { @@ -152,6 +158,15 @@ impl fmt::Display for Error { Self::PayjoinRequestTimeout => { write!(f, "Payjoin receiver did not respond to our request within the timeout period. Notice they can still broadcast the original PSBT we shared with them") }, + Self::PayjoinReceiverUnavailable => { + write!(f, "Failed to access payjoin receiver object. Make sure you have enabled Payjoin receiving support.") + }, + Self::PayjoinReceiverRequestValidationFailed => { + write!(f, "Failed to validate an incoming payjoin request. Payjoin sender request didnt pass the payjoin validation steps.") + }, + Self::PayjoinReceiverEnrollementFailed => { + write!(f, "Failed to enroll payjoin receiver. Make sure the configured Payjoin directory & Payjoin relay are available.") + }, } } } diff --git a/src/io/utils.rs b/src/io/utils.rs index 77cc56f55..d61f8d535 100644 --- a/src/io/utils.rs +++ b/src/io/utils.rs @@ -511,6 +511,13 @@ pub(crate) fn check_namespace_key_validity( Ok(()) } +pub(crate) fn ohttp_headers() -> reqwest::header::HeaderMap { + let mut headers = reqwest::header::HeaderMap::new(); + let header_value = reqwest::header::HeaderValue::from_static("message/ohttp-req"); + headers.insert(reqwest::header::CONTENT_TYPE, header_value); + headers +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/lib.rs b/src/lib.rs index a4debc01b..0946952cb 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -88,6 +88,7 @@ pub mod io; mod liquidity; mod logger; mod message_handler; +mod payjoin_receiver; mod payjoin_sender; pub mod payment; mod peer_store; @@ -131,6 +132,7 @@ use connection::ConnectionManager; use event::{EventHandler, EventQueue}; use gossip::GossipSource; use liquidity::LiquiditySource; +use payjoin_receiver::PayjoinReceiver; use payment::store::PaymentStore; use payment::{Bolt11Payment, OnchainPayment, PayjoinPayment, PaymentDetails, SpontaneousPayment}; use peer_store::{PeerInfo, PeerStore}; @@ -184,6 +186,7 @@ pub struct Node { peer_manager: Arc, connection_manager: Arc>>, payjoin_sender: Option>, + payjoin_receiver: Option>, keys_manager: Arc, network_graph: Arc, gossip_source: Arc, @@ -620,6 +623,28 @@ impl Node { } }); + if let Some(payjoin_receiver) = &self.payjoin_receiver { + let mut stop_payjoin_server = self.stop_sender.subscribe(); + let payjoin_receiver = Arc::clone(&payjoin_receiver); + let payjoin_check_interval = 5; + runtime.spawn(async move { + let mut payjoin_interval = + tokio::time::interval(Duration::from_secs(payjoin_check_interval)); + payjoin_interval.reset(); + payjoin_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + loop { + tokio::select! { + _ = stop_payjoin_server.changed() => { + return; + } + _ = payjoin_interval.tick() => { + let _ = payjoin_receiver.process_payjoin_request().await; + } + } + } + }); + } + let event_handler = Arc::new(EventHandler::new( Arc::clone(&self.event_queue), Arc::clone(&self.wallet), @@ -905,9 +930,11 @@ impl Node { #[cfg(not(feature = "uniffi"))] pub fn payjoin_payment(&self) -> PayjoinPayment { let payjoin_sender = self.payjoin_sender.as_ref(); + let payjoin_receiver = self.payjoin_receiver.as_ref(); PayjoinPayment::new( Arc::clone(&self.runtime), payjoin_sender.map(Arc::clone), + payjoin_receiver.map(Arc::clone), Arc::clone(&self.config), ) } @@ -923,9 +950,11 @@ impl Node { #[cfg(feature = "uniffi")] pub fn payjoin_payment(&self) -> PayjoinPayment { let payjoin_sender = self.payjoin_sender.as_ref(); + let payjoin_receiver = self.payjoin_receiver.as_ref(); PayjoinPayment::new( Arc::clone(&self.runtime), payjoin_sender.map(Arc::clone), + payjoin_receiver.map(Arc::clone), Arc::clone(&self.config), ) } diff --git a/src/payjoin_receiver.rs b/src/payjoin_receiver.rs new file mode 100644 index 000000000..382332adc --- /dev/null +++ b/src/payjoin_receiver.rs @@ -0,0 +1,398 @@ +use crate::error::Error; +use crate::io::utils::ohttp_headers; +use crate::logger::FilesystemLogger; +use crate::types::Wallet; +use lightning::log_info; +use lightning::util::logger::Logger; +use payjoin::receive::v2::{Enrolled, Enroller, PayjoinProposal, UncheckedProposal}; +use payjoin::{OhttpKeys, PjUriBuilder}; +use payjoin::{PjUri, Url}; +use std::ops::Deref; +use std::sync::Arc; +use tokio::sync::RwLock; + +pub(crate) struct PayjoinReceiver { + logger: Arc, + wallet: Arc, + payjoin_directory: Url, + payjoin_relay: Url, + enrolled: RwLock>, + ohttp_keys: RwLock>, +} + +impl PayjoinReceiver { + pub(crate) fn new( + logger: Arc, wallet: Arc, payjoin_relay: Url, + payjoin_directory: Url, ohttp_keys: Option, + ) -> Self { + Self { + logger, + wallet, + payjoin_directory, + payjoin_relay, + enrolled: RwLock::new(None), + ohttp_keys: RwLock::new(ohttp_keys), + } + } + + pub(crate) async fn receive(&self, amount: bitcoin::Amount) -> Result { + if !self.is_enrolled().await { + self.enroll().await?; + } + let enrolled = self.enrolled.read().await; + let enrolled = match enrolled.as_ref() { + Some(enrolled) => enrolled, + None => { + log_info!(self.logger, "Payjoin Receiver: Not enrolled"); + return Err(Error::PayjoinReceiverUnavailable); + }, + }; + let fallback_target = enrolled.fallback_target(); + let ohttp_keys = self.ohttp_keys.read().await; + let ohttp_keys = match ohttp_keys.as_ref() { + Some(okeys) => okeys, + None => { + log_info!(self.logger, "Payjoin Receiver: No ohttp keys"); + return Err(Error::PayjoinReceiverUnavailable); + }, + }; + let address = self.wallet.get_new_address()?; + let pj_part = match payjoin::Url::parse(&fallback_target) { + Ok(pj_part) => pj_part, + Err(_) => { + log_info!(self.logger, "Payjoin Receiver: Invalid fallback target"); + return Err(Error::PayjoinReceiverUnavailable); + }, + }; + let payjoin_uri = + PjUriBuilder::new(address, pj_part, Some(ohttp_keys.clone())).amount(amount).build(); + Ok(payjoin_uri) + } + + pub(crate) async fn process_payjoin_request(&self) { + let mut enrolled = self.enrolled.write().await; + if let Some(mut enrolled) = enrolled.take() { + let min_fee_rate = bitcoin::FeeRate::from_sat_per_vb(1); // FIXME: Use a real fee rate + let (req, context) = match enrolled.extract_req() { + Ok(req) => req, + Err(e) => { + log_info!( + self.logger, + "Payjoin Receiver: Unable to extract enrollement request and context{}", + e + ); + return; + }, + }; + + let client = reqwest::Client::new(); + let response = match client + .post(req.url.to_string()) + .body(req.body) + .headers(ohttp_headers()) + .send() + .await + { + Ok(response) => response, + Err(e) => { + log_info!( + self.logger, + "Payjoin Receiver: Unable to fetch payjoin request {}", + e + ); + return; + }, + }; + if response.status() != reqwest::StatusCode::OK { + log_info!( + self.logger, + "Payjoin Receiver: Got non-200 response from directory server {}", + response.status() + ); + return; + }; + let response = match response.bytes().await { + Ok(response) => response, + Err(e) => { + log_info!(self.logger, "Payjoin Receiver: Error reading response {}", e); + return; + }, + }; + if response.is_empty() { + log_info!(self.logger, "Payjoin Receiver: Empty response from directory server"); + return; + }; + let response = match enrolled.process_res(response.to_vec().as_slice(), context) { + Ok(response) => response, + Err(e) => { + log_info!( + self.logger, + "Payjoin Receiver: Unable to process payjoin request {}", + e + ); + return; + }, + }; + let unchecked_proposal = match response { + Some(proposal) => proposal, + None => { + return; + }, + }; + let mut payjoin_proposal = + match self.validate_payjoin_request(unchecked_proposal, min_fee_rate).await { + Ok(proposal) => proposal, + Err(e) => { + log_info!(self.logger, "Payjoin Validation: {}", e); + return; + }, + }; + let (receiver_request, _) = match payjoin_proposal.extract_v2_req() { + Ok(req) => req, + Err(e) => { + log_info!(self.logger, "Payjoin Receiver: Unable to extract V2 request {}", e); + return; + }, + }; + match reqwest::Client::new() + .post(&receiver_request.url.to_string()) + .body(receiver_request.body) + .headers(ohttp_headers()) + .send() + .await + { + Ok(response) => { + if response.status() == reqwest::StatusCode::OK { + log_info!(self.logger, "Payjoin Receiver: Payjoin response sent to sender"); + } else { + log_info!( + self.logger, + "Payjoin Receiver: Got non-200 response from directory {}", + response.status() + ); + } + }, + Err(e) => { + log_info!( + self.logger, + "Payjoin Receiver: Unable to make request to directory {}", + e + ); + }, + } + } else { + log_info!(self.logger, "Payjoin Receiver: Unable to get enrolled object"); + } + } + + async fn enroll(&self) -> Result<(), Error> { + let ohttp_keys = match self.ohttp_keys.read().await.deref() { + Some(okeys) => okeys.clone(), + None => { + let payjoin_directory = &self.payjoin_directory; + let payjoin_directory = match payjoin_directory.join("/ohttp-keys") { + Ok(payjoin_directory) => payjoin_directory, + Err(e) => { + log_info!( + self.logger, + "Payjoin Receiver: Unable to construct ohttp keys url {}", + e + ); + return Err(Error::PayjoinReceiverEnrollementFailed); + }, + }; + let proxy = match reqwest::Proxy::all(self.payjoin_relay.to_string()) { + Ok(proxy) => proxy, + Err(e) => { + log_info!( + self.logger, + "Payjoin Receiver: Unable to construct reqwest proxy {}", + e + ); + return Err(Error::PayjoinReceiverEnrollementFailed); + }, + }; + let client = match reqwest::Client::builder().proxy(proxy).build() { + Ok(client) => client, + Err(e) => { + log_info!( + self.logger, + "Payjoin Receiver: Unable to construct reqwest client {}", + e + ); + return Err(Error::PayjoinReceiverEnrollementFailed); + }, + }; + let response = match client.get(payjoin_directory).send().await { + Ok(response) => response, + Err(e) => { + log_info!( + self.logger, + "Payjoin Receiver: Unable to make request to fetch ohttp keys {}", + e + ); + return Err(Error::PayjoinReceiverEnrollementFailed); + }, + }; + if response.status() != reqwest::StatusCode::OK { + log_info!( + self.logger, + "Payjoin Receiver: Got non 200 response when fetching ohttp keys {}", + response.status() + ); + return Err(Error::PayjoinReceiverEnrollementFailed); + } + let response = match response.bytes().await { + Ok(response) => response, + Err(e) => { + log_info!( + self.logger, + "Payjoin Receiver: Error reading ohttp keys response {}", + e + ); + return Err(Error::PayjoinReceiverEnrollementFailed); + }, + }; + OhttpKeys::decode(response.to_vec().as_slice()).map_err(|e| { + log_info!(self.logger, "Payjoin Receiver: Unable to decode ohttp keys {}", e); + Error::PayjoinReceiverEnrollementFailed + })? + }, + }; + let mut enroller = Enroller::from_directory_config( + self.payjoin_directory.clone(), + ohttp_keys.clone(), + self.payjoin_relay.clone(), + ); + let (req, ctx) = match enroller.extract_req() { + Ok(req) => req, + Err(e) => { + log_info!( + self.logger, + "Payjoin Receiver: unable to extract enrollement request {}", + e + ); + return Err(Error::PayjoinReceiverEnrollementFailed); + }, + }; + let response = match reqwest::Client::new() + .post(&req.url.to_string()) + .body(req.body) + .headers(ohttp_headers()) + .send() + .await + { + Ok(response) => response, + Err(_) => { + log_info!(self.logger, "Payjoin Receiver: unable to make enrollement request"); + return Err(Error::PayjoinReceiverEnrollementFailed); + }, + }; + let response = match response.bytes().await { + Ok(response) => response, + Err(_) => { + panic!("Error reading response"); + }, + }; + let enrolled = match enroller.process_res(response.to_vec().as_slice(), ctx) { + Ok(enrolled) => enrolled, + Err(e) => { + log_info!( + self.logger, + "Payjoin Receiver: unable to process enrollement response {}", + e + ); + return Err(Error::PayjoinReceiverEnrollementFailed); + }, + }; + + *self.ohttp_keys.write().await = Some(ohttp_keys); + *self.enrolled.write().await = Some(enrolled); + Ok(()) + } + + async fn is_enrolled(&self) -> bool { + self.enrolled.read().await.deref().is_some() + && self.ohttp_keys.read().await.deref().is_some() + } + + async fn validate_payjoin_request( + &self, proposal: UncheckedProposal, min_fee_rate: Option, + ) -> Result { + let tx = proposal.extract_tx_to_schedule_broadcast(); + let wallet = &self.wallet; + let verified = wallet.verify_tx(&tx).await; + if verified.is_err() { + log_info!(self.logger, "Invalid transaction"); + return Err(Error::PayjoinReceiverRequestValidationFailed); + }; + let proposal = proposal + .check_broadcast_suitability(min_fee_rate, |_t| Ok(verified.is_ok())) + .map_err(|e| { + log_info!(self.logger, "Broadcast suitability check failed {}", e); + Error::PayjoinReceiverRequestValidationFailed + })?; + let proposal = proposal + .check_inputs_not_owned(|script| { + Ok(wallet.is_mine(&script.to_owned()).unwrap_or(false)) + }) + .map_err(|e| { + log_info!(self.logger, "Inputs owned by us check failed {}", e); + Error::PayjoinReceiverRequestValidationFailed + })?; + let proposal = proposal.check_no_mixed_input_scripts().map_err(|e| { + log_info!(self.logger, "Mixed input scripts check failed {}", e); + Error::PayjoinReceiverRequestValidationFailed + })?; + // Fixme: discuss how to handle this, instead of the Ok(false) we should have a way to + // store seen outpoints and check against them + let proposal = + proposal.check_no_inputs_seen_before(|_outpoint| Ok(false)).map_err(|e| { + log_info!(self.logger, "Inputs seen before check failed {}", e); + Error::PayjoinReceiverRequestValidationFailed + })?; + let mut provisional_proposal = proposal + .identify_receiver_outputs(|script| { + Ok(wallet.is_mine(&script.to_owned()).unwrap_or(false)) + }) + .map_err(|e| { + log_info!(self.logger, "Identify receiver outputs failed {}", e); + Error::PayjoinReceiverRequestValidationFailed + })?; + { + let (candidate_inputs, utxo_set) = wallet.payjoin_receiver_candidate_input()?; + match provisional_proposal.try_preserving_privacy(candidate_inputs) { + Ok(selected_outpoint) => { + if let Some(selected_utxo) = utxo_set.iter().find(|i| { + i.outpoint.txid == selected_outpoint.txid + && i.outpoint.vout == selected_outpoint.vout + }) { + let txo_to_contribute = bitcoin::TxOut { + value: selected_utxo.txout.value, + script_pubkey: selected_utxo.txout.script_pubkey.clone(), + }; + let outpoint_to_contribute = bitcoin::OutPoint { + txid: selected_utxo.outpoint.txid, + vout: selected_utxo.outpoint.vout, + }; + provisional_proposal + .contribute_witness_input(txo_to_contribute, outpoint_to_contribute); + } + }, + Err(_) => { + log_info!(self.logger, "Failed to select utxos to improve payjoin request privacy. Payjoin proceeds regardless"); + }, + }; + }; + let payjoin_proposal = provisional_proposal + .finalize_proposal( + |psbt| Ok(wallet.sign_provisional_payjoin_proposal(psbt.clone()).unwrap()), + None, + ) + .map_err(|e| { + log_info!(self.logger, "Finalize proposal failed {}", e); + Error::PayjoinReceiverRequestValidationFailed + })?; + Ok(payjoin_proposal) + } +} diff --git a/src/payjoin_sender.rs b/src/payjoin_sender.rs index b7e3b132b..e01e349cf 100644 --- a/src/payjoin_sender.rs +++ b/src/payjoin_sender.rs @@ -1,5 +1,6 @@ /// An implementation of payjoin v2 sender as described in BIP-77. use crate::error::Error; +use crate::io::utils::ohttp_headers; use crate::logger::FilesystemLogger; use crate::types::Wallet; @@ -86,8 +87,8 @@ where let response = match client .post(request.url.clone()) .body(request.body.clone()) - .headers(ohttp_req_header()) - .timeout(tokio::time::Duration::from_secs(10)) + .headers(ohttp_headers()) + .timeout(tokio::time::Duration::from_secs(30)) .send() .await { @@ -144,7 +145,7 @@ where let response = match client .post(request.url.clone()) .body(request.body.clone()) - .headers(ohttp_req_header()) + .headers(ohttp_headers()) .send() .await { @@ -251,12 +252,3 @@ where Ok(txid) } } - -fn ohttp_req_header() -> reqwest::header::HeaderMap { - let mut headers = reqwest::header::HeaderMap::new(); - headers.insert( - reqwest::header::CONTENT_TYPE, - reqwest::header::HeaderValue::from_static("message/ohttp-req"), - ); - headers -} diff --git a/src/payment/payjoin.rs b/src/payment/payjoin.rs index 590d04dda..8a203457b 100644 --- a/src/payment/payjoin.rs +++ b/src/payment/payjoin.rs @@ -1,5 +1,8 @@ //! Holds a payment handler allowing to send Payjoin payments. +use payjoin::PjUri; + +use crate::payjoin_receiver::PayjoinReceiver; use crate::types::PayjoinSender; use crate::{error::Error, Config}; @@ -13,15 +16,16 @@ use std::sync::{Arc, RwLock}; pub struct PayjoinPayment { runtime: Arc>>, sender: Option>, + receiver: Option>, config: Arc, } impl PayjoinPayment { pub(crate) fn new( runtime: Arc>>, sender: Option>, - config: Arc, + receiver: Option>, config: Arc, ) -> Self { - Self { runtime, sender, config } + Self { runtime, sender, receiver, config } } /// Send an on chain Payjoin transaction to the address specified in the `payjoin_uri` @@ -81,4 +85,20 @@ impl PayjoinPayment { }, } } + + /// Receive an on chain Payjoin transaction + /// + /// [`BIP21`]: https://github.com/bitcoin/bips/blob/master/bip-0021.mediawiki + /// [`BIP77`]: https://github.com/bitcoin/bips/blob/d7ffad81e605e958dcf7c2ae1f4c797a8631f146/bip-0077.mediawiki + pub async fn receive(&self, amount: bitcoin::Amount) -> Result { + let rt_lock = self.runtime.read().unwrap(); + if rt_lock.is_none() { + return Err(Error::NotRunning); + } + if let Some(receiver) = &self.receiver { + receiver.receive(amount).await + } else { + Err(Error::PayjoinReceiverUnavailable) + } + } } diff --git a/src/wallet.rs b/src/wallet.rs index 43b53eb81..f77707323 100644 --- a/src/wallet.rs +++ b/src/wallet.rs @@ -17,7 +17,7 @@ use lightning::util::message_signing; use bdk::blockchain::EsploraBlockchain; use bdk::database::BatchDatabase; use bdk::wallet::AddressIndex; -use bdk::FeeRate; +use bdk::{FeeRate, LocalUtxo}; use bdk::{SignOptions, SyncOptions}; use bitcoin::bech32::u5; @@ -25,8 +25,9 @@ use bitcoin::blockdata::locktime::absolute::LockTime; use bitcoin::secp256k1::ecdh::SharedSecret; use bitcoin::secp256k1::ecdsa::{RecoverableSignature, Signature}; use bitcoin::secp256k1::{PublicKey, Scalar, Secp256k1, SecretKey, Signing}; -use bitcoin::{ScriptBuf, Transaction, TxOut, Txid}; +use bitcoin::{bitcoinconsensus, ScriptBuf, Transaction, TxOut, Txid}; +use std::collections::{BTreeMap, HashMap}; use std::ops::Deref; use std::sync::{Arc, Condvar, Mutex}; use std::time::Duration; @@ -139,12 +140,112 @@ where Ok(psbt) } + // Returns a list of unspent outputs that can be used as inputs to improve the privacy of a + // payjoin transaction. + pub(crate) fn payjoin_receiver_candidate_input( + &self, + ) -> Result<(HashMap, Vec), Error> { + let locked_wallet = self.inner.lock().unwrap(); + let utxo_set = locked_wallet.list_unspent()?; + let candidate_inputs = utxo_set + .iter() + .filter_map(|utxo| { + if !utxo.is_spent { + Some((bitcoin::Amount::from_sat(utxo.txout.value), utxo.outpoint)) + } else { + None + } + }) + .collect(); + Ok((candidate_inputs, utxo_set)) + } + pub(crate) fn sign_transaction(&self, psbt: &mut Psbt) -> Result { let wallet = self.inner.lock().unwrap(); let is_signed = wallet.sign(psbt, SignOptions::default())?; Ok(is_signed) } + pub(crate) fn sign_provisional_payjoin_proposal(&self, mut psbt: Psbt) -> Result { + let wallet = self.inner.lock().unwrap(); + let mut sign_options = SignOptions::default(); + sign_options.trust_witness_utxo = true; + wallet.sign(&mut psbt, sign_options)?; + // Clear derivation paths and from the PSBT as required by BIP78 + psbt.inputs.iter_mut().for_each(|i| { + i.bip32_derivation = BTreeMap::new(); + }); + psbt.outputs.iter_mut().for_each(|o| { + o.bip32_derivation = BTreeMap::new(); + }); + Ok(psbt) + } + + /// Verifies that the given transaction meets the bitcoin consensus rules. + pub async fn verify_tx(&self, tx: &Transaction) -> Result<(), Error> { + let serialized_tx = bitcoin::consensus::serialize(&tx); + // Loop through all the inputs + for (index, input) in tx.input.iter().enumerate() { + let input = input.clone(); + let txid = input.previous_output.txid; + let prev_tx = match self.blockchain.get_tx(&txid).await { + Ok(prev_tx) => prev_tx, + Err(e) => { + log_error!( + self.logger, + "Failed to verify transaction: blockchain error {} for txid {}", + e, + &txid + ); + panic!("Failed to verify transaction: blockchain error"); + }, + }; + if let Some(prev_tx) = prev_tx { + let spent_output = match prev_tx.output.get(input.previous_output.vout as usize) { + Some(output) => output, + None => { + log_error!( + self.logger, + "Failed to verify transaction: missing output {} in tx {}", + input.previous_output.vout, + txid + ); + panic!("Failed to verify transaction: blockchain error"); + }, + }; + match bitcoinconsensus::verify( + &spent_output.script_pubkey.to_bytes(), + spent_output.value, + &serialized_tx, + index, + ) { + Ok(()) => {}, + Err(e) => { + log_error!(self.logger, "Failed to verify transaction: {}", e); + panic!("Failed to verify transaction: blockchain error"); + }, + } + } else { + if tx.is_coin_base() { + continue; + } else { + log_error!( + self.logger, + "Failed to verify transaction: missing previous transaction {}", + txid + ); + panic!("Failed to verify transaction: blockchain error"); + } + } + } + Ok(()) + } + + pub(crate) fn is_mine(&self, script: &ScriptBuf) -> Result { + let locked_wallet = self.inner.lock().unwrap(); + Ok(locked_wallet.is_mine(script)?) + } + pub(crate) fn create_funding_transaction( &self, output_script: ScriptBuf, value_sats: u64, confirmation_target: ConfirmationTarget, locktime: LockTime, diff --git a/tests/common/mod.rs b/tests/common/mod.rs index bcb47accb..7b5d2affe 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -188,7 +188,7 @@ type TestNode = Node; macro_rules! setup_builder { ($builder: ident, $config: expr) => { #[cfg(feature = "uniffi")] - let $builder = Builder::from_config($config.clone()); + let mut $builder = Builder::from_config($config.clone()); #[cfg(not(feature = "uniffi"))] let mut $builder = Builder::from_config($config.clone()); }; @@ -210,6 +210,22 @@ pub(crate) fn setup_two_nodes(electrsd: &ElectrsD, allow_0conf: bool) -> (TestNo (node_a, node_b) } +pub(crate) fn setup_two_payjoin_nodes( + electrsd: &ElectrsD, allow_0conf: bool, +) -> (TestNode, TestNode) { + println!("== Node A =="); + let config_a = random_config(); + let node_a_payjoin_receiver = setup_payjoin_receiver_node(electrsd, config_a); + + println!("\n== Node B =="); + let mut config_b = random_config(); + if allow_0conf { + config_b.trusted_peers_0conf.push(node_a_payjoin_receiver.node_id()); + } + let node_b_payjoin_sender = setup_payjoin_sender_node(electrsd, config_b); + (node_a_payjoin_receiver, node_b_payjoin_sender) +} + pub(crate) fn setup_node(electrsd: &ElectrsD, config: Config) -> TestNode { let esplora_url = format!("http://{}", electrsd.esplora_url.as_ref().unwrap()); setup_builder!(builder, config); @@ -222,6 +238,40 @@ pub(crate) fn setup_node(electrsd: &ElectrsD, config: Config) -> TestNode { node } +pub(crate) fn setup_payjoin_sender_node(electrsd: &ElectrsD, config: Config) -> TestNode { + let esplora_url = format!("http://{}", electrsd.esplora_url.as_ref().unwrap()); + setup_builder!(builder, config); + builder.set_esplora_server(esplora_url.clone()); + let payjoin_relay = "https://pj.bobspacebkk.com".to_string(); + builder.set_payjoin_sender_config(payjoin_relay.clone()); + let test_sync_store = Arc::new(TestSyncStore::new(config.storage_dir_path.into())); + let node = builder.build_with_store(test_sync_store).unwrap(); + node.start().unwrap(); + assert!(node.status().is_running); + assert!(node.status().latest_fee_rate_cache_update_timestamp.is_some()); + node +} + +pub(crate) fn setup_payjoin_receiver_node(electrsd: &ElectrsD, config: Config) -> TestNode { + let esplora_url = format!("http://{}", electrsd.esplora_url.as_ref().unwrap()); + setup_builder!(builder, config); + builder.set_esplora_server(esplora_url.clone()); + let ohttp_keys = [ + 1, 0, 32, 108, 178, 19, 223, 232, 96, 28, 254, 93, 0, 96, 121, 239, 134, 221, 11, 36, 222, + 38, 242, 81, 226, 126, 225, 44, 158, 1, 241, 220, 96, 96, 51, 0, 4, 0, 1, 0, 3, + ]; + let payjoin_directory = "https://payjo.in".to_string(); + let payjoin_relay = "https://pj.bobspacebkk.com".to_string(); + let ohttp_keys = bitcoin::base64::encode(ohttp_keys); + builder.set_payjoin_receiver_config(payjoin_directory, payjoin_relay, Some(ohttp_keys)); + let test_sync_store = Arc::new(TestSyncStore::new(config.storage_dir_path.into())); + let node = builder.build_with_store(test_sync_store).unwrap(); + node.start().unwrap(); + assert!(node.status().is_running); + assert!(node.status().latest_fee_rate_cache_update_timestamp.is_some()); + node +} + pub(crate) fn generate_blocks_and_wait( bitcoind: &BitcoindClient, electrs: &E, num: usize, ) { diff --git a/tests/integration_tests_payjoin.rs b/tests/integration_tests_payjoin.rs new file mode 100644 index 000000000..c3b8bf2b4 --- /dev/null +++ b/tests/integration_tests_payjoin.rs @@ -0,0 +1,65 @@ +mod common; + +use crate::common::{ + generate_blocks_and_wait, premine_and_distribute_funds, setup_two_payjoin_nodes, wait_for_tx, +}; +use bitcoin::Amount; +use common::setup_bitcoind_and_electrsd; + +#[test] +fn send_receive_regular_payjoin_transaction() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let (node_a_pj_receiver, node_b_pj_sender) = setup_two_payjoin_nodes(&electrsd, false); + let addr_b = node_b_pj_sender.onchain_payment().new_address().unwrap(); + let addr_a = node_a_pj_receiver.onchain_payment().new_address().unwrap(); + let premine_amount_sat = 100_000_00; + premine_and_distribute_funds( + &bitcoind.client, + &electrsd.client, + vec![addr_b, addr_a], + Amount::from_sat(premine_amount_sat), + ); + node_a_pj_receiver.sync_wallets().unwrap(); + node_b_pj_sender.sync_wallets().unwrap(); + assert_eq!(node_b_pj_sender.list_balances().spendable_onchain_balance_sats, premine_amount_sat); + assert_eq!(node_a_pj_receiver.list_balances().spendable_onchain_balance_sats, 100_000_00); + assert_eq!(node_a_pj_receiver.next_event(), None); + assert_eq!(node_a_pj_receiver.list_channels().len(), 0); + let payjoin_payment = node_a_pj_receiver.payjoin_payment(); + + let payjoin_uri = tokio::runtime::Runtime::new().unwrap().handle().block_on(async { + let payjoin_uri = payjoin_payment.receive(Amount::from_sat(80_000)).await.unwrap(); + payjoin_uri + }); + let payjoin_uri = payjoin_uri.to_string(); + let txid = tokio::runtime::Runtime::new() + .unwrap() + .handle() + .block_on(async { + let txid = node_b_pj_sender + .payjoin_payment() + .send(payjoin::Uri::try_from(payjoin_uri).unwrap(), None, None) + .await; + txid + }) + .unwrap(); + if txid.is_none() { + dbg!("no txid yet"); + loop { + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6); + node_a_pj_receiver.sync_wallets().unwrap(); + let node_a_balance = node_a_pj_receiver.list_balances(); + if node_a_balance.total_onchain_balance_sats == 80000 + 100_000_00 { + break; + } + } + } else { + dbg!("got txid already"); + wait_for_tx(&electrsd.client, txid.unwrap()); + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6); + } + node_b_pj_sender.sync_wallets().unwrap(); + let node_b_balance = node_b_pj_sender.list_balances(); + // The fact that we have "<" here should indicate that we are not handling fees correctly yet + assert!(node_b_balance.total_onchain_balance_sats < premine_amount_sat - 80000); +}