|
| 1 | +use crate::error::Error; |
| 2 | +use crate::io::utils::ohttp_headers; |
| 3 | +use crate::logger::FilesystemLogger; |
| 4 | +use crate::types::Wallet; |
| 5 | +use lightning::log_info; |
| 6 | +use lightning::util::logger::Logger; |
| 7 | +use payjoin::receive::v2::{Enrolled, Enroller, PayjoinProposal, UncheckedProposal}; |
| 8 | +use payjoin::{OhttpKeys, PjUriBuilder}; |
| 9 | +use payjoin::{PjUri, Url}; |
| 10 | +use std::ops::Deref; |
| 11 | +use std::sync::Arc; |
| 12 | +use tokio::sync::RwLock; |
| 13 | + |
| 14 | +pub(crate) struct PayjoinReceiver { |
| 15 | + logger: Arc<FilesystemLogger>, |
| 16 | + wallet: Arc<Wallet>, |
| 17 | + payjoin_directory: Url, |
| 18 | + payjoin_relay: Url, |
| 19 | + enrolled: RwLock<Option<Enrolled>>, |
| 20 | + ohttp_keys: RwLock<Option<OhttpKeys>>, |
| 21 | +} |
| 22 | + |
| 23 | +impl PayjoinReceiver { |
| 24 | + pub(crate) fn new( |
| 25 | + logger: Arc<FilesystemLogger>, wallet: Arc<Wallet>, payjoin_relay: Url, |
| 26 | + payjoin_directory: Url, ohttp_keys: Option<OhttpKeys>, |
| 27 | + ) -> Self { |
| 28 | + Self { |
| 29 | + logger, |
| 30 | + wallet, |
| 31 | + payjoin_directory, |
| 32 | + payjoin_relay, |
| 33 | + enrolled: RwLock::new(None), |
| 34 | + ohttp_keys: RwLock::new(ohttp_keys), |
| 35 | + } |
| 36 | + } |
| 37 | + |
| 38 | + pub(crate) async fn receive(&self, amount: bitcoin::Amount) -> Result<PjUri, Error> { |
| 39 | + if !self.is_enrolled().await { |
| 40 | + self.enroll().await?; |
| 41 | + } |
| 42 | + let enrolled = self.enrolled.read().await; |
| 43 | + let enrolled = match enrolled.as_ref() { |
| 44 | + Some(enrolled) => enrolled, |
| 45 | + None => { |
| 46 | + log_info!(self.logger, "Payjoin Receiver: Not enrolled"); |
| 47 | + return Err(Error::PayjoinReceiverUnavailable); |
| 48 | + }, |
| 49 | + }; |
| 50 | + let fallback_target = enrolled.fallback_target(); |
| 51 | + let ohttp_keys = self.ohttp_keys.read().await; |
| 52 | + let ohttp_keys = match ohttp_keys.as_ref() { |
| 53 | + Some(okeys) => okeys, |
| 54 | + None => { |
| 55 | + log_info!(self.logger, "Payjoin Receiver: No ohttp keys"); |
| 56 | + return Err(Error::PayjoinReceiverUnavailable); |
| 57 | + }, |
| 58 | + }; |
| 59 | + let address = self.wallet.get_new_address()?; |
| 60 | + let pj_part = match payjoin::Url::parse(&fallback_target) { |
| 61 | + Ok(pj_part) => pj_part, |
| 62 | + Err(_) => { |
| 63 | + log_info!(self.logger, "Payjoin Receiver: Invalid fallback target"); |
| 64 | + return Err(Error::PayjoinReceiverUnavailable); |
| 65 | + }, |
| 66 | + }; |
| 67 | + let payjoin_uri = |
| 68 | + PjUriBuilder::new(address, pj_part, Some(ohttp_keys.clone())).amount(amount).build(); |
| 69 | + Ok(payjoin_uri) |
| 70 | + } |
| 71 | + |
| 72 | + pub(crate) async fn process_payjoin_request(&self) { |
| 73 | + let mut enrolled = self.enrolled.write().await; |
| 74 | + if let Some(mut enrolled) = enrolled.take() { |
| 75 | + let min_fee_rate = bitcoin::FeeRate::from_sat_per_vb(1); // FIXME: Use a real fee rate |
| 76 | + let (req, context) = match enrolled.extract_req() { |
| 77 | + Ok(req) => req, |
| 78 | + Err(e) => { |
| 79 | + log_info!( |
| 80 | + self.logger, |
| 81 | + "Payjoin Receiver: Unable to extract enrollement request and context{}", |
| 82 | + e |
| 83 | + ); |
| 84 | + return; |
| 85 | + }, |
| 86 | + }; |
| 87 | + |
| 88 | + let client = reqwest::Client::new(); |
| 89 | + let response = match client |
| 90 | + .post(req.url.to_string()) |
| 91 | + .body(req.body) |
| 92 | + .headers(ohttp_headers()) |
| 93 | + .send() |
| 94 | + .await |
| 95 | + { |
| 96 | + Ok(response) => response, |
| 97 | + Err(e) => { |
| 98 | + log_info!( |
| 99 | + self.logger, |
| 100 | + "Payjoin Receiver: Unable to fetch payjoin request {}", |
| 101 | + e |
| 102 | + ); |
| 103 | + return; |
| 104 | + }, |
| 105 | + }; |
| 106 | + if response.status() != reqwest::StatusCode::OK { |
| 107 | + log_info!( |
| 108 | + self.logger, |
| 109 | + "Payjoin Receiver: Got non-200 response from directory server {}", |
| 110 | + response.status() |
| 111 | + ); |
| 112 | + return; |
| 113 | + }; |
| 114 | + let response = match response.bytes().await { |
| 115 | + Ok(response) => response, |
| 116 | + Err(e) => { |
| 117 | + log_info!(self.logger, "Payjoin Receiver: Error reading response {}", e); |
| 118 | + return; |
| 119 | + }, |
| 120 | + }; |
| 121 | + if response.is_empty() { |
| 122 | + log_info!(self.logger, "Payjoin Receiver: Empty response from directory server"); |
| 123 | + return; |
| 124 | + }; |
| 125 | + let response = match enrolled.process_res(response.to_vec().as_slice(), context) { |
| 126 | + Ok(response) => response, |
| 127 | + Err(e) => { |
| 128 | + log_info!( |
| 129 | + self.logger, |
| 130 | + "Payjoin Receiver: Unable to process payjoin request {}", |
| 131 | + e |
| 132 | + ); |
| 133 | + return; |
| 134 | + }, |
| 135 | + }; |
| 136 | + let unchecked_proposal = match response { |
| 137 | + Some(proposal) => proposal, |
| 138 | + None => { |
| 139 | + return; |
| 140 | + }, |
| 141 | + }; |
| 142 | + let mut payjoin_proposal = |
| 143 | + match self.validate_payjoin_request(unchecked_proposal, min_fee_rate).await { |
| 144 | + Ok(proposal) => proposal, |
| 145 | + Err(e) => { |
| 146 | + log_info!(self.logger, "Payjoin Validation: {}", e); |
| 147 | + return; |
| 148 | + }, |
| 149 | + }; |
| 150 | + let (receiver_request, _) = match payjoin_proposal.extract_v2_req() { |
| 151 | + Ok(req) => req, |
| 152 | + Err(e) => { |
| 153 | + log_info!(self.logger, "Payjoin Receiver: Unable to extract V2 request {}", e); |
| 154 | + return; |
| 155 | + }, |
| 156 | + }; |
| 157 | + match reqwest::Client::new() |
| 158 | + .post(&receiver_request.url.to_string()) |
| 159 | + .body(receiver_request.body) |
| 160 | + .headers(ohttp_headers()) |
| 161 | + .send() |
| 162 | + .await |
| 163 | + { |
| 164 | + Ok(response) => { |
| 165 | + if response.status() == reqwest::StatusCode::OK { |
| 166 | + log_info!(self.logger, "Payjoin Receiver: Payjoin response sent to sender"); |
| 167 | + } else { |
| 168 | + log_info!( |
| 169 | + self.logger, |
| 170 | + "Payjoin Receiver: Got non-200 response from directory {}", |
| 171 | + response.status() |
| 172 | + ); |
| 173 | + } |
| 174 | + }, |
| 175 | + Err(e) => { |
| 176 | + log_info!( |
| 177 | + self.logger, |
| 178 | + "Payjoin Receiver: Unable to make request to directory {}", |
| 179 | + e |
| 180 | + ); |
| 181 | + }, |
| 182 | + } |
| 183 | + } else { |
| 184 | + log_info!(self.logger, "Payjoin Receiver: Unable to get enrolled object"); |
| 185 | + } |
| 186 | + } |
| 187 | + |
| 188 | + async fn enroll(&self) -> Result<(), Error> { |
| 189 | + let ohttp_keys = match self.ohttp_keys.read().await.deref() { |
| 190 | + Some(okeys) => okeys.clone(), |
| 191 | + None => { |
| 192 | + let payjoin_directory = &self.payjoin_directory; |
| 193 | + let payjoin_directory = match payjoin_directory.join("/ohttp-keys") { |
| 194 | + Ok(payjoin_directory) => payjoin_directory, |
| 195 | + Err(e) => { |
| 196 | + log_info!( |
| 197 | + self.logger, |
| 198 | + "Payjoin Receiver: Unable to construct ohttp keys url {}", |
| 199 | + e |
| 200 | + ); |
| 201 | + return Err(Error::PayjoinReceiverEnrollementFailed); |
| 202 | + }, |
| 203 | + }; |
| 204 | + let proxy = match reqwest::Proxy::all(self.payjoin_relay.to_string()) { |
| 205 | + Ok(proxy) => proxy, |
| 206 | + Err(e) => { |
| 207 | + log_info!( |
| 208 | + self.logger, |
| 209 | + "Payjoin Receiver: Unable to construct reqwest proxy {}", |
| 210 | + e |
| 211 | + ); |
| 212 | + return Err(Error::PayjoinReceiverEnrollementFailed); |
| 213 | + }, |
| 214 | + }; |
| 215 | + let client = match reqwest::Client::builder().proxy(proxy).build() { |
| 216 | + Ok(client) => client, |
| 217 | + Err(e) => { |
| 218 | + log_info!( |
| 219 | + self.logger, |
| 220 | + "Payjoin Receiver: Unable to construct reqwest client {}", |
| 221 | + e |
| 222 | + ); |
| 223 | + return Err(Error::PayjoinReceiverEnrollementFailed); |
| 224 | + }, |
| 225 | + }; |
| 226 | + let response = match client.get(payjoin_directory).send().await { |
| 227 | + Ok(response) => response, |
| 228 | + Err(e) => { |
| 229 | + log_info!( |
| 230 | + self.logger, |
| 231 | + "Payjoin Receiver: Unable to make request to fetch ohttp keys {}", |
| 232 | + e |
| 233 | + ); |
| 234 | + return Err(Error::PayjoinReceiverEnrollementFailed); |
| 235 | + }, |
| 236 | + }; |
| 237 | + if response.status() != reqwest::StatusCode::OK { |
| 238 | + log_info!( |
| 239 | + self.logger, |
| 240 | + "Payjoin Receiver: Got non 200 response when fetching ohttp keys {}", |
| 241 | + response.status() |
| 242 | + ); |
| 243 | + return Err(Error::PayjoinReceiverEnrollementFailed); |
| 244 | + } |
| 245 | + let response = match response.bytes().await { |
| 246 | + Ok(response) => response, |
| 247 | + Err(e) => { |
| 248 | + log_info!( |
| 249 | + self.logger, |
| 250 | + "Payjoin Receiver: Error reading ohttp keys response {}", |
| 251 | + e |
| 252 | + ); |
| 253 | + return Err(Error::PayjoinReceiverEnrollementFailed); |
| 254 | + }, |
| 255 | + }; |
| 256 | + OhttpKeys::decode(response.to_vec().as_slice()).map_err(|e| { |
| 257 | + log_info!(self.logger, "Payjoin Receiver: Unable to decode ohttp keys {}", e); |
| 258 | + Error::PayjoinReceiverEnrollementFailed |
| 259 | + })? |
| 260 | + }, |
| 261 | + }; |
| 262 | + let mut enroller = Enroller::from_directory_config( |
| 263 | + self.payjoin_directory.clone(), |
| 264 | + ohttp_keys.clone(), |
| 265 | + self.payjoin_relay.clone(), |
| 266 | + ); |
| 267 | + let (req, ctx) = match enroller.extract_req() { |
| 268 | + Ok(req) => req, |
| 269 | + Err(e) => { |
| 270 | + log_info!( |
| 271 | + self.logger, |
| 272 | + "Payjoin Receiver: unable to extract enrollement request {}", |
| 273 | + e |
| 274 | + ); |
| 275 | + return Err(Error::PayjoinReceiverEnrollementFailed); |
| 276 | + }, |
| 277 | + }; |
| 278 | + let response = match reqwest::Client::new() |
| 279 | + .post(&req.url.to_string()) |
| 280 | + .body(req.body) |
| 281 | + .headers(ohttp_headers()) |
| 282 | + .send() |
| 283 | + .await |
| 284 | + { |
| 285 | + Ok(response) => response, |
| 286 | + Err(_) => { |
| 287 | + log_info!(self.logger, "Payjoin Receiver: unable to make enrollement request"); |
| 288 | + return Err(Error::PayjoinReceiverEnrollementFailed); |
| 289 | + }, |
| 290 | + }; |
| 291 | + let response = match response.bytes().await { |
| 292 | + Ok(response) => response, |
| 293 | + Err(_) => { |
| 294 | + panic!("Error reading response"); |
| 295 | + }, |
| 296 | + }; |
| 297 | + let enrolled = match enroller.process_res(response.to_vec().as_slice(), ctx) { |
| 298 | + Ok(enrolled) => enrolled, |
| 299 | + Err(e) => { |
| 300 | + log_info!( |
| 301 | + self.logger, |
| 302 | + "Payjoin Receiver: unable to process enrollement response {}", |
| 303 | + e |
| 304 | + ); |
| 305 | + return Err(Error::PayjoinReceiverEnrollementFailed); |
| 306 | + }, |
| 307 | + }; |
| 308 | + |
| 309 | + *self.ohttp_keys.write().await = Some(ohttp_keys); |
| 310 | + *self.enrolled.write().await = Some(enrolled); |
| 311 | + Ok(()) |
| 312 | + } |
| 313 | + |
| 314 | + async fn is_enrolled(&self) -> bool { |
| 315 | + self.enrolled.read().await.deref().is_some() |
| 316 | + && self.ohttp_keys.read().await.deref().is_some() |
| 317 | + } |
| 318 | + |
| 319 | + async fn validate_payjoin_request( |
| 320 | + &self, proposal: UncheckedProposal, min_fee_rate: Option<bitcoin::FeeRate>, |
| 321 | + ) -> Result<PayjoinProposal, Error> { |
| 322 | + let tx = proposal.extract_tx_to_schedule_broadcast(); |
| 323 | + let wallet = &self.wallet; |
| 324 | + let verified = wallet.verify_tx(&tx).await; |
| 325 | + if verified.is_err() { |
| 326 | + log_info!(self.logger, "Invalid transaction"); |
| 327 | + return Err(Error::PayjoinReceiverRequestValidationFailed); |
| 328 | + }; |
| 329 | + let proposal = proposal |
| 330 | + .check_broadcast_suitability(min_fee_rate, |_t| Ok(verified.is_ok())) |
| 331 | + .map_err(|e| { |
| 332 | + log_info!(self.logger, "Broadcast suitability check failed {}", e); |
| 333 | + Error::PayjoinReceiverRequestValidationFailed |
| 334 | + })?; |
| 335 | + let proposal = proposal |
| 336 | + .check_inputs_not_owned(|script| { |
| 337 | + Ok(wallet.is_mine(&script.to_owned()).unwrap_or(false)) |
| 338 | + }) |
| 339 | + .map_err(|e| { |
| 340 | + log_info!(self.logger, "Inputs owned by us check failed {}", e); |
| 341 | + Error::PayjoinReceiverRequestValidationFailed |
| 342 | + })?; |
| 343 | + let proposal = proposal.check_no_mixed_input_scripts().map_err(|e| { |
| 344 | + log_info!(self.logger, "Mixed input scripts check failed {}", e); |
| 345 | + Error::PayjoinReceiverRequestValidationFailed |
| 346 | + })?; |
| 347 | + // Fixme: discuss how to handle this, instead of the Ok(false) we should have a way to |
| 348 | + // store seen outpoints and check against them |
| 349 | + let proposal = |
| 350 | + proposal.check_no_inputs_seen_before(|_outpoint| Ok(false)).map_err(|e| { |
| 351 | + log_info!(self.logger, "Inputs seen before check failed {}", e); |
| 352 | + Error::PayjoinReceiverRequestValidationFailed |
| 353 | + })?; |
| 354 | + let mut provisional_proposal = proposal |
| 355 | + .identify_receiver_outputs(|script| { |
| 356 | + Ok(wallet.is_mine(&script.to_owned()).unwrap_or(false)) |
| 357 | + }) |
| 358 | + .map_err(|e| { |
| 359 | + log_info!(self.logger, "Identify receiver outputs failed {}", e); |
| 360 | + Error::PayjoinReceiverRequestValidationFailed |
| 361 | + })?; |
| 362 | + { |
| 363 | + let (candidate_inputs, utxo_set) = wallet.payjoin_receiver_candidate_input()?; |
| 364 | + match provisional_proposal.try_preserving_privacy(candidate_inputs) { |
| 365 | + Ok(selected_outpoint) => { |
| 366 | + if let Some(selected_utxo) = utxo_set.iter().find(|i| { |
| 367 | + i.outpoint.txid == selected_outpoint.txid |
| 368 | + && i.outpoint.vout == selected_outpoint.vout |
| 369 | + }) { |
| 370 | + let txo_to_contribute = bitcoin::TxOut { |
| 371 | + value: selected_utxo.txout.value, |
| 372 | + script_pubkey: selected_utxo.txout.script_pubkey.clone(), |
| 373 | + }; |
| 374 | + let outpoint_to_contribute = bitcoin::OutPoint { |
| 375 | + txid: selected_utxo.outpoint.txid, |
| 376 | + vout: selected_utxo.outpoint.vout, |
| 377 | + }; |
| 378 | + provisional_proposal |
| 379 | + .contribute_witness_input(txo_to_contribute, outpoint_to_contribute); |
| 380 | + } |
| 381 | + }, |
| 382 | + Err(_) => { |
| 383 | + log_info!(self.logger, "Failed to select utxos to improve payjoin request privacy. Payjoin proceeds regardless"); |
| 384 | + }, |
| 385 | + }; |
| 386 | + }; |
| 387 | + let payjoin_proposal = provisional_proposal |
| 388 | + .finalize_proposal( |
| 389 | + |psbt| Ok(wallet.sign_provisional_payjoin_proposal(psbt.clone()).unwrap()), |
| 390 | + None, |
| 391 | + ) |
| 392 | + .map_err(|e| { |
| 393 | + log_info!(self.logger, "Finalize proposal failed {}", e); |
| 394 | + Error::PayjoinReceiverRequestValidationFailed |
| 395 | + })?; |
| 396 | + Ok(payjoin_proposal) |
| 397 | + } |
| 398 | +} |
0 commit comments