From ac0e23e694af9999f051c7e58f973b546271b87f Mon Sep 17 00:00:00 2001 From: jbesraa Date: Mon, 20 May 2024 21:31:13 +0300 Subject: [PATCH] Allow to send payjoin transactions Implements the payjoin sender as describe in BIP77. This would allow the on chain wallet linked to LDK node to send payjoin transactions. --- Cargo.toml | 4 + bindings/ldk_node.udl | 29 ++++ src/builder.rs | 47 ++++- src/config.rs | 9 + src/error.rs | 36 ++++ src/event.rs | 127 +++++++++++++- src/lib.rs | 57 ++++++- src/payment/mod.rs | 2 + src/payment/payjoin/handler.rs | 264 +++++++++++++++++++++++++++++ src/payment/payjoin/mod.rs | 180 ++++++++++++++++++++ src/payment/store.rs | 52 +++++- src/types.rs | 4 + src/wallet.rs | 57 ++++++- tests/common/mod.rs | 66 ++++++++ tests/integration_tests_payjoin.rs | 216 +++++++++++++++++++++++ 15 files changed, 1137 insertions(+), 13 deletions(-) create mode 100644 src/payment/payjoin/handler.rs create mode 100644 src/payment/payjoin/mod.rs create mode 100644 tests/integration_tests_payjoin.rs diff --git a/Cargo.toml b/Cargo.toml index 206f5f2dd..c399f9f70 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -69,6 +69,7 @@ tokio = { version = "1.37", default-features = false, features = [ "rt-multi-thr esplora-client = { version = "0.6", default-features = false } libc = "0.2" uniffi = { version = "0.26.0", features = ["build"], optional = true } +payjoin = { version = "0.16.0", default-features = false, features = ["send", "v2"] } [target.'cfg(vss)'.dependencies] vss-client = "0.2" @@ -85,6 +86,9 @@ bitcoincore-rpc = { version = "0.17.0", default-features = false } proptest = "1.0.0" regex = "1.5.6" +payjoin = { version = "0.16.0", default-features = false, features = ["send", "v2", "receive"] } +reqwest = { version = "0.11", default-features = false, features = ["json", "rustls-tls", "blocking"] } + [target.'cfg(not(no_download))'.dev-dependencies] electrsd = { version = "0.26.0", features = ["legacy", "esplora_a33e97e1", "bitcoind_25_0"] } diff --git a/bindings/ldk_node.udl b/bindings/ldk_node.udl index aedf9f6ab..d25547f79 100644 --- a/bindings/ldk_node.udl +++ b/bindings/ldk_node.udl @@ -64,6 +64,7 @@ interface Node { SpontaneousPayment spontaneous_payment(); OnchainPayment onchain_payment(); UnifiedQrPayment unified_qr_payment(); + PayjoinPayment payjoin_payment(); [Throws=NodeError] void connect(PublicKey node_id, SocketAddress address, boolean persist); [Throws=NodeError] @@ -156,6 +157,13 @@ interface UnifiedQrPayment { QrPaymentResult send([ByRef]string uri_str); }; +interface PayjoinPayment { + [Throws=NodeError] + void send(string payjoin_uri); + [Throws=NodeError] + void send_with_amount(string payjoin_uri, u64 amount_sats); +}; + [Error] enum NodeError { "AlreadyRunning", @@ -206,6 +214,12 @@ enum NodeError { "InsufficientFunds", "LiquiditySourceUnavailable", "LiquidityFeeTooHigh", + "PayjoinUnavailable", + "PayjoinUriInvalid", + "PayjoinRequestMissingAmount", + "PayjoinRequestCreationFailed", + "PayjoinRequestSendingFailed", + "PayjoinResponseProcessingFailed", }; dictionary NodeStatus { @@ -231,6 +245,7 @@ enum BuildError { "InvalidSystemTime", "InvalidChannelMonitor", "InvalidListeningAddresses", + "InvalidPayjoinConfig", "ReadFailed", "WriteFailed", "StoragePathAccessFailed", @@ -248,6 +263,11 @@ interface Event { ChannelPending(ChannelId channel_id, UserChannelId user_channel_id, ChannelId former_temporary_channel_id, PublicKey counterparty_node_id, OutPoint funding_txo); ChannelReady(ChannelId channel_id, UserChannelId user_channel_id, PublicKey? counterparty_node_id); ChannelClosed(ChannelId channel_id, UserChannelId user_channel_id, PublicKey? counterparty_node_id, ClosureReason? reason); + PayjoinPaymentPending(Txid txid, u64 amount_sats); + PayjoinPaymentBroadcasted(Txid txid, u64 amount_sats); + PayjoinPaymentSuccessful(Txid txid, u64 amount_sats, boolean is_original_psbt_modified); + PayjoinPaymentFailed(Txid txid, u64 amount_sats, PayjoinPaymentFailureReason reason); + PayjoinPaymentOriginalPsbtBroadcasted(Txid txid, u64 amount_sats); }; enum PaymentFailureReason { @@ -259,6 +279,12 @@ enum PaymentFailureReason { "UnexpectedError", }; +enum PayjoinPaymentFailureReason { + "Timeout", + "RequestSendingFailed", + "ResponseProcessingFailed", +}; + [Enum] interface ClosureReason { CounterpartyForceClosed(UntrustedString peer_msg); @@ -284,6 +310,7 @@ interface PaymentKind { Bolt12Offer(PaymentHash? hash, PaymentPreimage? preimage, PaymentSecret? secret, OfferId offer_id); Bolt12Refund(PaymentHash? hash, PaymentPreimage? preimage, PaymentSecret? secret); Spontaneous(PaymentHash hash, PaymentPreimage? preimage); + Payjoin(); }; [Enum] @@ -316,6 +343,8 @@ dictionary PaymentDetails { PaymentDirection direction; PaymentStatus status; u64 latest_update_timestamp; + Txid? txid; + BestBlock? best_block; }; [NonExhaustive] diff --git a/src/builder.rs b/src/builder.rs index a2a93aa79..9f3f4afe5 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::payment::payjoin::handler::PayjoinHandler; use crate::payment::store::PaymentStore; use crate::peer_store::PeerStore; use crate::tx_broadcaster::TransactionBroadcaster; @@ -93,6 +94,11 @@ struct LiquiditySourceConfig { lsps2_service: Option<(SocketAddress, PublicKey, Option)>, } +#[derive(Debug, Clone)] +struct PayjoinConfig { + payjoin_relay: payjoin::Url, +} + impl Default for LiquiditySourceConfig { fn default() -> Self { Self { lsps2_service: None } @@ -132,6 +138,8 @@ pub enum BuildError { WalletSetupFailed, /// We failed to setup the logger. LoggerSetupFailed, + /// Invalid Payjoin configuration. + InvalidPayjoinConfig, } impl fmt::Display for BuildError { @@ -152,6 +160,10 @@ impl fmt::Display for BuildError { Self::KVStoreSetupFailed => write!(f, "Failed to setup KVStore."), Self::WalletSetupFailed => write!(f, "Failed to setup onchain wallet."), Self::LoggerSetupFailed => write!(f, "Failed to setup the logger."), + Self::InvalidPayjoinConfig => write!( + f, + "Invalid Payjoin configuration. Make sure the provided arguments are valid URLs." + ), } } } @@ -172,6 +184,7 @@ pub struct NodeBuilder { chain_data_source_config: Option, gossip_source_config: Option, liquidity_source_config: Option, + payjoin_config: Option, } impl NodeBuilder { @@ -187,12 +200,14 @@ impl NodeBuilder { let chain_data_source_config = None; let gossip_source_config = None; let liquidity_source_config = None; + let payjoin_config = None; Self { config, entropy_source_config, chain_data_source_config, gossip_source_config, liquidity_source_config, + payjoin_config, } } @@ -247,6 +262,14 @@ impl NodeBuilder { self } + /// Configures the [`Node`] instance to enable payjoin transactions. + pub fn set_payjoin_config(&mut self, payjoin_relay: String) -> Result<&mut Self, BuildError> { + let payjoin_relay = + payjoin::Url::parse(&payjoin_relay).map_err(|_| BuildError::InvalidPayjoinConfig)?; + self.payjoin_config = Some(PayjoinConfig { payjoin_relay }); + Ok(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. @@ -365,6 +388,7 @@ impl NodeBuilder { self.chain_data_source_config.as_ref(), self.gossip_source_config.as_ref(), self.liquidity_source_config.as_ref(), + self.payjoin_config.as_ref(), seed_bytes, logger, vss_store, @@ -386,6 +410,7 @@ impl NodeBuilder { self.chain_data_source_config.as_ref(), self.gossip_source_config.as_ref(), self.liquidity_source_config.as_ref(), + self.payjoin_config.as_ref(), seed_bytes, logger, kv_store, @@ -453,6 +478,11 @@ impl ArcedNodeBuilder { self.inner.write().unwrap().set_gossip_source_p2p(); } + /// Configures the [`Node`] instance to enable payjoin transactions. + pub fn set_payjoin_config(&self, payjoin_relay: String) -> Result<(), BuildError> { + self.inner.write().unwrap().set_payjoin_config(payjoin_relay).map(|_| ()) + } + /// 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) { @@ -521,8 +551,9 @@ impl ArcedNodeBuilder { fn build_with_store_internal( config: Arc, chain_data_source_config: Option<&ChainDataSourceConfig>, gossip_source_config: Option<&GossipSourceConfig>, - liquidity_source_config: Option<&LiquiditySourceConfig>, seed_bytes: [u8; 64], - logger: Arc, kv_store: Arc, + liquidity_source_config: Option<&LiquiditySourceConfig>, + payjoin_config: Option<&PayjoinConfig>, seed_bytes: [u8; 64], logger: Arc, + kv_store: Arc, ) -> Result { // Initialize the on-chain wallet and chain access let xprv = bitcoin::bip32::ExtendedPrivKey::new_master(config.network.into(), &seed_bytes) @@ -966,6 +997,17 @@ fn build_with_store_internal( let (stop_sender, _) = tokio::sync::watch::channel(()); let (event_handling_stopped_sender, _) = tokio::sync::watch::channel(()); + let payjoin_handler = payjoin_config.map(|pj_config| { + Arc::new(PayjoinHandler::new( + Arc::clone(&tx_sync), + Arc::clone(&event_queue), + Arc::clone(&logger), + pj_config.payjoin_relay.clone(), + Arc::clone(&payment_store), + Arc::clone(&wallet), + )) + }); + 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)); @@ -987,6 +1029,7 @@ fn build_with_store_internal( channel_manager, chain_monitor, output_sweeper, + payjoin_handler, peer_manager, connection_manager, keys_manager, diff --git a/src/config.rs b/src/config.rs index d0e72080f..0cff9a92a 100644 --- a/src/config.rs +++ b/src/config.rs @@ -40,6 +40,15 @@ pub(crate) const RESOLVED_CHANNEL_MONITOR_ARCHIVAL_INTERVAL: u32 = 6; // The time in-between peer reconnection attempts. pub(crate) const PEER_RECONNECTION_INTERVAL: Duration = Duration::from_secs(10); +// The time before a payjoin http request is considered timed out. +pub(crate) const PAYJOIN_REQUEST_TIMEOUT: Duration = Duration::from_secs(30); + +// The duration between retries of a payjoin http request. +pub(crate) const PAYJOIN_RETRY_INTERVAL: Duration = Duration::from_secs(3); + +// The total duration of retrying to send a payjoin http request. +pub(crate) const PAYJOIN_REQUEST_TOTAL_DURATION: Duration = Duration::from_secs(24 * 60 * 60); + // The time in-between RGS sync attempts. pub(crate) const RGS_SYNC_INTERVAL: Duration = Duration::from_secs(60 * 60); diff --git a/src/error.rs b/src/error.rs index 7506b013b..84b918abe 100644 --- a/src/error.rs +++ b/src/error.rs @@ -99,6 +99,18 @@ pub enum Error { LiquiditySourceUnavailable, /// The given operation failed due to the LSP's required opening fee being too high. LiquidityFeeTooHigh, + /// Failed to access Payjoin object. + PayjoinUnavailable, + /// Payjoin URI is invalid. + PayjoinUriInvalid, + /// Amount is neither user-provided nor defined in the URI. + PayjoinRequestMissingAmount, + /// Failed to build a Payjoin request. + PayjoinRequestCreationFailed, + /// Failed to send Payjoin request. + PayjoinRequestSendingFailed, + /// Payjoin response processing failed. + PayjoinResponseProcessingFailed, } impl fmt::Display for Error { @@ -168,6 +180,30 @@ impl fmt::Display for Error { Self::LiquidityFeeTooHigh => { write!(f, "The given operation failed due to the LSP's required opening fee being too high.") }, + Self::PayjoinUnavailable => { + write!( + f, + "Failed to access Payjoin object. Make sure you have enabled Payjoin support." + ) + }, + Self::PayjoinRequestMissingAmount => { + write!( + f, + "Amount is neither user-provided nor defined in the provided Payjoin URI." + ) + }, + Self::PayjoinRequestCreationFailed => { + write!(f, "Failed construct a Payjoin request") + }, + Self::PayjoinUriInvalid => { + write!(f, "The provided Payjoin URI is invalid") + }, + Self::PayjoinRequestSendingFailed => { + write!(f, "Failed to send Payjoin request") + }, + Self::PayjoinResponseProcessingFailed => { + write!(f, "Payjoin receiver responded to our request with an invalid response") + }, } } } diff --git a/src/event.rs b/src/event.rs index e319ab5e4..99332e762 100644 --- a/src/event.rs +++ b/src/event.rs @@ -143,8 +143,84 @@ pub enum Event { /// This will be `None` for events serialized by LDK Node v0.2.1 and prior. reason: Option, }, + /// This event is emitted when we initiate a Payjoin transaction and before negotiating with + /// the receiver. + PayjoinPaymentPending { + /// Transaction ID of original PSBT. + txid: bitcoin::Txid, + /// Transaction amount as specified in the Payjoin URI in case of using + /// [`PayjoinPayment::send`] or as specified by the user if using + /// [`PayjoinPayment::send_with_amount`]. + /// + /// [`PayjoinPayment::send`]: crate::PayjoinPayment::send + /// [`PayjoinPayment::send_with_amount`]: crate::PayjoinPayment::send_with_amount + amount_sats: u64, + }, + /// This event is emitted when we have successfully negotiated a Payjoin transaction with the + /// receiver and we have finalised and broadcasted the transaction. + /// + /// This does not necessarily imply the Payjoin transaction is fully successful. + PayjoinPaymentBroadcasted { + /// Transaction ID of the finalised Payjoin transaction. i.e., the final transaction + /// after we have successfully negotiated with the receiver. + txid: bitcoin::Txid, + /// Transaction amount as specified in the Payjoin URI in case of using + /// [`PayjoinPayment::send`] or as specified by the user if using + /// [`PayjoinPayment::send_with_amount`]. + /// + /// [`PayjoinPayment::send`]: crate::PayjoinPayment::send + /// [`PayjoinPayment::send_with_amount`]: crate::PayjoinPayment::send_with_amount + amount_sats: u64, + }, + /// This event is emitted when a Payjoin transaction has at least six block confirmations. + PayjoinPaymentSuccessful { + /// This can refer to the original PSBT or to the finalised Payjoin transaction. Depending + /// if the Payjoin negotiation was successful or the receiver decided to broadcast the + /// original PSBT. + txid: bitcoin::Txid, + /// Transaction amount as specified in the Payjoin URI in case of using + /// [`PayjoinPayment::send`] or as specified by the user if using + /// [`PayjoinPayment::send_with_amount`]. + /// + /// [`PayjoinPayment::send`]: crate::PayjoinPayment::send + /// [`PayjoinPayment::send_with_amount`]: crate::PayjoinPayment::send_with_amount + amount_sats: u64, + /// Indicates whether the Payjoin negotiation was successful or the receiver decided to + /// broadcast the original PSBT. + is_original_psbt_modified: bool, + }, + /// A Payjoin transaction has failed. + /// + /// This event is emitted when an attempt to send a Payjoin transaction failed. + PayjoinPaymentFailed { + /// This can refer to the original PSBT or to the finalised Payjoin transaction. Depending + /// on the stage of the Payjoin process when the failure occurred. + txid: bitcoin::Txid, + /// Transaction amount as specified in the Payjoin URI in case of using + /// [`PayjoinPayment::send`] or as specified by the user if using + /// [`PayjoinPayment::send_with_amount`]. + /// + /// [`PayjoinPayment::send`]: crate::PayjoinPayment::send + /// [`PayjoinPayment::send_with_amount`]: crate::PayjoinPayment::send_with_amount + amount_sats: u64, + /// Failure reason. + reason: PayjoinPaymentFailureReason, + }, + /// This event is emitted when the Payjoin receiver has received our offer but decided to + /// broadcast the `original_psbt` without any modifications. i.e., the receiver has declined to + /// participate in the Payjoin transaction and will receive the funds in a regular transaction. + PayjoinPaymentOriginalPsbtBroadcasted { + /// Transaction ID of original PSBT. + txid: bitcoin::Txid, + /// Transaction amount as specified in the Payjoin URI in case of using + /// [`PayjoinPayment::send`] or as specified by the user if using + /// [`PayjoinPayment::send_with_amount`]. + /// + /// [`PayjoinPayment::send`]: crate::PayjoinPayment::send + /// [`PayjoinPayment::send_with_amount`]: crate::PayjoinPayment::send_with_amount + amount_sats: u64, + }, } - impl_writeable_tlv_based_enum!(Event, (0, PaymentSuccessful) => { (0, payment_hash, required), @@ -184,9 +260,58 @@ impl_writeable_tlv_based_enum!(Event, (2, payment_id, required), (4, claimable_amount_msat, required), (6, claim_deadline, option), + }, + (7, PayjoinPaymentPending) => { + (0, txid, required), + (2, amount_sats, required), + }, + (8, PayjoinPaymentBroadcasted) => { + (0, txid, required), + (2, amount_sats, required), + }, + (9, PayjoinPaymentSuccessful) => { + (0, txid, required), + (2, amount_sats, required), + (4, is_original_psbt_modified, required), + }, + (10, PayjoinPaymentFailed) => { + (0, amount_sats, required), + (2, txid, required), + (4, reason, required), + }, + (11, PayjoinPaymentOriginalPsbtBroadcasted) => { + (0, txid, required), + (2, amount_sats, required), }; ); +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PayjoinPaymentFailureReason { + /// The request failed as we did not receive a response in time. + /// + /// This is considered a failure but the receiver can still broadcast the original PSBT, in + /// which case a `PayjoinPaymentOriginalPsbtBroadcasted` event will be emitted. + Timeout, + /// Failed to send the Payjoin request. + /// + /// Sending a Payjoin request can fail due to insufficient funds, network issues, or other reasons. The + /// exact reason can be determined by inspecting the logs. + RequestSendingFailed, + /// Processing the received response failed. + /// + /// The received response was invalid, i.e., the receiver responded with an invalid Payjoin + /// proposal that does not adhere to the [`BIP78`] specification. + /// + /// [`BIP78`]: https://github.com/bitcoin/bips/blob/master/bip-0078.mediawiki + ResponseProcessingFailed, +} + +impl_writeable_tlv_based_enum!(PayjoinPaymentFailureReason, + (0, Timeout) => {}, + (1, RequestSendingFailed) => {}, + (2, ResponseProcessingFailed) => {}; +); + pub struct EventQueue where L::Target: Logger, diff --git a/src/lib.rs b/src/lib.rs index 206fe52d8..48c7728db 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -108,7 +108,10 @@ pub use config::{default_config, AnchorChannelsConfig, Config}; pub use error::Error as NodeError; use error::Error; +#[cfg(feature = "uniffi")] +use crate::event::PayjoinPaymentFailureReason; pub use event::Event; +use payment::payjoin::handler::PayjoinHandler; pub use types::ChannelConfig; pub use io::utils::generate_entropy_mnemonic; @@ -134,8 +137,8 @@ use graph::NetworkGraph; use liquidity::LiquiditySource; use payment::store::PaymentStore; use payment::{ - Bolt11Payment, Bolt12Payment, OnchainPayment, PaymentDetails, SpontaneousPayment, - UnifiedQrPayment, + Bolt11Payment, Bolt12Payment, OnchainPayment, PayjoinPayment, PaymentDetails, + SpontaneousPayment, UnifiedQrPayment, }; use peer_store::{PeerInfo, PeerStore}; use types::{ @@ -188,6 +191,7 @@ pub struct Node { output_sweeper: Arc, peer_manager: Arc, connection_manager: Arc>>, + payjoin_handler: Option>, keys_manager: Arc, network_graph: Arc, gossip_source: Arc, @@ -380,6 +384,8 @@ impl Node { let archive_cmon = Arc::clone(&self.chain_monitor); let sync_sweeper = Arc::clone(&self.output_sweeper); let sync_logger = Arc::clone(&self.logger); + let sync_payjoin = &self.payjoin_handler.as_ref(); + let sync_payjoin = sync_payjoin.map(Arc::clone); let sync_wallet_timestamp = Arc::clone(&self.latest_wallet_sync_timestamp); let sync_monitor_archival_height = Arc::clone(&self.latest_channel_monitor_archival_height); let mut stop_sync = self.stop_sender.subscribe(); @@ -399,11 +405,14 @@ impl Node { return; } _ = wallet_sync_interval.tick() => { - let confirmables = vec![ + let mut confirmables = vec![ &*sync_cman as &(dyn Confirm + Sync + Send), &*sync_cmon as &(dyn Confirm + Sync + Send), &*sync_sweeper as &(dyn Confirm + Sync + Send), ]; + if let Some(sync_payjoin) = sync_payjoin.as_ref() { + confirmables.push(sync_payjoin.as_ref() as &(dyn Confirm + Sync + Send)); + } let now = Instant::now(); let timeout_fut = tokio::time::timeout(Duration::from_secs(LDK_WALLET_SYNC_TIMEOUT_SECS), tx_sync.sync(confirmables)); match timeout_fut.await { @@ -1109,6 +1118,42 @@ impl Node { )) } + /// Returns a Payjoin payment handler allowing to send Payjoin transactions + /// + /// in order to utilize Payjoin functionality, it is necessary to configure a Payjoin relay + /// using [`set_payjoin_config`]. + /// + /// [`set_payjoin_config`]: crate::builder::NodeBuilder::set_payjoin_config + #[cfg(not(feature = "uniffi"))] + pub fn payjoin_payment(&self) -> PayjoinPayment { + let payjoin_handler = self.payjoin_handler.as_ref(); + PayjoinPayment::new( + Arc::clone(&self.config), + Arc::clone(&self.logger), + payjoin_handler.map(Arc::clone), + Arc::clone(&self.runtime), + Arc::clone(&self.tx_broadcaster), + ) + } + + /// Returns a Payjoin payment handler allowing to send Payjoin transactions. + /// + /// in order to utilize Payjoin functionality, it is necessary to configure a Payjoin relay + /// using [`set_payjoin_config`]. + /// + /// [`set_payjoin_config`]: crate::builder::NodeBuilder::set_payjoin_config + #[cfg(feature = "uniffi")] + pub fn payjoin_payment(&self) -> Arc { + let payjoin_handler = self.payjoin_handler.as_ref(); + Arc::new(PayjoinPayment::new( + Arc::clone(&self.config), + Arc::clone(&self.logger), + payjoin_handler.map(Arc::clone), + Arc::clone(&self.runtime), + Arc::clone(&self.tx_broadcaster), + )) + } + /// Retrieve a list of known channels. pub fn list_channels(&self) -> Vec { self.channel_manager.list_channels().into_iter().map(|c| c.into()).collect() @@ -1310,11 +1355,15 @@ impl Node { let fee_estimator = Arc::clone(&self.fee_estimator); let sync_sweeper = Arc::clone(&self.output_sweeper); let sync_logger = Arc::clone(&self.logger); - let confirmables = vec![ + let sync_payjoin = &self.payjoin_handler.as_ref(); + let mut confirmables = vec![ &*sync_cman as &(dyn Confirm + Sync + Send), &*sync_cmon as &(dyn Confirm + Sync + Send), &*sync_sweeper as &(dyn Confirm + Sync + Send), ]; + if let Some(sync_payjoin) = sync_payjoin { + confirmables.push(sync_payjoin.as_ref() as &(dyn Confirm + Sync + Send)); + } let sync_wallet_timestamp = Arc::clone(&self.latest_wallet_sync_timestamp); let sync_fee_rate_update_timestamp = Arc::clone(&self.latest_fee_rate_cache_update_timestamp); diff --git a/src/payment/mod.rs b/src/payment/mod.rs index ac4fc5663..3346e0fd7 100644 --- a/src/payment/mod.rs +++ b/src/payment/mod.rs @@ -3,10 +3,12 @@ mod bolt11; mod bolt12; mod onchain; +pub(crate) mod payjoin; mod spontaneous; pub(crate) mod store; mod unified_qr; +pub use self::payjoin::PayjoinPayment; pub use bolt11::Bolt11Payment; pub use bolt12::Bolt12Payment; pub use onchain::OnchainPayment; diff --git a/src/payment/payjoin/handler.rs b/src/payment/payjoin/handler.rs new file mode 100644 index 000000000..be40a8292 --- /dev/null +++ b/src/payment/payjoin/handler.rs @@ -0,0 +1,264 @@ +use bitcoin::address::NetworkChecked; +use bitcoin::block::Header; +use bitcoin::psbt::Psbt; +use bitcoin::{BlockHash, Script, Transaction, Txid}; + +use crate::config::PAYJOIN_REQUEST_TIMEOUT; +use crate::error::Error; +use crate::event::PayjoinPaymentFailureReason; +use crate::logger::FilesystemLogger; +use crate::payment::store::PaymentDetailsUpdate; +use crate::payment::PaymentKind; +use crate::payment::{PaymentDirection, PaymentStatus}; +use crate::types::{ChainSource, EventQueue, PaymentStore, Wallet}; +use crate::Event; +use crate::PaymentDetails; + +use lightning::chain::transaction::TransactionData; +use lightning::chain::{BestBlock, Confirm, Filter}; +use lightning::ln::channelmanager::PaymentId; +use lightning::log_error; +use lightning::util::logger::Logger; + +use std::sync::Arc; + +pub(crate) struct PayjoinHandler { + chain_source: Arc, + event_queue: Arc, + logger: Arc, + payjoin_relay: payjoin::Url, + payment_store: Arc, + wallet: Arc, +} + +impl PayjoinHandler { + pub(crate) fn new( + chain_source: Arc, event_queue: Arc, + logger: Arc, payjoin_relay: payjoin::Url, + payment_store: Arc, wallet: Arc, + ) -> Self { + Self { chain_source, event_queue, logger, payjoin_relay, payment_store, wallet } + } + + pub(crate) fn start_request( + &self, payjoin_uri: payjoin::Uri<'_, NetworkChecked>, + ) -> Result { + let amount = payjoin_uri.amount.ok_or(Error::PayjoinRequestMissingAmount)?; + let receiver = payjoin_uri.address.clone(); + let original_psbt = + self.wallet.build_payjoin_transaction(amount, receiver.clone().into())?; + let tx = original_psbt.clone().unsigned_tx; + self.payment_store.insert(PaymentDetails::new( + self.payment_id(&original_psbt.unsigned_tx.txid()), + PaymentKind::Payjoin, + Some(amount.to_sat()), + PaymentDirection::Outbound, + PaymentStatus::Pending, + ))?; + self.chain_source.register_tx(&tx.txid(), Script::empty()); + self.event_queue.add_event(Event::PayjoinPaymentPending { + txid: tx.txid(), + amount_sats: amount.to_sat(), + })?; + Ok(original_psbt) + } + + pub(crate) async fn send_request( + &self, payjoin_uri: payjoin::Uri<'_, NetworkChecked>, original_psbt: &mut Psbt, + ) -> Result, Error> { + let (request, context) = payjoin::send::RequestBuilder::from_psbt_and_uri( + original_psbt.clone(), + payjoin_uri.clone(), + ) + .and_then(|b| b.build_non_incentivizing()) + .and_then(|mut c| c.extract_v2(self.payjoin_relay.clone())) + .map_err(|e| { + log_error!(self.logger, "Failed to create Payjoin request: {}", e); + Error::PayjoinRequestCreationFailed + })?; + let mut headers = reqwest::header::HeaderMap::new(); + headers.insert( + reqwest::header::CONTENT_TYPE, + reqwest::header::HeaderValue::from_static("message/ohttp-req"), + ); + let response = reqwest::Client::new() + .post(request.url.clone()) + .body(request.body.clone()) + .timeout(PAYJOIN_REQUEST_TIMEOUT) + .headers(headers) + .send() + .await + .and_then(|r| r.error_for_status()) + .map_err(|e| { + log_error!(self.logger, "Failed to send Payjoin request: {}", e); + Error::PayjoinRequestSendingFailed + })?; + let response = response.bytes().await.map_err(|e| { + log_error!( + self.logger, + "Failed to send Payjoin request, receiver invalid response: {}", + e + ); + Error::PayjoinRequestSendingFailed + })?; + let response = response.to_vec(); + context.process_response(&mut response.as_slice()).map_err(|e| { + log_error!(self.logger, "Failed to process Payjoin response: {}", e); + Error::PayjoinResponseProcessingFailed + }) + } + + pub(crate) fn process_response( + &self, payjoin_proposal: &mut Psbt, original_psbt: &mut Psbt, + ) -> Result { + let wallet = self.wallet.clone(); + wallet.sign_payjoin_proposal(payjoin_proposal, original_psbt)?; + let proposal_tx = payjoin_proposal.clone().extract_tx(); + let payment_store = self.payment_store.clone(); + let payment_id = self.payment_id(&original_psbt.unsigned_tx.txid()); + let payment_details = payment_store.get(&payment_id); + if let Some(payment_details) = payment_details { + let txid = proposal_tx.txid(); + let mut payment_update = PaymentDetailsUpdate::new(payment_id); + payment_update.txid = Some(txid); + payment_store.update(&payment_update)?; + self.chain_source.register_tx(&txid, Script::empty()); + self.event_queue.add_event(Event::PayjoinPaymentBroadcasted { + txid, + amount_sats: payment_details + .amount_msat + .ok_or(Error::PayjoinRequestMissingAmount)?, + })?; + Ok(proposal_tx) + } else { + log_error!(self.logger, "Failed to process Payjoin response: transaction not found"); + Err(Error::PayjoinResponseProcessingFailed) + } + } + + fn payment_id(&self, original_psbt_txid: &Txid) -> PaymentId { + let payment_id: [u8; 32] = + original_psbt_txid[..].try_into().expect("Unreachable, Txid is 32 bytes"); + PaymentId(payment_id) + } + + pub(crate) fn handle_request_failure( + &self, original_psbt: &Psbt, reason: PayjoinPaymentFailureReason, + ) -> Result<(), Error> { + let payment_store = self.payment_store.clone(); + let payment_id = &self.payment_id(&original_psbt.unsigned_tx.txid()); + let payment_details = payment_store.get(payment_id); + if let Some(payment_details) = payment_details { + let mut update_details = PaymentDetailsUpdate::new(payment_id.clone()); + update_details.status = Some(PaymentStatus::Failed); + let _ = payment_store.update(&update_details); + self.event_queue.add_event(Event::PayjoinPaymentFailed { + txid: original_psbt.unsigned_tx.txid(), + amount_sats: payment_details + .amount_msat + .ok_or(Error::PayjoinRequestMissingAmount)?, + reason, + }) + } else { + log_error!( + self.logger, + "Failed to handle request failure for Payjoin payment: transaction not found" + ); + Err(Error::PayjoinRequestSendingFailed) + } + } + + fn internal_transactions_confirmed( + &self, header: &Header, txdata: &TransactionData, height: u32, + ) { + for (_, tx) in txdata { + let confirmed_tx_txid = tx.txid(); + let payment_store = self.payment_store.clone(); + let payment_id = self.payment_id(&confirmed_tx_txid); + let payjoin_tx_filter = |payment_details: &&PaymentDetails| { + payment_details.txid == Some(confirmed_tx_txid) + && payment_details.amount_msat.is_some() + }; + let payjoin_tx_details = payment_store.list_filter(payjoin_tx_filter); + if let Some(payjoin_tx_details) = payjoin_tx_details.get(0) { + let mut payment_update = PaymentDetailsUpdate::new(payjoin_tx_details.id); + payment_update.status = Some(PaymentStatus::Succeeded); + payment_update.best_block = Some(BestBlock::new(header.block_hash(), height)); + let _ = payment_store.update(&payment_update); + let _ = self.event_queue.add_event(Event::PayjoinPaymentSuccessful { + txid: confirmed_tx_txid, + amount_sats: payjoin_tx_details + .amount_msat + .expect("Unreachable, asserted in `payjoin_tx_filter`"), + is_original_psbt_modified: if payment_id == payjoin_tx_details.id { + false + } else { + true + }, + }); + // check if this is original psbt transaction + } else if let Some(payment_details) = payment_store.get(&payment_id) { + let mut payment_update = PaymentDetailsUpdate::new(payment_id); + payment_update.status = Some(PaymentStatus::Succeeded); + let _ = payment_store.update(&payment_update); + payment_update.best_block = Some(BestBlock::new(header.block_hash(), height)); + payment_update.txid = Some(confirmed_tx_txid); + let _ = self.event_queue.add_event(Event::PayjoinPaymentSuccessful { + txid: confirmed_tx_txid, + amount_sats: payment_details + .amount_msat + .expect("Unreachable, payjoin transactions must have amount"), + is_original_psbt_modified: false, + }); + } + } + } + + fn internal_get_relevant_txids(&self) -> Vec<(Txid, u32, Option)> { + let payjoin_tx_filter = |payment_details: &&PaymentDetails| { + payment_details.txid.is_some() + && payment_details.status == PaymentStatus::Succeeded + && payment_details.kind == PaymentKind::Payjoin + }; + let payjoin_tx_details = self.payment_store.list_filter(payjoin_tx_filter); + let mut ret = Vec::new(); + for payjoin_tx_details in payjoin_tx_details { + if let (Some(txid), Some(best_block)) = + (payjoin_tx_details.txid, payjoin_tx_details.best_block) + { + ret.push((txid, best_block.height, Some(best_block.block_hash))); + } + } + ret + } + + fn internal_best_block_updated(&self, height: u32, block_hash: BlockHash) { + let payment_store = self.payment_store.clone(); + let payjoin_tx_filter = |payment_details: &&PaymentDetails| { + payment_details.txid.is_some() && payment_details.kind == PaymentKind::Payjoin + }; + let payjoin_tx_details = payment_store.list_filter(payjoin_tx_filter); + for payjoin_tx_details in payjoin_tx_details { + let mut payment_update = PaymentDetailsUpdate::new(payjoin_tx_details.id); + payment_update.best_block = Some(BestBlock::new(block_hash, height)); + let _ = payment_store.update(&payment_update); + } + } +} + +impl Confirm for PayjoinHandler { + fn transactions_confirmed(&self, header: &Header, txdata: &TransactionData, height: u32) { + self.internal_transactions_confirmed(header, txdata, height); + } + + fn get_relevant_txids(&self) -> Vec<(Txid, u32, Option)> { + self.internal_get_relevant_txids() + } + + fn best_block_updated(&self, header: &Header, height: u32) { + let block_hash = header.block_hash(); + self.internal_best_block_updated(height, block_hash); + } + + fn transaction_unconfirmed(&self, _txid: &Txid) {} +} diff --git a/src/payment/payjoin/mod.rs b/src/payment/payjoin/mod.rs new file mode 100644 index 000000000..456717b0b --- /dev/null +++ b/src/payment/payjoin/mod.rs @@ -0,0 +1,180 @@ +use crate::config::{PAYJOIN_REQUEST_TOTAL_DURATION, PAYJOIN_RETRY_INTERVAL}; +use crate::event::PayjoinPaymentFailureReason; +use crate::logger::{FilesystemLogger, Logger}; +use crate::types::Broadcaster; +use crate::{error::Error, Config}; + +use lightning::chain::chaininterface::BroadcasterInterface; +use lightning::{log_error, log_info}; + +use std::sync::{Arc, RwLock}; + +pub(crate) mod handler; +use handler::PayjoinHandler; + +/// Payjoin payment handler. +/// +/// Payjoin transactions can be used to improve privacy by breaking the common-input-ownership +/// heuristic when Payjoin receivers contribute input(s) to the transaction. They can also be used to +/// save on fees, as the Payjoin receiver can direct the incoming funds to open a lightning +/// channel, forwards the funds to another address, or simply consolidate UTXOs. +/// +/// Payjoin [`BIP77`] implementation. Compatible also with previous Payjoin version [`BIP78`]. +/// +/// Should be retrieved by calling [`Node::payjoin_payment`]. +/// +/// In a Payjoin, both the sender and receiver contribute inputs to the transaction in a +/// coordinated manner. The Payjoin mechanism is also called pay-to-endpoint(P2EP). +/// +/// The Payjoin receiver endpoint address is communicated through a [`BIP21`] URI, along with the +/// payment address and amount. In the Payjoin process, parties edit, sign and pass iterations of +/// the transaction between each other, before a final version is broadcasted by the Payjoin +/// sender. [`BIP77`] codifies a protocol with 2 iterations (or one round of interaction beyond +/// address sharing). +/// +/// [`BIP77`] Defines the Payjoin process to happen asynchronously, with the Payjoin receiver +/// enrolling with a Payjoin Directory to receive Payjoin requests. The Payjoin sender can then +/// make requests through a proxy server, Payjoin Relay, to the Payjoin receiver even if the +/// receiver is offline. This mechanism requires the Payjoin sender to regulary check for responses +/// from the Payjoin receiver as implemented in [`Node::payjoin_payment::send`]. +/// +/// A Payjoin Relay is a proxy server that forwards Payjoin requests from the Payjoin sender to the +/// Payjoin receiver subdirectory. A Payjoin Relay can be run by anyone. Public Payjoin Relay servers are: +/// - +/// +/// A Payjoin directory is a service that allows Payjoin receivers to receive Payjoin requests +/// offline. A Payjoin directory can be run by anyone. Public Payjoin Directory servers are: +/// - +/// +/// For futher information on Payjoin, please refer to the BIPs included in this documentation. Or +/// visit the [Payjoin website](https://payjoin.org). +/// +/// [`Node::payjoin_payment`]: crate::Node::payjoin_payment +/// [`Node::payjoin_payment::send`]: crate::payment::PayjoinPayment::send +/// [`BIP21`]: https://github.com/bitcoin/bips/blob/master/bip-0021.mediawiki +/// [`BIP78`]: https://github.com/bitcoin/bips/blob/master/bip-0078.mediawiki +/// [`BIP77`]: https://github.com/bitcoin/bips/blob/3b863a402e0250658985f08a455a6cd103e269e5/bip-0077.mediawiki +pub struct PayjoinPayment { + config: Arc, + logger: Arc, + payjoin_handler: Option>, + runtime: Arc>>>, + tx_broadcaster: Arc, +} + +impl PayjoinPayment { + pub(crate) fn new( + config: Arc, logger: Arc, + payjoin_handler: Option>, + runtime: Arc>>>, + tx_broadcaster: Arc, + ) -> Self { + Self { config, logger, payjoin_handler, runtime, tx_broadcaster } + } + + /// Send a Payjoin transaction to the address specified in the `payjoin_uri`. + /// + /// The `payjoin_uri` argument is expected to be a valid [`BIP21`] URI with Payjoin parameters. + /// + /// Due to the asynchronous nature of the Payjoin process, this method will return immediately + /// after constructing the Payjoin request and sending it in the background. The result of the + /// operation will be communicated through the event queue. If the Payjoin request is + /// successful, [`Event::PayjoinPaymentSuccessful`] event will be added to the event queue. + /// Otherwise, [`Event::PayjoinPaymentFailed`] is added. + /// + /// The total duration of the Payjoin process is defined in `PAYJOIN_REQUEST_TOTAL_DURATION`. + /// If the Payjoin receiver does not respond within this duration, the process is considered + /// failed. Note, the Payjoin receiver can still broadcast the original PSBT shared with them + /// as part of our request in a regular transaction if we timed out, or for any other reason. + /// The Payjoin sender should monitor the Block-chain for such transactions and handle them + /// accordingly. + /// + /// [`BIP21`]: https://github.com/bitcoin/bips/blob/master/bip-0021.mediawiki + /// [`BIP77`]: https://github.com/bitcoin/bips/blob/d7ffad81e605e958dcf7c2ae1f4c797a8631f146/bip-0077.mediawiki + /// [`Event::PayjoinPaymentSuccessful`]: crate::Event::PayjoinPaymentSuccessful + /// [`Event::PayjoinPaymentFailed`]: crate::Event::PayjoinPaymentFailed + pub fn send(&self, payjoin_uri: String) -> Result<(), Error> { + let rt_lock = self.runtime.read().unwrap(); + if rt_lock.is_none() { + return Err(Error::NotRunning); + } + let payjoin_handler = self.payjoin_handler.as_ref().ok_or(Error::PayjoinUnavailable)?; + let payjoin_uri = + payjoin::Uri::try_from(payjoin_uri).map_err(|_| Error::PayjoinUriInvalid).and_then( + |uri| uri.require_network(self.config.network).map_err(|_| Error::InvalidNetwork), + )?; + let original_psbt = payjoin_handler.start_request(payjoin_uri.clone())?; + let payjoin_handler = Arc::clone(payjoin_handler); + let runtime = rt_lock.as_ref().unwrap(); + let tx_broadcaster = Arc::clone(&self.tx_broadcaster); + let logger = Arc::clone(&self.logger); + runtime.spawn(async move { + let mut interval = tokio::time::interval(PAYJOIN_RETRY_INTERVAL); + loop { + tokio::select! { + _ = tokio::time::sleep(PAYJOIN_REQUEST_TOTAL_DURATION) => { + let _ = payjoin_handler.handle_request_failure(&original_psbt, PayjoinPaymentFailureReason::Timeout); + break; + } + _ = interval.tick() => { + let payjoin_uri = payjoin_uri.clone(); + match payjoin_handler.send_request(payjoin_uri.clone(), &mut original_psbt.clone()).await { + Ok(Some(mut proposal)) => { + match payjoin_handler.process_response(&mut proposal, &mut original_psbt.clone()) { + Ok(tx) => { + tx_broadcaster.broadcast_transactions(&[&tx]); + }, + Err(e) => { + log_error!(logger, "Failed to process Payjoin response: {}", e); + let _ = payjoin_handler.handle_request_failure(&original_psbt, PayjoinPaymentFailureReason::ResponseProcessingFailed); + }, + }; + break; + }, + Ok(None) => { + log_info!(logger, "Payjoin request sent, waiting for response..."); + continue; + } + Err(e) => { + log_error!(logger, "Failed to send Payjoin request : {}", e); + let _ = payjoin_handler.handle_request_failure(&original_psbt, PayjoinPaymentFailureReason::RequestSendingFailed); + break; + }, + } + } + } + } + }); + Ok(()) + } + + /// Send a Payjoin transaction to the address specified in the `payjoin_uri`. + /// + /// The `payjoin_uri` argument is expected to be a valid [`BIP21`] URI with Payjoin parameters. + /// + /// Due to the asynchronous nature of the Payjoin process, this method will return immediately + /// after constructing the Payjoin request and sending it in the background. The result of the + /// operation will be communicated through the event queue. If the Payjoin request is + /// successful, [`Event::PayjoinPaymentSuccessful`] event will be added to the event queue. + /// Otherwise, [`Event::PayjoinPaymentFailed`] is added. + /// + /// The total duration of the Payjoin process is defined in `PAYJOIN_REQUEST_TOTAL_DURATION`. + /// If the Payjoin receiver does not respond within this duration, the process is considered + /// failed. Note, the Payjoin receiver can still broadcast the original PSBT shared with them + /// as part of our request in a regular transaction if we timed out, or for any other reason. + /// The Payjoin sender should monitor the Block-chain for such transactions and handle them + /// accordingly. + /// + /// [`BIP21`]: https://github.com/bitcoin/bips/blob/master/bip-0021.mediawiki + /// [`BIP77`]: https://github.com/bitcoin/bips/blob/d7ffad81e605e958dcf7c2ae1f4c797a8631f146/bip-0077.mediawiki + /// [`Event::PayjoinPaymentSuccessful`]: crate::Event::PayjoinPaymentSuccessful + /// [`Event::PayjoinPaymentFailed`]: crate::Event::PayjoinPaymentFailed + pub fn send_with_amount(&self, payjoin_uri: String, amount_sats: u64) -> Result<(), Error> { + let mut payjoin_uri = + payjoin::Uri::try_from(payjoin_uri).map_err(|_| Error::PayjoinUriInvalid).and_then( + |uri| uri.require_network(self.config.network).map_err(|_| Error::InvalidNetwork), + )?; + payjoin_uri.amount = Some(bitcoin::Amount::from_sat(amount_sats)); + self.send(payjoin_uri.to_string()) + } +} diff --git a/src/payment/store.rs b/src/payment/store.rs index eb3ac091f..ebfc7d155 100644 --- a/src/payment/store.rs +++ b/src/payment/store.rs @@ -6,6 +6,7 @@ use crate::logger::{log_error, Logger}; use crate::types::DynStore; use crate::Error; +use lightning::chain::BestBlock; use lightning::ln::channelmanager::PaymentId; use lightning::ln::msgs::DecodeError; use lightning::ln::{PaymentHash, PaymentPreimage, PaymentSecret}; @@ -37,6 +38,10 @@ pub struct PaymentDetails { pub status: PaymentStatus, /// The timestamp, in seconds since start of the UNIX epoch, when this entry was last updated. pub latest_update_timestamp: u64, + /// txid + pub txid: Option, + /// best_block + pub best_block: Option, } impl PaymentDetails { @@ -48,7 +53,16 @@ impl PaymentDetails { .duration_since(UNIX_EPOCH) .unwrap_or(Duration::from_secs(0)) .as_secs(); - Self { id, kind, amount_msat, direction, status, latest_update_timestamp } + Self { + id, + kind, + amount_msat, + direction, + status, + latest_update_timestamp, + txid: None, + best_block: None, + } } } @@ -67,7 +81,9 @@ impl Writeable for PaymentDetails { (5, self.latest_update_timestamp, required), (6, self.amount_msat, required), (8, self.direction, required), - (10, self.status, required) + (10, self.status, required), + (11, self.txid, option), + (13, self.best_block, option), }); Ok(()) } @@ -88,7 +104,9 @@ impl Readable for PaymentDetails { (5, latest_update_timestamp, (default_value, unix_time_secs)), (6, amount_msat, required), (8, direction, required), - (10, status, required) + (10, status, required), + (11, txid, option), + (13, best_block, option), }); let id: PaymentId = id.0.ok_or(DecodeError::InvalidValue)?; @@ -127,7 +145,16 @@ impl Readable for PaymentDetails { } }; - Ok(PaymentDetails { id, kind, amount_msat, direction, status, latest_update_timestamp }) + Ok(PaymentDetails { + id, + kind, + amount_msat, + direction, + status, + latest_update_timestamp, + txid, + best_block, + }) } } @@ -232,6 +259,8 @@ pub enum PaymentKind { /// The pre-image used by the payment. preimage: Option, }, + /// A Payjoin payment. + Payjoin, } impl_writeable_tlv_based_enum!(PaymentKind, @@ -261,7 +290,8 @@ impl_writeable_tlv_based_enum!(PaymentKind, (0, hash, option), (2, preimage, option), (4, secret, option), - }; + }, + (12, Payjoin) => { }; ); /// Limits applying to how much fee we allow an LSP to deduct from the payment amount. @@ -293,6 +323,8 @@ pub(crate) struct PaymentDetailsUpdate { pub amount_msat: Option>, pub direction: Option, pub status: Option, + pub txid: Option, + pub best_block: Option, } impl PaymentDetailsUpdate { @@ -305,6 +337,8 @@ impl PaymentDetailsUpdate { amount_msat: None, direction: None, status: None, + txid: None, + best_block: None, } } } @@ -415,6 +449,14 @@ where payment.status = status; } + if let Some(txid) = update.txid { + payment.txid = Some(txid); + } + + if let Some(best_block) = update.best_block { + payment.best_block = Some(best_block); + } + payment.latest_update_timestamp = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or(Duration::from_secs(0)) diff --git a/src/types.rs b/src/types.rs index 0c2faeb78..92c4a0aa6 100644 --- a/src/types.rs +++ b/src/types.rs @@ -72,6 +72,10 @@ pub(crate) type Wallet = crate::wallet::Wallet< Arc, >; +pub(crate) type EventQueue = crate::event::EventQueue>; + +pub(crate) type PaymentStore = crate::payment::store::PaymentStore>; + pub(crate) type KeysManager = crate::wallet::WalletKeysManager< bdk::database::SqliteDatabase, Arc, diff --git a/src/wallet.rs b/src/wallet.rs index 0da3f6db8..80d56542d 100644 --- a/src/wallet.rs +++ b/src/wallet.rs @@ -3,6 +3,7 @@ use crate::logger::{log_error, log_info, log_trace, Logger}; use crate::config::BDK_WALLET_SYNC_TIMEOUT_SECS; use crate::Error; +use bitcoin::psbt::Psbt; use lightning::chain::chaininterface::{BroadcasterInterface, ConfirmationTarget, FeeEstimator}; use lightning::events::bump_transaction::{Utxo, WalletSource}; @@ -32,7 +33,7 @@ use bitcoin::psbt::PartiallySignedTransaction; 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::{Amount, ScriptBuf, Transaction, TxOut, Txid}; use std::ops::{Deref, DerefMut}; use std::sync::{Arc, Mutex, RwLock}; @@ -149,6 +150,60 @@ where res } + pub(crate) fn build_payjoin_transaction( + &self, amount: Amount, recipient: ScriptBuf, + ) -> Result { + let fee_rate = self + .fee_estimator + .get_est_sat_per_1000_weight(ConfirmationTarget::OutputSpendingFee) as f32; + let fee_rate = FeeRate::from_sat_per_kwu(fee_rate); + let locked_wallet = self.inner.lock().unwrap(); + let mut tx_builder = locked_wallet.build_tx(); + tx_builder.add_recipient(recipient, amount.to_sat()).fee_rate(fee_rate).enable_rbf(); + let mut psbt = match tx_builder.finish() { + Ok((psbt, _)) => { + log_trace!(self.logger, "Created Payjoin transaction: {:?}", psbt); + psbt + }, + Err(err) => { + log_error!(self.logger, "Failed to create Payjoin transaction: {}", err); + return Err(err.into()); + }, + }; + locked_wallet.sign(&mut psbt, SignOptions::default())?; + Ok(psbt) + } + + pub(crate) fn sign_payjoin_proposal( + &self, payjoin_proposal_psbt: &mut Psbt, original_psbt: &mut Psbt, + ) -> Result { + // BDK only signs scripts that match its target descriptor by iterating through input map. + // The BIP 78 spec makes receiver clear sender input map UTXOs, so process_response will + // fail unless they're cleared. A PSBT unsigned_tx.input references input OutPoints and + // not a Script, so the sender signer must either be able to sign based on OutPoint UTXO + // lookup or otherwise re-introduce the Script from original_psbt. Since BDK PSBT signer + // only checks Input map Scripts for match against its descriptor, it won't sign if they're + // empty. Re-add the scripts from the original_psbt in order for BDK to sign properly. + // reference: https://github.com/bitcoindevkit/bdk-cli/pull/156#discussion_r1261300637 + let mut original_inputs = + original_psbt.unsigned_tx.input.iter().zip(&mut original_psbt.inputs).peekable(); + for (proposed_txin, proposed_psbtin) in + payjoin_proposal_psbt.unsigned_tx.input.iter().zip(&mut payjoin_proposal_psbt.inputs) + { + if let Some((original_txin, original_psbtin)) = original_inputs.peek() { + if proposed_txin.previous_output == original_txin.previous_output { + proposed_psbtin.witness_utxo = original_psbtin.witness_utxo.clone(); + proposed_psbtin.non_witness_utxo = original_psbtin.non_witness_utxo.clone(); + original_inputs.next(); + } + } + } + + let wallet = self.inner.lock().unwrap(); + let is_signed = wallet.sign(payjoin_proposal_psbt, SignOptions::default())?; + Ok(is_signed) + } + 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 5959bd58e..f8d5e2fb2 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -147,6 +147,58 @@ macro_rules! expect_payment_successful_event { pub(crate) use expect_payment_successful_event; +macro_rules! expect_payjoin_tx_pending_event { + ($node: expr) => {{ + match $node.wait_next_event() { + ref e @ Event::PayjoinPaymentPending { txid, .. } => { + println!("{} got event {:?}", $node.node_id(), e); + $node.event_handled(); + txid + }, + ref e => { + panic!("{} got unexpected event!: {:?}", std::stringify!($node), e); + }, + } + }}; +} + +pub(crate) use expect_payjoin_tx_pending_event; + +macro_rules! expect_payjoin_tx_sent_successfully_event { + ($node: expr, $is_original_psbt_modified: expr) => {{ + match $node.wait_next_event() { + ref e @ Event::PayjoinPaymentSuccessful { txid, is_original_psbt_modified, .. } => { + println!("{} got event {:?}", $node.node_id(), e); + assert_eq!(is_original_psbt_modified, $is_original_psbt_modified); + $node.event_handled(); + txid + }, + ref e => { + panic!("{} got unexpected event!: {:?}", std::stringify!($node), e); + }, + } + }}; +} + +pub(crate) use expect_payjoin_tx_sent_successfully_event; + +macro_rules! expect_payjoin_tx_broadcasted { + ($node: expr) => {{ + match $node.wait_next_event() { + ref e @ Event::PayjoinPaymentBroadcasted { txid, .. } => { + println!("{} got event {:?}", $node.node_id(), e); + $node.event_handled(); + txid + }, + ref e => { + panic!("{} got unexpected event!: {:?}", std::stringify!($node), e); + }, + } + }}; +} + +pub(crate) use expect_payjoin_tx_broadcasted; + pub(crate) fn setup_bitcoind_and_electrsd() -> (BitcoinD, ElectrsD) { let bitcoind_exe = env::var("BITCOIND_EXE").ok().or_else(|| bitcoind::downloaded_exe_path().ok()).expect( @@ -270,6 +322,20 @@ pub(crate) fn setup_node(electrsd: &ElectrsD, config: Config) -> TestNode { node } +pub(crate) fn setup_payjoin_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_config(payjoin_relay).unwrap(); + 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..1d73b34c9 --- /dev/null +++ b/tests/integration_tests_payjoin.rs @@ -0,0 +1,216 @@ +mod common; + +use common::{ + expect_payjoin_tx_sent_successfully_event, generate_blocks_and_wait, + premine_and_distribute_funds, setup_bitcoind_and_electrsd, wait_for_tx, +}; + +use bitcoin::Amount; +use ldk_node::{ + payment::{PaymentDirection, PaymentKind, PaymentStatus}, + Event, +}; +use payjoin::{ + receive::v2::{Enrolled, Enroller}, + OhttpKeys, PjUriBuilder, +}; + +use crate::common::{ + expect_payjoin_tx_broadcasted, expect_payjoin_tx_pending_event, random_config, + setup_payjoin_node, setup_two_nodes, +}; + +struct PayjoinReceiver { + ohttp_keys: OhttpKeys, + enrolled: Enrolled, +} + +impl PayjoinReceiver { + fn enroll() -> Self { + let payjoin_directory = payjoin::Url::parse("https://payjo.in").unwrap(); + let payjoin_relay = payjoin::Url::parse("https://pj.bobspacebkk.com").unwrap(); + let ohttp_keys = { + let payjoin_directory = payjoin_directory.join("/ohttp-keys").unwrap(); + let proxy = reqwest::Proxy::all(payjoin_relay.clone()).unwrap(); + let client = reqwest::blocking::Client::builder().proxy(proxy).build().unwrap(); + let response = client.get(payjoin_directory).send().unwrap(); + let response = response.bytes().unwrap(); + OhttpKeys::decode(response.to_vec().as_slice()).unwrap() + }; + let mut enroller = Enroller::from_directory_config( + payjoin_directory.clone(), + ohttp_keys.clone(), + payjoin_relay.clone(), + ); + let (req, ctx) = enroller.extract_req().unwrap(); + let mut headers = reqwest::header::HeaderMap::new(); + headers.insert( + reqwest::header::CONTENT_TYPE, + reqwest::header::HeaderValue::from_static("message/ohttp-req"), + ); + let response = reqwest::blocking::Client::new() + .post(&req.url.to_string()) + .body(req.body) + .headers(headers) + .send() + .unwrap(); + let response = match response.bytes() { + Ok(response) => response, + Err(_) => { + panic!("Error reading response"); + }, + }; + let enrolled = enroller.process_res(response.to_vec().as_slice(), ctx).unwrap(); + Self { ohttp_keys, enrolled } + } + + pub(crate) fn receive( + &self, amount: bitcoin::Amount, receiving_address: bitcoin::Address, + ) -> String { + let enrolled = self.enrolled.clone(); + let fallback_target = enrolled.fallback_target(); + let ohttp_keys = self.ohttp_keys.clone(); + let pj_part = payjoin::Url::parse(&fallback_target).unwrap(); + let payjoin_uri = PjUriBuilder::new(receiving_address, pj_part, Some(ohttp_keys.clone())) + .amount(amount) + .build(); + payjoin_uri.to_string() + } + + pub(crate) fn process_payjoin_request(self, substitue_address: Option) { + let mut enrolled = self.enrolled; + let (req, context) = enrolled.extract_req().unwrap(); + + let client = reqwest::blocking::Client::new(); + let response = client + .post(req.url.to_string()) + .body(req.body) + .headers(PayjoinReceiver::ohttp_headers()) + .send() + .unwrap(); + let response = response.bytes().unwrap(); + let response = enrolled.process_res(response.to_vec().as_slice(), context).unwrap(); + let unchecked_proposal = response.unwrap(); + + let proposal = unchecked_proposal.assume_interactive_receiver(); + let proposal = proposal.check_inputs_not_owned(|_script| Ok(false)).unwrap(); + let proposal = proposal.check_no_mixed_input_scripts().unwrap(); + let proposal = proposal.check_no_inputs_seen_before(|_outpoint| Ok(false)).unwrap(); + let mut provisional_proposal = + proposal.identify_receiver_outputs(|_script| Ok(true)).unwrap(); + + if let Some(substitue_address) = substitue_address { + provisional_proposal.substitute_output_address(substitue_address); + } + + // Finalise Payjoin Proposal + let mut payjoin_proposal = + provisional_proposal.finalize_proposal(|psbt| Ok(psbt.clone()), None).unwrap(); + + let (receiver_request, _) = payjoin_proposal.extract_v2_req().unwrap(); + reqwest::blocking::Client::new() + .post(&receiver_request.url.to_string()) + .body(receiver_request.body) + .headers(PayjoinReceiver::ohttp_headers()) + .send() + .unwrap(); + } + + fn ohttp_headers() -> 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 + } +} + +#[test] +fn send_payjoin_transaction_original_psbt_used() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let config_a = random_config(false); + let (receiver, _) = setup_two_nodes(&electrsd, false, false, false); + let payjoin_sender = setup_payjoin_node(&electrsd, config_a); + let addr_a = payjoin_sender.onchain_payment().new_address().unwrap(); + let premine_amount_sat = 100_000_00; + premine_and_distribute_funds( + &bitcoind.client, + &electrsd.client, + vec![addr_a], + Amount::from_sat(premine_amount_sat), + ); + payjoin_sender.sync_wallets().unwrap(); + assert_eq!(payjoin_sender.list_balances().spendable_onchain_balance_sats, premine_amount_sat); + assert_eq!(payjoin_sender.list_balances().spendable_onchain_balance_sats, 100_000_00); + assert_eq!(payjoin_sender.next_event(), None); + + let payjoin_receiver = PayjoinReceiver::enroll(); + let payjoin_uri = payjoin_receiver + .receive(Amount::from_sat(80_000), receiver.onchain_payment().new_address().unwrap()); + + assert!(payjoin_sender.payjoin_payment().send(payjoin_uri).is_ok()); + + let txid = expect_payjoin_tx_pending_event!(payjoin_sender); + let payments = payjoin_sender.list_payments(); + let payment = payments.first().unwrap(); + assert_eq!(payment.amount_msat, Some(80_000)); + assert_eq!(payment.status, PaymentStatus::Pending); + assert_eq!(payment.direction, PaymentDirection::Outbound); + assert_eq!(payment.kind, PaymentKind::Payjoin); + + payjoin_receiver.process_payjoin_request(None); + + expect_payjoin_tx_broadcasted!(payjoin_sender); + + wait_for_tx(&electrsd.client, txid); + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 1); + payjoin_sender.sync_wallets().unwrap(); + + let _ = expect_payjoin_tx_sent_successfully_event!(payjoin_sender, false); +} + +#[test] +fn send_payjoin_transaction() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let config_a = random_config(false); + let (receiver, _) = setup_two_nodes(&electrsd, false, false, false); + let payjoin_sender = setup_payjoin_node(&electrsd, config_a); + let addr_a = payjoin_sender.onchain_payment().new_address().unwrap(); + let premine_amount_sat = 100_000_00; + premine_and_distribute_funds( + &bitcoind.client, + &electrsd.client, + vec![addr_a], + Amount::from_sat(premine_amount_sat), + ); + payjoin_sender.sync_wallets().unwrap(); + assert_eq!(payjoin_sender.list_balances().spendable_onchain_balance_sats, premine_amount_sat); + assert_eq!(payjoin_sender.list_balances().spendable_onchain_balance_sats, 100_000_00); + assert_eq!(payjoin_sender.next_event(), None); + + let payjoin_receiver = PayjoinReceiver::enroll(); + let payjoin_uri = payjoin_receiver + .receive(Amount::from_sat(80_000), receiver.onchain_payment().new_address().unwrap()); + + assert!(payjoin_sender.payjoin_payment().send(payjoin_uri).is_ok()); + + let _txid = expect_payjoin_tx_pending_event!(payjoin_sender); + let payments = payjoin_sender.list_payments(); + let payment = payments.first().unwrap(); + assert_eq!(payment.amount_msat, Some(80_000)); + assert_eq!(payment.status, PaymentStatus::Pending); + assert_eq!(payment.direction, PaymentDirection::Outbound); + assert_eq!(payment.kind, PaymentKind::Payjoin); + + let substitue_address = receiver.onchain_payment().new_address().unwrap(); + payjoin_receiver.process_payjoin_request(Some(substitue_address)); + + let txid = expect_payjoin_tx_broadcasted!(payjoin_sender); + + wait_for_tx(&electrsd.client, txid); + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 1); + payjoin_sender.sync_wallets().unwrap(); + + expect_payjoin_tx_sent_successfully_event!(payjoin_sender, true); +}