|
| 1 | +// 1. Get payjoin_drectory and payjoin_relay urls |
| 2 | +// 2. Fetch payjoin_directory's ohttp_keys |
| 3 | +// 3. Create enroller from payjoin_directory, ohttp_keys and payjoin_relay |
| 4 | +// 4. Extract request from enroller |
| 5 | +// 5. Send request to payjoin_directory via payjoin_relay to enroll receiver |
| 6 | +// 6. Process response from payjoin_directory with `enroller.process_res` |
| 7 | +// 7. Store the enrolled receiver in `receive_store`(optional) |
| 8 | +// -- At this stage the receiver is enrolled and can receive payjoin requests but is actively polling for them -- |
| 9 | +// -- When constructing payjoin uri use enrolled.fallback_target as payjoin_endpoint and ohttp_keys fetch in step 2 -- |
| 10 | +// |
| 11 | +// For polling payjoin requests |
| 12 | +// 1. start loop { } |
| 13 | +// 2. Extract request from enrolled using `enrolled.extract_req` |
| 14 | +// 3. Send post request with request.url and request.body |
| 15 | +// 4. Process response from payjoin_directory with `enrolled.process_res` |
| 16 | +// 5. If proposal is received(return by `process_res`) break loop |
| 17 | +pub mod error; |
1 | 18 | pub mod scheduler; |
| 19 | + |
| 20 | +use std::{ops::Deref, sync::Arc}; |
| 21 | + |
| 22 | +use bitcoin::{address::NetworkChecked, psbt::Psbt, Address, Amount, ScriptBuf, Transaction}; |
| 23 | +use lightning::ln::ChannelId; |
| 24 | +pub use payjoin::Url; |
| 25 | +use payjoin::{ |
| 26 | + receive::v2::{Enrolled, Enroller, PayjoinProposal, ProvisionalProposal}, |
| 27 | + PjUriBuilder, |
| 28 | +}; |
| 29 | +use tokio::sync::Mutex; |
| 30 | + |
| 31 | +use crate::{ |
| 32 | + error::{Error, InternalError}, |
| 33 | + scheduler::{ChannelScheduler, ScheduledChannel}, |
| 34 | +}; |
| 35 | + |
| 36 | +/// Payjoin `Receiver` trait defines the behavior of the receiver side in a payjoin transaction |
| 37 | +/// withing a lightning network context. |
| 38 | +/// |
| 39 | +/// Utilising payjoin transactions, a lightning node setup as a receiver can fund a channel from an |
| 40 | +/// incoming payjoin transaction saving additional on-chain transaction. |
| 41 | +pub trait Receiver { |
| 42 | + fn get_new_address(&self) -> Result<Address<NetworkChecked>, Error>; |
| 43 | + /// Used to validate that the inputs of the incoming payjoin transaction are not owned by the |
| 44 | + /// receiver. i.e make sure the sender of the transaction is not trying to pay the receiver |
| 45 | + /// using the receiver's own funds. |
| 46 | + fn check_inputs_not_owned(&self, script: &ScriptBuf) -> Result<bool, Error>; |
| 47 | + /// Used to identify ouputs of the incoming payjoin request that are targeted to the receiver. |
| 48 | + fn identify_my_outputs(&self, script: &ScriptBuf) -> Result<bool, Error>; |
| 49 | + /// Sign PSBT with receivers wallet. |
| 50 | + fn sign_tx(&self, psbt: &Psbt) -> Result<Psbt, Error>; |
| 51 | + /// Check if the transaction can be broadcasted. |
| 52 | + /// |
| 53 | + /// This is to make sure the original psbt proposed by the payjoin sender can be broadcasted to |
| 54 | + /// the network. This checks if the transaction violates the consensus or policy rules. |
| 55 | + fn can_broadcast(&self, transaction: &Transaction) -> Result<bool, Error>; |
| 56 | + /// Notify the receiver `ChannelManager` that the funding transaction has been generated. |
| 57 | + /// |
| 58 | + /// This is used after we have created the funding transaction from an incoming payjoin |
| 59 | + /// request, we notify the `ChannelManager` so it can proceed with the channel opening process. |
| 60 | + fn funding_transaction_generated( |
| 61 | + &self, temporary_channel_id: &ChannelId, |
| 62 | + counterparty_node_id: bitcoin::secp256k1::PublicKey, funding_tx: bitcoin::Transaction, |
| 63 | + ) -> Result<(), Error>; |
| 64 | +} |
| 65 | + |
| 66 | +pub struct LightningPayjoin<P: Deref> |
| 67 | +where |
| 68 | + P::Target: Receiver, |
| 69 | +{ |
| 70 | + receiver_handler: P, |
| 71 | + channel_scheduler: Arc<Mutex<ChannelScheduler>>, |
| 72 | + ohttp_keys: payjoin::OhttpKeys, |
| 73 | + enrolled: Enrolled, |
| 74 | +} |
| 75 | + |
| 76 | +impl<P: Deref> LightningPayjoin<P> |
| 77 | +where |
| 78 | + P::Target: Receiver, |
| 79 | +{ |
| 80 | + pub fn new( |
| 81 | + receiver_handler: P, channel_scheduler: Arc<Mutex<ChannelScheduler>>, |
| 82 | + payjoin_directory: Url, payjoin_relay: Url, |
| 83 | + ) -> Self { |
| 84 | + let ohttp_keys = |
| 85 | + fetch_ohttp_keys(payjoin_directory.clone(), payjoin_relay.clone()).unwrap(); |
| 86 | + let mut enroller = Enroller::from_directory_config( |
| 87 | + payjoin_directory.clone(), |
| 88 | + ohttp_keys.clone(), |
| 89 | + payjoin_relay.clone(), |
| 90 | + ); |
| 91 | + let (req, ctx) = enroller.extract_req().unwrap(); |
| 92 | + let agent = reqwest::blocking::Client::new(); |
| 93 | + let ohttp_response = agent |
| 94 | + .post(req.url) |
| 95 | + .body(req.body) |
| 96 | + .header("Content-Type", payjoin::V2_REQ_CONTENT_TYPE) |
| 97 | + .send() |
| 98 | + .unwrap(); |
| 99 | + let mut ohttp_response = ohttp_response.error_for_status().unwrap(); |
| 100 | + let mut body = Vec::new(); |
| 101 | + let _ = ohttp_response.copy_to(&mut body).unwrap(); |
| 102 | + let enrolled = enroller.process_res(body.as_slice(), ctx).unwrap(); |
| 103 | + |
| 104 | + Self { receiver_handler, channel_scheduler, ohttp_keys, enrolled } |
| 105 | + } |
| 106 | + |
| 107 | + pub async fn process_request(&mut self) -> Result<(), Error> { |
| 108 | + let proposal = match self.fetch_payjoin_proposal().await { |
| 109 | + Ok(Some(proposal)) => proposal, |
| 110 | + Ok(None) => return Ok(()), |
| 111 | + Err(e) => return Err(e), |
| 112 | + }; |
| 113 | + |
| 114 | + let min_fee_rate = bitcoin::FeeRate::from_sat_per_vb(1); |
| 115 | + let provisional_proposal = self.validate_payjoin_request(proposal, min_fee_rate)?; |
| 116 | + let channel = self |
| 117 | + .channel_scheduler |
| 118 | + .lock() |
| 119 | + .await |
| 120 | + .get_next_channel(bitcoin::Amount::from_sat(provisional_proposal.payjoin_amount())) |
| 121 | + .cloned(); |
| 122 | + let finalized_proposal = if channel.is_some() { |
| 123 | + self.accept_payjoin_with_channel_opening(provisional_proposal, channel.unwrap()) |
| 124 | + .await |
| 125 | + .unwrap() |
| 126 | + } else { |
| 127 | + self.accept_payjoin_without_channel_opening(provisional_proposal).unwrap() |
| 128 | + }; |
| 129 | + self.send_response(finalized_proposal).await |
| 130 | + } |
| 131 | + |
| 132 | + async fn send_response(&self, mut proposal: PayjoinProposal) -> Result<(), Error> { |
| 133 | + let (res, _ctx) = proposal.extract_v2_req()?; |
| 134 | + let agent = reqwest::Client::new(); |
| 135 | + let ohttp_response = agent |
| 136 | + .post(res.url.as_str()) |
| 137 | + .header("Content-Type", payjoin::V2_REQ_CONTENT_TYPE) |
| 138 | + .body(res.body) |
| 139 | + .send() |
| 140 | + .await |
| 141 | + .unwrap(); |
| 142 | + assert!(ohttp_response.status().is_success()); |
| 143 | + Ok(()) |
| 144 | + } |
| 145 | + |
| 146 | + async fn fetch_payjoin_proposal( |
| 147 | + &mut self, |
| 148 | + ) -> Result<Option<payjoin::receive::v2::UncheckedProposal>, Error> { |
| 149 | + let (req, context) = self.enrolled.extract_req().unwrap(); |
| 150 | + let agent = reqwest::Client::new(); |
| 151 | + let ohttp_response = agent |
| 152 | + .post(req.clone().url.clone().as_str()) |
| 153 | + .header("Content-Type", payjoin::V2_REQ_CONTENT_TYPE) |
| 154 | + .body(req.body) |
| 155 | + .send() |
| 156 | + .await; |
| 157 | + match ohttp_response { |
| 158 | + Ok(ohttp_response) => { |
| 159 | + let ohttp_response = ohttp_response.error_for_status().unwrap(); |
| 160 | + let body = ohttp_response.bytes().await.unwrap(); |
| 161 | + let body = body.as_ref(); |
| 162 | + match self.enrolled.process_res(body, context).map_err(Error::from) { |
| 163 | + Ok(Some(proposal)) => { |
| 164 | + return Ok(Some(proposal)); |
| 165 | + }, |
| 166 | + Ok(None) => Ok(None), |
| 167 | + Err(_) => panic!("Error processing response"), |
| 168 | + } |
| 169 | + }, |
| 170 | + Err(_e) => Ok(None), |
| 171 | + } |
| 172 | + } |
| 173 | + |
| 174 | + pub fn construct_payjoin_uri(&self, amount: Amount) -> String { |
| 175 | + let pj_receiver_address = self.receiver_handler.get_new_address().unwrap(); |
| 176 | + let pj_part = payjoin::Url::parse(&self.enrolled.fallback_target()).unwrap(); |
| 177 | + let pj_uri = PjUriBuilder::new(pj_receiver_address, pj_part, Some(self.ohttp_keys.clone())) |
| 178 | + .amount(amount) |
| 179 | + .build(); |
| 180 | + pj_uri.to_string() |
| 181 | + } |
| 182 | + |
| 183 | + fn validate_payjoin_request( |
| 184 | + &self, proposal: payjoin::receive::v2::UncheckedProposal, |
| 185 | + min_fee_rate: Option<bitcoin::FeeRate>, |
| 186 | + ) -> Result<ProvisionalProposal, Error> { |
| 187 | + let proposal = proposal.check_broadcast_suitability(min_fee_rate, |t| { |
| 188 | + Ok(self.receiver_handler.can_broadcast(t)?) |
| 189 | + })?; |
| 190 | + let proposal = proposal.check_inputs_not_owned(|script| { |
| 191 | + Ok(self.receiver_handler.check_inputs_not_owned(&script.to_owned())?) |
| 192 | + })?; |
| 193 | + let proposal = proposal.check_no_mixed_input_scripts()?; |
| 194 | + let proposal = proposal.check_no_inputs_seen_before(|_outpoint| Ok(false))?; |
| 195 | + let original_proposal = proposal.clone().identify_receiver_outputs(|script| { |
| 196 | + Ok(self.receiver_handler.identify_my_outputs(&script.to_owned())?) |
| 197 | + })?; |
| 198 | + Ok(original_proposal) |
| 199 | + } |
| 200 | + |
| 201 | + fn accept_payjoin_without_channel_opening( |
| 202 | + &self, provisional_proposal: ProvisionalProposal, |
| 203 | + ) -> Result<PayjoinProposal, Box<dyn std::error::Error>> { |
| 204 | + // fixme: instead of psbt.clone() we should sign the psbt, |
| 205 | + // but BDK adds bip32 derivation paths to the psbt which is not supported by payjoin |
| 206 | + let finalized_proposal = |
| 207 | + provisional_proposal.finalize_proposal(|psbt| Ok(psbt.clone()), None).unwrap(); |
| 208 | + Ok(finalized_proposal) |
| 209 | + } |
| 210 | + |
| 211 | + async fn accept_payjoin_with_channel_opening( |
| 212 | + &self, provisional_proposal: ProvisionalProposal, channel: ScheduledChannel, |
| 213 | + ) -> Result<PayjoinProposal, Error> { |
| 214 | + let mut proposal = provisional_proposal.clone(); |
| 215 | + let output_script = channel.output_script().expect("Output script should exist"); |
| 216 | + // substitute the output script with the channel output script |
| 217 | + proposal.substitute_output_script(output_script.clone()); |
| 218 | + let finalized_proposal = proposal |
| 219 | + .clone() |
| 220 | + .finalize_proposal(|psbt| Ok(self.receiver_handler.sign_tx(&psbt)?), None)?; |
| 221 | + let psbt = finalized_proposal.psbt(); |
| 222 | + let funding_tx = psbt.clone().extract_tx(); |
| 223 | + // tell the channel_scheduler that the funding tx has been created |
| 224 | + self.channel_scheduler |
| 225 | + .lock() |
| 226 | + .await |
| 227 | + .set_funding_tx_created(channel.channel_id(), psbt.clone()); |
| 228 | + let temporary_channel_id = |
| 229 | + channel.temporary_channel_id().expect("Temporary channel id should exist"); |
| 230 | + // tell the counterparty node that the funding tx has been created |
| 231 | + let _ = self.receiver_handler.funding_transaction_generated( |
| 232 | + &temporary_channel_id, |
| 233 | + channel.node_id(), |
| 234 | + funding_tx.clone(), |
| 235 | + )?; |
| 236 | + // wait for the counterparty node to return FundingSigned message |
| 237 | + let channel_scheduler = self.channel_scheduler.clone(); |
| 238 | + let is_funding_signed = |
| 239 | + tokio::time::timeout(tokio::time::Duration::from_secs(3), async move { |
| 240 | + loop { |
| 241 | + let txid = funding_tx.txid(); |
| 242 | + if channel_scheduler.lock().await.is_funding_tx_signed(&txid) { |
| 243 | + break; |
| 244 | + } |
| 245 | + } |
| 246 | + }) |
| 247 | + .await; |
| 248 | + match is_funding_signed { |
| 249 | + Ok(_) => { |
| 250 | + return Ok(finalized_proposal); |
| 251 | + }, |
| 252 | + Err(_) => { |
| 253 | + return Err(Error::Internal(InternalError::FundingTxNotSigned)); |
| 254 | + }, |
| 255 | + } |
| 256 | + } |
| 257 | +} |
| 258 | + |
| 259 | +// should be used from payjoin_defaults crate, which will be published in the next release. |
| 260 | +// https://github.com/payjoin/rust-payjoin/tree/master/payjoin-defaults |
| 261 | +fn fetch_ohttp_keys( |
| 262 | + payjoin_directory: Url, payjoin_relay: Url, |
| 263 | +) -> Result<payjoin::OhttpKeys, Error> { |
| 264 | + let ohttp_keys_url = payjoin_directory.join("/ohttp-keys").unwrap(); |
| 265 | + let proxy = reqwest::blocking::Client::builder() |
| 266 | + .proxy(reqwest::Proxy::all(payjoin_relay).unwrap()) |
| 267 | + .build() |
| 268 | + .unwrap(); |
| 269 | + let mut res = proxy.get(ohttp_keys_url.as_str()).send().unwrap(); |
| 270 | + let mut body = Vec::new(); |
| 271 | + let _ = res.copy_to(&mut body).unwrap(); |
| 272 | + Ok(payjoin::OhttpKeys::decode(body.as_slice()).unwrap()) |
| 273 | +} |
0 commit comments