diff --git a/Cargo.lock b/Cargo.lock index 85f2d0afa..7b503f942 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -224,6 +224,16 @@ version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d86b93f97252c47b41663388e6d155714a9d0c398b99f1005cbc5f978b29f445" +[[package]] +name = "bip21" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b9532c632b068e45a478f5e309126b6e2ec1dbf0bbd327b73836f33d9a43ede" +dependencies = [ + "bitcoin 0.30.1", + "percent-encoding-rfc3986", +] + [[package]] name = "bip39" version = "2.0.0" @@ -254,6 +264,7 @@ version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e99ff7289b20a7385f66a0feda78af2fc119d28fb56aea8886a9cd0a4abdd75" dependencies = [ + "base64 0.13.1", "bech32", "bitcoin-private", "bitcoin_hashes 0.12.0", @@ -1209,6 +1220,7 @@ dependencies = [ "mockall", "nostr", "nostr-sdk", + "payjoin", "pbkdf2", "proc-macro2", "reqwest", @@ -1245,6 +1257,7 @@ dependencies = [ "mutiny-core", "nostr", "once_cell", + "payjoin", "rexie", "serde", "serde_json", @@ -1440,6 +1453,18 @@ dependencies = [ "subtle", ] +[[package]] +name = "payjoin" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fac532e6caa3a192dd6017a88446c2a1014d31b66cc68f04c584a846a4cb0373" +dependencies = [ + "bip21", + "bitcoin 0.30.1", + "log", + "url", +] + [[package]] name = "pbkdf2" version = "0.11.0" @@ -1458,6 +1483,12 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b2a4787296e9989611394c33f193f676704af1686e70b8f8033ab5ba9a35a94" +[[package]] +name = "percent-encoding-rfc3986" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3637c05577168127568a64e9dc5a6887da720efef07b3d9472d45f63ab191166" + [[package]] name = "pharos" version = "0.5.3" diff --git a/mutiny-core/Cargo.toml b/mutiny-core/Cargo.toml index f1a79b99b..4f813c133 100644 --- a/mutiny-core/Cargo.toml +++ b/mutiny-core/Cargo.toml @@ -40,6 +40,7 @@ cbc = { version = "0.1", features = ["alloc"] } aes = { version = "0.8" } jwt-compact = { version = "0.8.0-beta.1", features = ["es256k"] } argon2 = { version = "0.5.0", features = ["password-hash", "alloc"] } +payjoin = { version = "0.10.0", features = ["send", "base64"] } base64 = "0.13.0" pbkdf2 = "0.11" diff --git a/mutiny-core/src/error.rs b/mutiny-core/src/error.rs index 88d5485d1..65b5e4bfd 100644 --- a/mutiny-core/src/error.rs +++ b/mutiny-core/src/error.rs @@ -141,6 +141,15 @@ pub enum MutinyError { /// Cannot change password to the same password #[error("Cannot change password to the same password.")] SamePassword, + /// Payjoin request creation failed. + #[error("Failed to create payjoin request.")] + PayjoinCreateRequest, + /// Payjoin response validation failed. + #[error("Failed to validate payjoin response.")] + PayjoinValidateResponse(payjoin::send::ValidationError), + /// Payjoin configuration error + #[error("Payjoin configuration failed.")] + PayjoinConfigError, #[error(transparent)] Other(#[from] anyhow::Error), } @@ -446,3 +455,15 @@ impl From for MutinyError { Self::NostrError } } + +impl From for MutinyError { + fn from(_e: payjoin::send::CreateRequestError) -> Self { + Self::PayjoinCreateRequest + } +} + +impl From for MutinyError { + fn from(e: payjoin::send::ValidationError) -> Self { + Self::PayjoinValidateResponse(e) + } +} diff --git a/mutiny-core/src/nodemanager.rs b/mutiny-core/src/nodemanager.rs index 118152565..2d030d0b8 100644 --- a/mutiny-core/src/nodemanager.rs +++ b/mutiny-core/src/nodemanager.rs @@ -27,10 +27,11 @@ use crate::{gossip::*, scorer::HubPreferentialScorer}; use crate::{labels::LabelStorage, subscription::MutinySubscriptionClient}; use anyhow::anyhow; use bdk::chain::{BlockId, ConfirmationTime}; -use bdk::{wallet::AddressIndex, LocalUtxo}; +use bdk::{wallet::AddressIndex, FeeRate, LocalUtxo}; use bitcoin::blockdata::script; use bitcoin::hashes::hex::ToHex; use bitcoin::hashes::{sha256, Hash}; +use bitcoin::psbt::PartiallySignedTransaction; use bitcoin::secp256k1::{rand, PublicKey}; use bitcoin::util::bip32::ExtendedPrivKey; use bitcoin::{Address, Network, OutPoint, Transaction, Txid}; @@ -51,9 +52,11 @@ use lnurl::lnurl::LnUrl; use lnurl::{AsyncClient as LnUrlClient, LnUrlResponse, Response}; use nostr::key::XOnlyPublicKey; use nostr::{EventBuilder, Keys, Kind, Tag, TagKind}; +use payjoin::{PjUri, PjUriExt}; use reqwest::Client; use serde::{Deserialize, Serialize}; use serde_json::Value; +use std::io::Cursor; use std::str::FromStr; use std::sync::atomic::{AtomicBool, Ordering}; use std::{collections::HashMap, ops::Deref, sync::Arc}; @@ -1018,6 +1021,77 @@ impl NodeManager { }) } + pub async fn send_payjoin( + &self, + uri: PjUri<'_>, + amount: u64, + labels: Vec, + fee_rate: Option, + ) -> Result { + let address = Address::from_str(&uri.address.to_string()) + .map_err(|_| MutinyError::PayjoinConfigError)?; + let original_psbt = self.wallet.create_signed_psbt(address, amount, fee_rate)?; + + let payout_scripts = std::iter::once(uri.address.script_pubkey()); + let fee_rate = if let Some(rate) = fee_rate { + FeeRate::from_sat_per_vb(rate) + } else { + let sat_per_kwu = self.fee_estimator.get_normal_fee_rate(); + FeeRate::from_sat_per_kwu(sat_per_kwu as f32) + }; + let fee_rate = payjoin::bitcoin::FeeRate::from_sat_per_kwu(fee_rate.sat_per_kwu() as u64); + let original_psbt = payjoin::bitcoin::psbt::PartiallySignedTransaction::from_str( + &original_psbt.to_string(), + ) + .map_err(|_| MutinyError::PayjoinConfigError)?; + let pj_params = + payjoin::send::Configuration::recommended(&original_psbt, payout_scripts, fee_rate) + .map_err(|_| MutinyError::PayjoinConfigError)?; + + log_debug!(self.logger, "Creating payjoin request"); + let (req, ctx) = uri.create_pj_request(original_psbt.clone(), pj_params)?; + + let client = Client::builder() + .build() + .map_err(|_| MutinyError::PayjoinConfigError)?; + + log_debug!(self.logger, "Sending payjoin request"); + let res = client + .post(req.url) + .body(req.body) + .header("Content-Type", "text/plain") + .send() + .await + .map_err(|_| MutinyError::PayjoinCreateRequest)? + .bytes() + .await + .map_err(|_| MutinyError::PayjoinCreateRequest)?; + + let mut cursor = Cursor::new(res.to_vec()); + + log_debug!(self.logger, "Processing payjoin response"); + let proposal_psbt = ctx.process_response(&mut cursor).map_err(|e| { + log_error!(self.logger, "Error processing payjoin response: {e}"); + e + })?; + + // convert to pdk types + let original_psbt = PartiallySignedTransaction::from_str(&original_psbt.to_string()) + .map_err(|_| MutinyError::PayjoinConfigError)?; + let proposal_psbt = PartiallySignedTransaction::from_str(&proposal_psbt.to_string()) + .map_err(|_| MutinyError::PayjoinConfigError)?; + + log_debug!(self.logger, "Sending payjoin.."); + let tx = self + .wallet + .send_payjoin(original_psbt, proposal_psbt, labels) + .await?; + let txid = tx.txid(); + self.broadcast_transaction(tx).await?; + log_debug!(self.logger, "Payjoin broadcast! TXID: {txid}"); + Ok(txid) + } + /// 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 5911306b5..3ef4cace8 100644 --- a/mutiny-core/src/onchain.rs +++ b/mutiny-core/src/onchain.rs @@ -12,12 +12,12 @@ use bdk::{FeeRate, LocalUtxo, SignOptions, TransactionDetails, Wallet}; use bdk_esplora::EsploraAsyncExt; use bitcoin::consensus::serialize; use bitcoin::hashes::hex::ToHex; -use bitcoin::psbt::PartiallySignedTransaction; +use bitcoin::psbt::{Input, PartiallySignedTransaction}; use bitcoin::util::bip32::{ChildNumber, DerivationPath, ExtendedPrivKey}; use bitcoin::{Address, Network, OutPoint, Script, Transaction, Txid}; use lightning::events::bump_transaction::{Utxo, WalletSource}; use lightning::util::logger::Logger; -use lightning::{log_debug, log_error, log_info, log_warn}; +use lightning::{log_debug, log_error, log_info, log_trace, log_warn}; use crate::error::MutinyError; use crate::fees::MutinyFeeEstimator; @@ -478,6 +478,59 @@ impl OnChainWallet { Ok(txid) } + pub async fn send_payjoin( + &self, + mut original_psbt: PartiallySignedTransaction, + mut proposal_psbt: PartiallySignedTransaction, + labels: Vec, + ) -> Result { + let wallet = self.wallet.try_read()?; + + // add original psbt input map data in place so BDK knows which scripts to sign, + // proposal_psbt only contains the sender input outpoints, not scripts, which BDK + // does not look up + fn input_pairs( + psbt: &mut PartiallySignedTransaction, + ) -> Box + '_> { + Box::new(psbt.unsigned_tx.input.iter().zip(&mut psbt.inputs)) + } + + let mut original_inputs = input_pairs(&mut original_psbt).peekable(); + + for (proposed_txin, proposed_psbtin) in input_pairs(&mut proposal_psbt) { + log_trace!( + self.logger, + "Proposed txin: {:?}", + proposed_txin.previous_output + ); + if let Some((original_txin, original_psbtin)) = original_inputs.peek() { + log_trace!( + self.logger, + "Original txin: {:?}", + original_txin.previous_output + ); + log_trace!(self.logger, "Original psbtin: {original_psbtin:?}"); + 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(); + } + } + } + + log_trace!(self.logger, "Augmented PSBT: {proposal_psbt:?}"); + // sign and finalize payjoin + let result = wallet.sign(&mut proposal_psbt, SignOptions::default()); + log_trace!(self.logger, "Sign result: {result:?}"); + result?; + drop(wallet); + + self.label_psbt(&proposal_psbt, labels)?; + let payjoin = proposal_psbt.extract_tx(); + + Ok(payjoin) + } + pub fn create_sweep_psbt( &self, spk: Script, diff --git a/mutiny-wasm/Cargo.toml b/mutiny-wasm/Cargo.toml index a2079609d..01539acbb 100644 --- a/mutiny-wasm/Cargo.toml +++ b/mutiny-wasm/Cargo.toml @@ -40,6 +40,7 @@ getrandom = { version = "0.2", features = ["js"] } futures = "0.3.25" urlencoding = "2.1.2" once_cell = "1.18.0" +payjoin = { version = "0.10.0", features = ["send", "base64"] } # The `console_error_panic_hook` crate provides better debugging of panics by # logging them with `console.error`. This is great for development, but requires diff --git a/mutiny-wasm/src/error.rs b/mutiny-wasm/src/error.rs index 42bcef43e..3060fe4b0 100644 --- a/mutiny-wasm/src/error.rs +++ b/mutiny-wasm/src/error.rs @@ -141,6 +141,15 @@ pub enum MutinyJsError { /// Cannot change password to the same password #[error("Cannot change password to the same password.")] SamePassword, + /// Payjoin request creation failed. + #[error("Failed to create payjoin request.")] + PayjoinCreateRequest, + /// Payjoin response validation failed. + #[error("Failed to validate payjoin response.")] + PayjoinValidateResponse, + /// Payjoin configuration error + #[error("Payjoin configuration failed.")] + PayjoinConfigError, /// Unknown error. #[error("Unknown Error")] UnknownError, @@ -194,6 +203,9 @@ impl From for MutinyJsError { MutinyError::InvalidArgumentsError => MutinyJsError::InvalidArgumentsError, MutinyError::LspAmountTooHighError => MutinyJsError::LspAmountTooHighError, MutinyError::NetworkMismatch => MutinyJsError::NetworkMismatch, + MutinyError::PayjoinConfigError => MutinyJsError::PayjoinConfigError, + MutinyError::PayjoinCreateRequest => MutinyJsError::PayjoinCreateRequest, + MutinyError::PayjoinValidateResponse(_) => MutinyJsError::PayjoinValidateResponse, } } } diff --git a/mutiny-wasm/src/lib.rs b/mutiny-wasm/src/lib.rs index 66e3a88f4..e483bfff6 100644 --- a/mutiny-wasm/src/lib.rs +++ b/mutiny-wasm/src/lib.rs @@ -40,6 +40,7 @@ use mutiny_core::{labels::LabelStorage, nodemanager::NodeManager}; use mutiny_core::{logging::MutinyLogger, nostr::ProfileType}; use nostr::key::XOnlyPublicKey; use nostr::prelude::FromBech32; +use payjoin::UriExt; use std::str::FromStr; use std::sync::Arc; use std::{ @@ -451,6 +452,31 @@ impl MutinyWallet { .to_string()) } + #[wasm_bindgen] + pub async fn send_payjoin( + &self, + payjoin_uri: String, + amount: u64, /* override the uri amount if desired */ + labels: &JsValue, /* Vec */ + fee_rate: Option, + ) -> Result { + // I know walia parses `pj=` and `pjos=` but payjoin::Uri parses the whole bip21 uri + let pj_uri = payjoin::Uri::try_from(payjoin_uri.as_str()) + .map_err(|_| MutinyJsError::InvalidArgumentsError)? + .assume_checked() + .check_pj_supported() + .map_err(|_| MutinyJsError::InvalidArgumentsError)?; + let labels: Vec = labels + .into_serde() + .map_err(|_| MutinyJsError::InvalidArgumentsError)?; + Ok(self + .inner + .node_manager + .send_payjoin(pj_uri, amount, labels, fee_rate) + .await? + .to_string()) + } + /// Sweeps all the funds from the wallet to the given address. /// The fee rate is in sat/vbyte. ///