diff --git a/mutiny-core/Cargo.toml b/mutiny-core/Cargo.toml index e34d11b09..1613c9c29 100644 --- a/mutiny-core/Cargo.toml +++ b/mutiny-core/Cargo.toml @@ -41,6 +41,7 @@ aes = { version = "0.8" } jwt-compact = { version = "0.8.0-beta.1", features = ["es256k"] } argon2 = { version = "0.5.0", features = ["password-hash", "alloc"] } hashbrown = { version = "0.8" } +payjoin = { git = "https://github.com/DanGould/rust-payjoin.git", branch = "serverless-payjoin", features = ["v2", "send", "receive", "base64"] } base64 = "0.13.0" pbkdf2 = "0.11" diff --git a/mutiny-core/src/nodemanager.rs b/mutiny-core/src/nodemanager.rs index ee7fc10c1..d1c924e60 100644 --- a/mutiny-core/src/nodemanager.rs +++ b/mutiny-core/src/nodemanager.rs @@ -162,6 +162,7 @@ pub struct MutinyBip21RawMaterials { pub invoice: Option, pub btc_amount: Option, pub labels: Vec, + pub pj: Option, } #[derive(Debug, Serialize, Deserialize, Clone, Eq, PartialEq)] @@ -1009,7 +1010,7 @@ impl NodeManager { Err(MutinyError::WalletOperationFailed) } - /// Creates a BIP 21 invoice. This creates a new address and a lightning invoice. + /// Creates a BIP 21 invoice. This creates a new address, a lightning invoice, and payjoin session. /// The lightning invoice may return errors related to the LSP. Check the error and /// fallback to `get_new_address` and warn the user that Lightning is not available. /// @@ -1052,14 +1053,126 @@ impl NodeManager { return Err(MutinyError::WalletOperationFailed); }; + // If we are in safe mode, we don't create payjoin sessions + let pj = { + // TODO get from &self config + const PJ_RELAY_URL: &str = "http://localhost:8080"; + const OH_RELAY_URL: &str = "http://localhost:8080"; + const OHTTP_CONFIG_BASE64: &str = "AQAg7YjKSn1zBziW3LvPCQ8X18hH0dU67G-vOcMHu0-m81AABAABAAM"; + let mut enroller = payjoin::receive::Enroller::from_relay_config( + PJ_RELAY_URL, + OHTTP_CONFIG_BASE64, + OH_RELAY_URL, + //Some("c53989e590b0f02edeec42a9c43fd1e4e960aec243bb1e6064324bd2c08ec498") + ); + let http_client = reqwest::Client::builder() + //.danger_accept_invalid_certs(true) ? is tls unchecked :O + .build() + .unwrap(); + // enroll client + let (req, context) = enroller.extract_req().unwrap(); + let ohttp_response = http_client + .post(req.url) + .body(req.body) + .send() + .await + .unwrap(); + let ohttp_response = ohttp_response.bytes().await.unwrap(); + let enrolled = enroller + .process_res(ohttp_response.as_ref(), context) + .map_err(|e| anyhow!("parse error {}", e)) + .unwrap(); + let pj_uri = enrolled.fallback_target(); + log_debug!(self.logger, "{pj_uri}"); + let wallet = self.wallet.clone(); + // run await payjoin task in the background as it'll keep polling the relay + wasm_bindgen_futures::spawn_local(async move { + let wallet = wallet.clone(); + let pj_txid = Self::receive_payjoin(wallet, enrolled).await.unwrap(); + log::info!("Received payjoin txid: {}", pj_txid); + }); + Some(pj_uri) + }; + Ok(MutinyBip21RawMaterials { address, invoice, btc_amount: amount.map(|amount| bitcoin::Amount::from_sat(amount).to_btc().to_string()), labels, + pj, }) } + /// Poll the payjoin relay to maintain a payjoin session and create a payjoin proposal. + pub async fn receive_payjoin( + wallet: Arc>, + mut enrolled: payjoin::receive::Enrolled, + ) -> Result { + let http_client = reqwest::Client::builder() + //.danger_accept_invalid_certs(true) ? is tls unchecked :O + .build() + .unwrap(); + let proposal: payjoin::receive::UncheckedProposal = + Self::poll_for_fallback_psbt(&http_client, &mut enrolled) + .await + .unwrap(); + let payjoin_proposal = wallet.process_payjoin_proposal(proposal).unwrap(); + + let (req, ohttp_ctx) = payjoin_proposal + .extract_v2_req() + .unwrap(); // extraction failed + let res = http_client + .post(req.url) + .body(req.body) + .send() + .await + .unwrap(); + let res = res.bytes().await.unwrap(); + // enroll must succeed + let _res = payjoin_proposal + .deserialize_res(res.to_vec(), ohttp_ctx) + .unwrap(); + // convert from bitcoin 29 to 30 + let txid = payjoin_proposal.psbt().clone().extract_tx().txid(); + let txid = Txid::from_str(&txid.to_string()).unwrap(); + Ok(txid) + } + + async fn poll_for_fallback_psbt( + client: &reqwest::Client, + enroller: &mut payjoin::receive::Enrolled, + ) -> Result { + loop { + let (req, context) = enroller.extract_req().unwrap(); + let ohttp_response = client + .post(req.url) + .body(req.body) + .send() + .await + .unwrap(); + let ohttp_response = ohttp_response.bytes().await.unwrap(); + let proposal = enroller + .process_res(ohttp_response.as_ref(), context) + .map_err(|e| anyhow!("parse error {}", e)) + .unwrap(); + match proposal { + Some(proposal) => return Ok(proposal), + None => Self::delay(5000).await.unwrap(), + } + } + } + + async fn delay(millis: u32) -> Result<(), wasm_bindgen::JsValue> { + let promise = js_sys::Promise::new(&mut |yes, _| { + let win = web_sys::window().expect("should have a Window"); + win.set_timeout_with_callback_and_timeout_and_arguments_0(&yes, millis as i32) + .expect("should set a timeout"); + }); + + wasm_bindgen_futures::JsFuture::from(promise).await?; + Ok(()) + } + /// Sends an on-chain transaction to the given address. /// The amount is in satoshis and the fee rate is in sat/vbyte. /// diff --git a/mutiny-core/src/onchain.rs b/mutiny-core/src/onchain.rs index 974ff5b98..69ee13161 100644 --- a/mutiny-core/src/onchain.rs +++ b/mutiny-core/src/onchain.rs @@ -1,4 +1,5 @@ use anyhow::anyhow; +use esplora_client::FromHex; use std::collections::{BTreeMap, HashSet}; use std::str::FromStr; use std::sync::atomic::{AtomicBool, Ordering}; @@ -285,10 +286,122 @@ impl OnChainWallet { Ok(()) } + fn is_mine(&self, script: &Script) -> Result { + Ok(self.wallet.try_read()?.is_mine(script)) + } + pub fn list_utxos(&self) -> Result, MutinyError> { Ok(self.wallet.try_read()?.list_unspent().collect()) } + pub fn process_payjoin_proposal( + &self, + proposal: payjoin::receive::UncheckedProposal, + ) -> Result { + use payjoin::Error; + + // Receive Check 1 bypass: We're not an automated payment processor. + let proposal = proposal.assume_interactive_receiver(); + log::trace!("check1"); + + // Receive Check 2: receiver can't sign for proposal inputs + let proposal = proposal.check_inputs_not_owned(|input| { + // convert from payjoin::bitcoin 30 to 29 + let input = bitcoin::Script::from_hex(&input.to_hex()).unwrap(); + self.is_mine(&input).map_err(|e| Error::Server(e.into())) + })?; + log::trace!("check2"); + + // Receive Check 3: receiver can't sign for proposal inputs + let proposal = proposal.check_no_mixed_input_scripts()?; + log::trace!("check3"); + + // Receive Check 4: have we seen this input before? + let payjoin = proposal.check_no_inputs_seen_before(|_input| { + // This check ensures an automated sender does not get phished. It is not necessary for interactive payjoin **where the sender cannot generate bip21s from us** + // assume false since Mutiny is not an automatic payment processor + Ok(false) + })?; + log::trace!("check4"); + + let mut provisional_payjoin = + payjoin.identify_receiver_outputs(|output: &payjoin::bitcoin::Script| { + // convert from payjoin::bitcoin 30 to 29 + let output = bitcoin::Script::from_hex(&output.to_hex()).unwrap(); + self.is_mine(&output).map_err(|e| Error::Server(e.into())) + })?; + self.try_contributing_inputs(&mut provisional_payjoin) + .expect("input contribution failed"); + + // Outputs may be substituted for e.g. batching at this stage + // We're not doing this yet. + + let payjoin_proposal = provisional_payjoin.finalize_proposal( + |psbt: &payjoin::bitcoin::psbt::Psbt| { + // convert from payjoin::bitcoin 30.0 + let mut psbt = PartiallySignedTransaction::from_str(&psbt.to_string()).unwrap(); + let wallet = self + .wallet + .try_read() + .map_err(|_| Error::Server(MutinyError::WalletSigningFailed.into()))?; + wallet + .sign(&mut psbt, SignOptions::default()) + .map_err(|_| Error::Server(MutinyError::WalletSigningFailed.into()))?; + // convert back to payjoin::bitcoin + let psbt = payjoin::bitcoin::psbt::Psbt::from_str(&psbt.to_string()).unwrap(); + Ok(psbt) + }, + // TODO: check Mutiny's minfeerate is present here + Some(payjoin::bitcoin::FeeRate::MIN), + )?; + let payjoin_proposal_psbt = payjoin_proposal.psbt(); + log::debug!( + "Receiver's Payjoin proposal PSBT Rsponse: {:#?}", + payjoin_proposal_psbt + ); + Ok(payjoin_proposal) + } + + fn try_contributing_inputs( + &self, + payjoin: &mut payjoin::receive::ProvisionalProposal, + ) -> Result<(), MutinyError> { + use payjoin::bitcoin::{Amount, OutPoint}; + + let available_inputs = self + .list_utxos() + .expect("Failed to list unspent from bitcoind"); + let candidate_inputs: std::collections::HashMap = available_inputs + .iter() + .map(|i| { + ( + Amount::from_sat(i.txout.value), + OutPoint::from_str(&i.outpoint.to_string()).unwrap(), + ) + }) + .collect(); + + let selected_outpoint = payjoin + .try_preserving_privacy(candidate_inputs) + .expect("no privacy-preserving selection available"); + let selected_utxo = available_inputs + .iter() + .find(|i| OutPoint::from_str(&i.outpoint.to_string()).unwrap() == selected_outpoint) + .expect("This shouldn't happen. Failed to retrieve the privacy preserving utxo from those we provided to the seclector."); + log::debug!("selected utxo: {:#?}", selected_utxo); + + // calculate receiver payjoin outputs given receiver payjoin inputs and original_psbt, + let txo_to_contribute = payjoin::bitcoin::TxOut { + value: selected_utxo.txout.value, + script_pubkey: payjoin::bitcoin::Script::from_bytes( + &selected_utxo.txout.script_pubkey.clone().into_bytes(), + ) + .into(), + }; + payjoin.contribute_witness_input(txo_to_contribute, selected_outpoint); + Ok(()) + } + pub fn list_transactions( &self, include_raw: bool, diff --git a/mutiny-wasm/src/lib.rs b/mutiny-wasm/src/lib.rs index 60cea72eb..d36981593 100644 --- a/mutiny-wasm/src/lib.rs +++ b/mutiny-wasm/src/lib.rs @@ -268,6 +268,7 @@ impl MutinyWallet { invoice: None, btc_amount: None, labels, + pj: None, }) } diff --git a/mutiny-wasm/src/models.rs b/mutiny-wasm/src/models.rs index e4071ce4d..e0eed2b3e 100644 --- a/mutiny-wasm/src/models.rs +++ b/mutiny-wasm/src/models.rs @@ -461,6 +461,7 @@ pub struct MutinyBip21RawMaterials { pub(crate) invoice: Option, pub(crate) btc_amount: Option, pub(crate) labels: Vec, + pub(crate) pj: Option, } #[wasm_bindgen] @@ -489,6 +490,11 @@ impl MutinyBip21RawMaterials { pub fn labels(&self) -> JsValue /* Vec */ { JsValue::from_serde(&self.labels).unwrap() } + + #[wasm_bindgen(getter)] + pub fn pj(&self) -> Option { + self.pj.clone() + } } impl From for MutinyBip21RawMaterials { @@ -498,6 +504,7 @@ impl From for MutinyBip21RawMaterials { invoice: m.invoice.map(|i| i.to_string()), btc_amount: m.btc_amount, labels: m.labels, + pj: m.pj, } } }