diff --git a/payjoin-ffi/src/send/mod.rs b/payjoin-ffi/src/send/mod.rs index 0883bfbd7..046a9d0a4 100644 --- a/payjoin-ffi/src/send/mod.rs +++ b/payjoin-ffi/src/send/mod.rs @@ -93,10 +93,10 @@ impl InitInputsTransition { /// ///These parameters define how client wants to handle Payjoin. #[derive(Clone)] -pub struct SenderBuilder(payjoin::send::v2::SenderBuilder<'static>); +pub struct SenderBuilder(payjoin::send::v2::SenderBuilder); -impl From> for SenderBuilder { - fn from(value: payjoin::send::v2::SenderBuilder<'static>) -> Self { Self(value) } +impl From for SenderBuilder { + fn from(value: payjoin::send::v2::SenderBuilder) -> Self { Self(value) } } impl SenderBuilder { @@ -259,16 +259,16 @@ impl WithReplyKey { /// Data required for validation of response. /// This type is used to process the response. Get it from SenderBuilder's build methods. Then you only need to call .process_response() on it to continue BIP78 flow. #[derive(Clone)] -pub struct V1Context(Arc); -impl From for V1Context { - fn from(value: payjoin::send::v1::V1Context) -> Self { Self(Arc::new(value)) } +pub struct V1Context(Arc); +impl From for V1Context { + fn from(value: payjoin::send::V1Context) -> Self { Self(Arc::new(value)) } } impl V1Context { ///Decodes and validates the response. /// Call this method with response from receiver to continue BIP78 flow. If the response is valid you will get appropriate PSBT that you should sign and broadcast. pub fn process_response(&self, response: &[u8]) -> Result { - ::clone(&self.0.clone()) + ::clone(&self.0.clone()) .process_response(response) .map(|e| e.to_string()) .map_err(Into::into) diff --git a/payjoin/src/core/output_substitution.rs b/payjoin/src/core/output_substitution.rs index 0f0d5b5ab..1e4ca605b 100644 --- a/payjoin/src/core/output_substitution.rs +++ b/payjoin/src/core/output_substitution.rs @@ -4,16 +4,3 @@ pub enum OutputSubstitution { Enabled, Disabled, } - -impl OutputSubstitution { - /// Combine two output substitution flags. - /// - /// If both are enabled, the result is enabled. - /// If one is disabled, the result is disabled. - pub(crate) fn combine(self, other: Self) -> Self { - match (self, other) { - (Self::Enabled, Self::Enabled) => Self::Enabled, - _ => Self::Disabled, - } - } -} diff --git a/payjoin/src/core/send/mod.rs b/payjoin/src/core/send/mod.rs index cab91c500..fdc0b45dc 100644 --- a/payjoin/src/core/send/mod.rs +++ b/payjoin/src/core/send/mod.rs @@ -26,6 +26,7 @@ use url::Url; use crate::output_substitution::OutputSubstitution; use crate::psbt::PsbtExt; +use crate::{Request, Version, MAX_CONTENT_LENGTH}; // See usize casts #[cfg(not(any(target_pointer_width = "32", target_pointer_width = "64")))] @@ -36,20 +37,195 @@ mod error; #[cfg(feature = "v1")] #[cfg_attr(docsrs, doc(cfg(feature = "v1")))] pub mod v1; -#[cfg(not(feature = "v1"))] -pub(crate) mod v1; #[cfg(feature = "v2")] #[cfg_attr(docsrs, doc(cfg(feature = "v2")))] pub mod v2; -#[cfg(all(feature = "v2", not(feature = "v1")))] -pub use v1::V1Context; #[cfg(feature = "_multiparty")] pub mod multiparty; type InternalResult = Result; +/// A builder to construct the properties of a `PsbtContext`. +#[derive(Clone)] +pub(crate) struct PsbtContextBuilder { + pub(crate) psbt: Psbt, + pub(crate) payee: ScriptBuf, + pub(crate) amount: Option, + pub(crate) fee_contribution: Option<(bitcoin::Amount, Option)>, + /// Decreases the fee contribution instead of erroring. + /// + /// If this option is true and a transaction with change amount lower than fee + /// contribution is provided then instead of returning error the fee contribution will + /// be just lowered in the request to match the change amount. + pub(crate) clamp_fee_contribution: bool, + pub(crate) min_fee_rate: FeeRate, +} + +/// We only need to add the weight of the txid: 32, index: 4 and sequence: 4 as rust_bitcoin +/// already accounts for the scriptsig length when calculating InputWeightPrediction +/// +const NON_WITNESS_INPUT_WEIGHT: bitcoin::Weight = Weight::from_non_witness_data_size(32 + 4 + 4); + +impl PsbtContextBuilder { + /// Prepare the context from which to make Sender requests + /// + /// Call [`PsbtContextBuilder::build_recommended()`] or other `build` methods + /// to create a [`PsbtContext`] + pub fn new(psbt: Psbt, payee: ScriptBuf, amount: Option) -> Self { + Self { + psbt, + payee, + amount, + // Sender's optional parameters + fee_contribution: None, + clamp_fee_contribution: false, + min_fee_rate: FeeRate::ZERO, + } + } + + // Calculate the recommended fee contribution for an Original PSBT. + // + // BIP 78 recommends contributing `originalPSBTFeeRate * vsize(sender_input_type)`. + // The minfeerate parameter is set if the contribution is available in change. + // + // This method fails if no recommendation can be made or if the PSBT is malformed. + pub fn build_recommended( + self, + min_fee_rate: FeeRate, + output_substitution: OutputSubstitution, + ) -> Result { + // TODO support optional batched payout scripts. This would require a change to + // build() which now checks for a single payee. + let mut payout_scripts = std::iter::once(self.payee.clone()); + + // Check if the PSBT is a sweep transaction with only one output that's a payout script and no change + if self.psbt.unsigned_tx.output.len() == 1 + && payout_scripts.all(|script| script == self.psbt.unsigned_tx.output[0].script_pubkey) + { + return self.build_non_incentivizing(min_fee_rate, output_substitution); + } + + if let Some((additional_fee_index, fee_available)) = self + .psbt + .unsigned_tx + .output + .clone() + .into_iter() + .enumerate() + .find(|(_, txo)| payout_scripts.all(|script| script != txo.script_pubkey)) + .map(|(i, txo)| (i, txo.value)) + { + let mut input_pairs = self.psbt.input_pairs(); + let first_input_pair = input_pairs.next().ok_or(InternalBuildSenderError::NoInputs)?; + let mut input_weight = first_input_pair + .expected_input_weight() + .map_err(InternalBuildSenderError::InputWeight)?; + for input_pair in input_pairs { + // use cheapest default if mixed input types + if input_pair.address_type()? != first_input_pair.address_type()? { + input_weight = + bitcoin::transaction::InputWeightPrediction::P2TR_KEY_NON_DEFAULT_SIGHASH + .weight() + + NON_WITNESS_INPUT_WEIGHT; + break; + } + } + + let recommended_additional_fee = min_fee_rate * input_weight; + if fee_available < recommended_additional_fee { + log::warn!("Insufficient funds to maintain specified minimum feerate."); + return self.build_with_additional_fee( + fee_available, + Some(additional_fee_index), + min_fee_rate, + true, + output_substitution, + ); + } + return self.build_with_additional_fee( + recommended_additional_fee, + Some(additional_fee_index), + min_fee_rate, + false, + output_substitution, + ); + } + self.build_non_incentivizing(min_fee_rate, output_substitution) + } + + /// Offer the receiver contribution to pay for his input. + /// + /// These parameters will allow the receiver to take `max_fee_contribution` from given change + /// output to pay for additional inputs. The recommended fee is `size_of_one_input * fee_rate`. + /// + /// `change_index` specifies which output can be used to pay fee. If `None` is provided, then + /// the output is auto-detected unless the supplied transaction has more than two outputs. + /// + /// `clamp_fee_contribution` decreases fee contribution instead of erroring. + /// + /// If this option is true and a transaction with change amount lower than fee + /// contribution is provided then instead of returning error the fee contribution will + /// be just lowered in the request to match the change amount. + pub fn build_with_additional_fee( + mut self, + max_fee_contribution: bitcoin::Amount, + change_index: Option, + min_fee_rate: FeeRate, + clamp_fee_contribution: bool, + output_substitution: OutputSubstitution, + ) -> Result { + self.fee_contribution = Some((max_fee_contribution, change_index)); + self.clamp_fee_contribution = clamp_fee_contribution; + self.min_fee_rate = min_fee_rate; + self.build(output_substitution) + } + + /// Perform Payjoin without incentivizing the payee to cooperate. + /// + /// While it's generally better to offer some contribution some users may wish not to. + /// This function disables contribution. + pub fn build_non_incentivizing( + mut self, + min_fee_rate: FeeRate, + output_substitution: OutputSubstitution, + ) -> Result { + // since this is a builder, these should already be cleared + // but we'll reset them to be sure + self.fee_contribution = None; + self.clamp_fee_contribution = false; + self.min_fee_rate = min_fee_rate; + self.build(output_substitution) + } + + fn build( + self, + output_substitution: OutputSubstitution, + ) -> Result { + let mut psbt = + self.psbt.validate().map_err(InternalBuildSenderError::InconsistentOriginalPsbt)?; + psbt.validate_input_utxos().map_err(InternalBuildSenderError::InvalidOriginalInput)?; + + check_single_payee(&psbt, &self.payee, self.amount)?; + let fee_contribution = determine_fee_contribution( + &psbt, + &self.payee, + self.fee_contribution, + self.clamp_fee_contribution, + )?; + clear_unneeded_fields(&mut psbt); + + Ok(PsbtContext { + original_psbt: psbt, + output_substitution, + fee_contribution, + min_fee_rate: self.min_fee_rate, + payee: self.payee, + }) + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[cfg_attr(feature = "v2", derive(serde::Serialize, serde::Deserialize))] pub(crate) struct AdditionalFeeContribution { @@ -88,7 +264,7 @@ fn ensure(condition: bool, error: T) -> Result<(), T> { impl PsbtContext { fn process_proposal(self, mut proposal: Psbt) -> InternalResult { self.basic_checks(&proposal)?; - self.check_inputs(&proposal)?; + self.check_inputs(&proposal, true)?; let contributed_fee = self.check_outputs(&proposal)?; self.restore_original_utxos(&mut proposal)?; self.check_fees(&proposal, contributed_fee)?; @@ -161,7 +337,11 @@ impl PsbtContext { Ok(()) } - fn check_inputs(&self, proposal: &Psbt) -> InternalResult<()> { + fn check_inputs( + &self, + proposal: &Psbt, + ensure_receiver_input_finalized: bool, + ) -> InternalResult<()> { let mut original_inputs = self.original_psbt.input_pairs().peekable(); for proposed in proposal.input_pairs() { @@ -200,12 +380,14 @@ impl PsbtContext { .input_pairs() .next() .ok_or(InternalProposalError::NoInputs)?; - // Verify the PSBT input is finalized - ensure( - proposed.psbtin.final_script_sig.is_some() - || proposed.psbtin.final_script_witness.is_some(), - InternalProposalError::ReceiverTxinNotFinalized, - )?; + if ensure_receiver_input_finalized { + // Verify the PSBT input is finalized + ensure( + proposed.psbtin.final_script_sig.is_some() + || proposed.psbtin.final_script_witness.is_some(), + InternalProposalError::ReceiverTxinNotFinalized, + )?; + } // Verify that non_witness_utxo or witness_utxo are filled in. ensure( proposed.psbtin.witness_utxo.is_some() @@ -451,10 +633,10 @@ fn serialize_url( output_substitution: OutputSubstitution, fee_contribution: Option, min_fee_rate: FeeRate, - version: &str, + version: Version, ) -> Url { let mut url = endpoint; - url.query_pairs_mut().append_pair("v", version); + url.query_pairs_mut().append_pair("v", &version.to_string()); if output_substitution == OutputSubstitution::Disabled { url.query_pairs_mut().append_pair("disableoutputsubstitution", "true"); } @@ -471,6 +653,56 @@ fn serialize_url( url } +/// Construct serialized V1 Request and Context from a Payjoin Proposal +pub(crate) fn create_v1_post_request(endpoint: Url, psbt_ctx: PsbtContext) -> (Request, V1Context) { + let url = serialize_url( + endpoint.clone(), + psbt_ctx.output_substitution, + psbt_ctx.fee_contribution, + psbt_ctx.min_fee_rate, + Version::One, + ); + let body = psbt_ctx.original_psbt.to_string().as_bytes().to_vec(); + ( + Request::new_v1(&url, &body), + V1Context { + psbt_context: PsbtContext { + original_psbt: psbt_ctx.original_psbt.clone(), + output_substitution: psbt_ctx.output_substitution, + fee_contribution: psbt_ctx.fee_contribution, + payee: psbt_ctx.payee.clone(), + min_fee_rate: psbt_ctx.min_fee_rate, + }, + }, + ) +} + +/// Data required to validate the response. +/// +/// This type is used to process a BIP78 response. +/// Call [`Self::process_response`] on it to continue the BIP78 flow. +#[derive(Debug, Clone)] +pub struct V1Context { + psbt_context: PsbtContext, +} + +impl V1Context { + /// Decodes and validates the response. + /// + /// Call this method with response from receiver to continue BIP78 flow. If the response is + /// valid you will get appropriate PSBT that you should sign and broadcast. + #[inline] + pub fn process_response(self, response: &[u8]) -> Result { + if response.len() > MAX_CONTENT_LENGTH { + return Err(ResponseError::from(InternalValidationError::ContentTooLarge)); + } + + let res_str = std::str::from_utf8(response).map_err(|_| InternalValidationError::Parse)?; + let proposal = Psbt::from_str(res_str).map_err(|_| ResponseError::parse(res_str))?; + self.psbt_context.process_proposal(proposal).map_err(Into::into) + } +} + #[cfg(test)] mod test { use std::str::FromStr; @@ -479,7 +711,6 @@ mod test { use bitcoin::ecdsa::Signature; use bitcoin::hex::FromHex; use bitcoin::secp256k1::{Message, PublicKey, Secp256k1, SecretKey}; - use bitcoin::transaction::Version; use bitcoin::{ Amount, FeeRate, OutPoint, Script, ScriptBuf, Sequence, Witness, XOnlyPublicKey, }; @@ -489,9 +720,7 @@ mod test { }; use url::Url; - use super::{ - check_single_payee, clear_unneeded_fields, determine_fee_contribution, serialize_url, - }; + use super::*; use crate::output_substitution::OutputSubstitution; use crate::psbt::PsbtExt; use crate::send::{AdditionalFeeContribution, InternalBuildSenderError, InternalProposalError}; @@ -793,7 +1022,7 @@ mod test { OutputSubstitution::Disabled, None, FeeRate::ZERO, - "2", + Version::Two, ); assert_eq!(url, Url::parse("http://localhost?v=2&disableoutputsubstitution=true")?); @@ -802,7 +1031,7 @@ mod test { OutputSubstitution::Enabled, None, FeeRate::ZERO, - "2", + Version::Two, ); assert_eq!(url, Url::parse("http://localhost?v=2")?); Ok(()) @@ -815,7 +1044,7 @@ mod test { OutputSubstitution::Enabled, None, FeeRate::from_sat_per_vb(10).expect("Could not parse feerate"), - "2", + Version::Two, ); assert_eq!(url, Url::parse("http://localhost?v=2&minfeerate=10")?); Ok(()) @@ -828,7 +1057,7 @@ mod test { OutputSubstitution::Enabled, Some(AdditionalFeeContribution { max_amount: Amount::from_sat(1000), vout: 0 }), FeeRate::ZERO, - "2", + Version::Two, ); assert_eq!( url, @@ -850,7 +1079,7 @@ mod test { let mut proposal: bitcoin::Psbt = PARSED_PAYJOIN_PROPOSAL.clone(); let original_version = ctx.original_psbt.unsigned_tx.version; - let proposed_version = Version::non_standard(88); + let proposed_version = bitcoin::transaction::Version::non_standard(88); proposal.unsigned_tx.version = proposed_version; assert!(matches!( diff --git a/payjoin/src/core/send/multiparty/mod.rs b/payjoin/src/core/send/multiparty/mod.rs index 6a136b09f..47b354b32 100644 --- a/payjoin/src/core/send/multiparty/mod.rs +++ b/payjoin/src/core/send/multiparty/mod.rs @@ -6,22 +6,22 @@ use serde::{Deserialize, Serialize}; use url::Url; use super::v2::{self, extract_request, EncapsulationError, HpkeContext}; -use super::{serialize_url, AdditionalFeeContribution, BuildSenderError, InternalResult}; +use super::{serialize_url, AdditionalFeeContribution, BuildSenderError}; use crate::hpke::decrypt_message_b; use crate::ohttp::{process_get_res, process_post_res}; use crate::output_substitution::OutputSubstitution; use crate::persist::NoopSessionPersister; use crate::send::v2::V2PostContext; use crate::uri::UrlExt; -use crate::{ImplementationError, IntoUrl, PjUri, Request}; +use crate::{ImplementationError, IntoUrl, PjUri, Request, Version}; mod error; #[derive(Clone)] -pub struct SenderBuilder<'a>(v2::SenderBuilder<'a>); +pub struct SenderBuilder(v2::SenderBuilder); -impl<'a> SenderBuilder<'a> { - pub fn new(psbt: Psbt, uri: PjUri<'a>) -> Self { Self(v2::SenderBuilder::new(psbt, uri)) } +impl SenderBuilder { + pub fn new(psbt: Psbt, uri: PjUri) -> Self { Self(v2::SenderBuilder::new(psbt, uri)) } pub fn build_recommended(self, min_fee_rate: FeeRate) -> Result { let noop_persister = NoopSessionPersister::default(); @@ -56,10 +56,10 @@ impl Sender { .ohttp() .map_err(|_| InternalCreateRequestError::MissingOhttpConfig)?; let body = serialize_v2_body( - &self.0.v1.psbt, - self.0.v1.output_substitution, - self.0.v1.fee_contribution, - self.0.v1.min_fee_rate, + &self.0.state.psbt_ctx.original_psbt, + self.0.state.psbt_ctx.output_substitution, + self.0.state.psbt_ctx.fee_contribution, + self.0.state.psbt_ctx.min_fee_rate, )?; let (request, ohttp_ctx) = extract_request( ohttp_relay, @@ -72,13 +72,7 @@ impl Sender { .map_err(InternalCreateRequestError::V2CreateRequest)?; let v2_post_ctx = V2PostContext { endpoint: self.0.endpoint().clone(), - psbt_ctx: crate::send::PsbtContext { - original_psbt: self.0.v1.psbt.clone(), - output_substitution: self.0.v1.output_substitution, - fee_contribution: self.0.v1.fee_contribution, - payee: self.0.v1.payee.clone(), - min_fee_rate: self.0.v1.min_fee_rate, - }, + psbt_ctx: self.0.state.psbt_ctx.clone(), hpke_ctx: HpkeContext::new(rs, &self.0.reply_key), ohttp_ctx, }; @@ -111,7 +105,7 @@ fn serialize_v2_body( output_substitution, fee_contribution, min_fee_rate, - "2", + Version::Two, ); append_optimisitic_merge_query_param(&mut url); let base64 = psbt.to_string(); @@ -143,7 +137,7 @@ impl GetContext { ohttp_ctx: ohttp::ClientResponse, finalize_psbt: impl Fn(&Psbt) -> Result, ) -> Result { - let psbt_ctx = PsbtContext { inner: self.0.psbt_ctx.clone() }; + let psbt_ctx = self.0.psbt_ctx.clone(); let body = match process_get_res(response, ohttp_ctx)? { Some(body) => body, None => return Err(FinalizedError::from(InternalFinalizedError::MissingResponse)), @@ -156,7 +150,8 @@ impl GetContext { .map_err(InternalFinalizedError::Hpke)?; let proposal = Psbt::deserialize(&psbt).map_err(InternalFinalizedError::Psbt)?; - let psbt = psbt_ctx.process_proposal(proposal).map_err(InternalFinalizedError::Proposal)?; + let psbt = + process_proposal(psbt_ctx, proposal).map_err(InternalFinalizedError::Proposal)?; let finalized_psbt = finalize_psbt(&psbt).map_err(InternalFinalizedError::FinalizePsbt)?; Ok(FinalizeContext { hpke_ctx: self.0.hpke_ctx.clone(), @@ -213,21 +208,17 @@ impl FinalizeContext { } } -pub(crate) struct PsbtContext { - inner: crate::send::PsbtContext, -} - -impl PsbtContext { - fn process_proposal(self, mut proposal: Psbt) -> InternalResult { - // TODO(armins) add multiparty check fees modeled after crate::send::PsbtContext::check_fees - // The problem with this is that some of the inputs will be missing witness_utxo or non_witness_utxo field in the psbt so the default psbt.fee() will fail - // Similarly we need to implement a check for the inputs. It would be useful to have all the checks as crate::send::PsbtContext::check_inputs - // However that method expects the receiver to have provided witness for their inputs. In a ns1r the receiver will not sign any inputs of the optimistic merged psbt - self.inner.basic_checks(&proposal)?; - self.inner.check_outputs(&proposal)?; - self.inner.restore_original_utxos(&mut proposal)?; - Ok(proposal) - } +/// The same as `crate::send::PsbtContext::process_proposal` but without checking receiver input finalization +fn process_proposal( + psbt_ctx: crate::send::PsbtContext, + mut proposal: Psbt, +) -> crate::send::InternalResult { + psbt_ctx.basic_checks(&proposal)?; + psbt_ctx.check_inputs(&proposal, false)?; + let contributed_fee = psbt_ctx.check_outputs(&proposal)?; + psbt_ctx.restore_original_utxos(&mut proposal)?; + psbt_ctx.check_fees(&proposal, contributed_fee)?; + Ok(proposal) } fn append_optimisitic_merge_query_param(url: &mut Url) { @@ -240,6 +231,7 @@ mod test { use payjoin_test_utils::BoxError; use url::Url; + use super::*; use crate::output_substitution::OutputSubstitution; use crate::send::multiparty::append_optimisitic_merge_query_param; use crate::send::serialize_url; @@ -251,7 +243,7 @@ mod test { OutputSubstitution::Enabled, None, FeeRate::ZERO, - "2", + Version::Two, ); append_optimisitic_merge_query_param(&mut url); assert_eq!(url, Url::parse("http://localhost?v=2&optimisticmerge=true")?); @@ -261,7 +253,7 @@ mod test { OutputSubstitution::Enabled, None, FeeRate::ZERO, - "2", + Version::Two, ); assert_eq!(url, Url::parse("http://localhost?v=2")?); diff --git a/payjoin/src/core/send/v1.rs b/payjoin/src/core/send/v1.rs index de0f6f5db..aefe6d45f 100644 --- a/payjoin/src/core/send/v1.rs +++ b/payjoin/src/core/send/v1.rs @@ -22,50 +22,37 @@ //! wallet and http client. use bitcoin::psbt::Psbt; -use bitcoin::{FeeRate, ScriptBuf, Weight}; -use error::{BuildSenderError, InternalBuildSenderError}; +use bitcoin::FeeRate; +use error::BuildSenderError; use url::Url; use super::*; pub use crate::output_substitution::OutputSubstitution; -use crate::psbt::PsbtExt; -use crate::{PjUri, Request, MAX_CONTENT_LENGTH}; +use crate::{PjUri, Request}; /// A builder to construct the properties of a `Sender`. #[derive(Clone)] -pub struct SenderBuilder<'a> { - pub(crate) psbt: Psbt, - pub(crate) uri: PjUri<'a>, +pub struct SenderBuilder { + pub(crate) endpoint: Url, pub(crate) output_substitution: OutputSubstitution, - pub(crate) fee_contribution: Option<(bitcoin::Amount, Option)>, - /// Decreases the fee contribution instead of erroring. - /// - /// If this option is true and a transaction with change amount lower than fee - /// contribution is provided then instead of returning error the fee contribution will - /// be just lowered in the request to match the change amount. - pub(crate) clamp_fee_contribution: bool, - pub(crate) min_fee_rate: FeeRate, + pub(crate) psbt_ctx_builder: PsbtContextBuilder, } -/// We only need to add the weight of the txid: 32, index: 4 and sequence: 4 as rust_bitcoin -/// already accounts for the scriptsig length when calculating InputWeightPrediction -/// -const NON_WITNESS_INPUT_WEIGHT: bitcoin::Weight = Weight::from_non_witness_data_size(32 + 4 + 4); - -impl<'a> SenderBuilder<'a> { +impl SenderBuilder { /// Prepare the context from which to make Sender requests /// /// Call [`SenderBuilder::build_recommended()`] or other `build` methods /// to create a [`Sender`] - pub fn new(psbt: Psbt, uri: PjUri<'a>) -> Self { + pub fn new(psbt: Psbt, uri: PjUri) -> Self { Self { - psbt, - uri, - // Sender's optional parameters - output_substitution: OutputSubstitution::Enabled, - fee_contribution: None, - clamp_fee_contribution: false, - min_fee_rate: FeeRate::ZERO, + endpoint: uri.extras.endpoint, + // Adopt the output substitution preference from the URI + output_substitution: uri.extras.output_substitution, + psbt_ctx_builder: PsbtContextBuilder::new( + psbt, + uri.address.script_pubkey(), + uri.amount, + ), } } @@ -75,9 +62,8 @@ impl<'a> SenderBuilder<'a> { /// It is generally **not** recommended to set this as it may prevent the receiver from /// doing advanced operations such as opening LN channels and it also guarantees the /// receiver will **not** reward the sender with a discount. - pub fn always_disable_output_substitution(mut self) -> Self { - self.output_substitution = OutputSubstitution::Disabled; - self + pub fn always_disable_output_substitution(self) -> Self { + Self { output_substitution: OutputSubstitution::Disabled, ..self } } // Calculate the recommended fee contribution for an Original PSBT. @@ -87,61 +73,12 @@ impl<'a> SenderBuilder<'a> { // // This method fails if no recommendation can be made or if the PSBT is malformed. pub fn build_recommended(self, min_fee_rate: FeeRate) -> Result { - // TODO support optional batched payout scripts. This would require a change to - // build() which now checks for a single payee. - let mut payout_scripts = std::iter::once(self.uri.address.script_pubkey()); - - // Check if the PSBT is a sweep transaction with only one output that's a payout script and no change - if self.psbt.unsigned_tx.output.len() == 1 - && payout_scripts.all(|script| script == self.psbt.unsigned_tx.output[0].script_pubkey) - { - return self.build_non_incentivizing(min_fee_rate); - } - - if let Some((additional_fee_index, fee_available)) = self - .psbt - .unsigned_tx - .output - .clone() - .into_iter() - .enumerate() - .find(|(_, txo)| payout_scripts.all(|script| script != txo.script_pubkey)) - .map(|(i, txo)| (i, txo.value)) - { - let mut input_pairs = self.psbt.input_pairs(); - let first_input_pair = input_pairs.next().ok_or(InternalBuildSenderError::NoInputs)?; - let mut input_weight = first_input_pair - .expected_input_weight() - .map_err(InternalBuildSenderError::InputWeight)?; - for input_pair in input_pairs { - // use cheapest default if mixed input types - if input_pair.address_type()? != first_input_pair.address_type()? { - input_weight = - bitcoin::transaction::InputWeightPrediction::P2TR_KEY_NON_DEFAULT_SIGHASH - .weight() - + NON_WITNESS_INPUT_WEIGHT; - break; - } - } - - let recommended_additional_fee = min_fee_rate * input_weight; - if fee_available < recommended_additional_fee { - log::warn!("Insufficient funds to maintain specified minimum feerate."); - return self.build_with_additional_fee( - fee_available, - Some(additional_fee_index), - min_fee_rate, - true, - ); - } - return self.build_with_additional_fee( - recommended_additional_fee, - Some(additional_fee_index), - min_fee_rate, - false, - ); - } - self.build_non_incentivizing(min_fee_rate) + Ok(Sender { + endpoint: self.endpoint, + psbt_ctx: self + .psbt_ctx_builder + .build_recommended(min_fee_rate, self.output_substitution)?, + }) } /// Offer the receiver contribution to pay for his input. @@ -158,16 +95,22 @@ impl<'a> SenderBuilder<'a> { /// contribution is provided then instead of returning error the fee contribution will /// be just lowered in the request to match the change amount. pub fn build_with_additional_fee( - mut self, + self, max_fee_contribution: bitcoin::Amount, change_index: Option, min_fee_rate: FeeRate, clamp_fee_contribution: bool, ) -> Result { - self.fee_contribution = Some((max_fee_contribution, change_index)); - self.clamp_fee_contribution = clamp_fee_contribution; - self.min_fee_rate = min_fee_rate; - self.build() + Ok(Sender { + endpoint: self.endpoint, + psbt_ctx: self.psbt_ctx_builder.build_with_additional_fee( + max_fee_contribution, + change_index, + min_fee_rate, + clamp_fee_contribution, + self.output_substitution, + )?, + }) } /// Perform Payjoin without incentivizing the payee to cooperate. @@ -175,119 +118,39 @@ impl<'a> SenderBuilder<'a> { /// While it's generally better to offer some contribution some users may wish not to. /// This function disables contribution. pub fn build_non_incentivizing( - mut self, + self, min_fee_rate: FeeRate, ) -> Result { - // since this is a builder, these should already be cleared - // but we'll reset them to be sure - self.fee_contribution = None; - self.clamp_fee_contribution = false; - self.min_fee_rate = min_fee_rate; - self.build() - } - - fn build(self) -> Result { - let mut psbt = - self.psbt.validate().map_err(InternalBuildSenderError::InconsistentOriginalPsbt)?; - psbt.validate_input_utxos().map_err(InternalBuildSenderError::InvalidOriginalInput)?; - let endpoint = self.uri.extras.endpoint.clone(); - let output_substitution = - self.uri.extras.output_substitution.combine(self.output_substitution); - let payee = self.uri.address.script_pubkey(); - - check_single_payee(&psbt, &payee, self.uri.amount)?; - let fee_contribution = determine_fee_contribution( - &psbt, - &payee, - self.fee_contribution, - self.clamp_fee_contribution, - )?; - clear_unneeded_fields(&mut psbt); - Ok(Sender { - psbt, - endpoint, - output_substitution, - fee_contribution, - payee, - min_fee_rate: self.min_fee_rate, + endpoint: self.endpoint, + psbt_ctx: self + .psbt_ctx_builder + .build_non_incentivizing(min_fee_rate, self.output_substitution)?, }) } } /// A payjoin V1 sender, allowing the construction of a payjoin V1 request /// and the resulting `V1Context` -#[derive(Clone, PartialEq, Eq, Debug)] -#[cfg_attr(feature = "v2", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Debug)] +#[cfg_attr(feature = "v2", derive(PartialEq, Eq, serde::Serialize, serde::Deserialize))] pub struct Sender { - /// The original PSBT. - pub(crate) psbt: Psbt, /// The endpoint in the Payjoin URI pub(crate) endpoint: Url, - /// Whether the receiver is allowed to substitute original outputs. - pub(crate) output_substitution: OutputSubstitution, - /// (maxadditionalfeecontribution, additionalfeeoutputindex) - pub(crate) fee_contribution: Option, - pub(crate) min_fee_rate: FeeRate, - /// Script of the person being paid - pub(crate) payee: ScriptBuf, + /// The original PSBT. + pub(crate) psbt_ctx: PsbtContext, } impl Sender { /// Construct serialized V1 Request and Context from a Payjoin Proposal pub fn create_v1_post_request(&self) -> (Request, V1Context) { - let url = serialize_url( - self.endpoint.clone(), - self.output_substitution, - self.fee_contribution, - self.min_fee_rate, - "1", // payjoin version - ); - let body = self.psbt.to_string().as_bytes().to_vec(); - ( - Request::new_v1(&url, &body), - V1Context { - psbt_context: PsbtContext { - original_psbt: self.psbt.clone(), - output_substitution: self.output_substitution, - fee_contribution: self.fee_contribution, - payee: self.payee.clone(), - min_fee_rate: self.min_fee_rate, - }, - }, - ) + super::create_v1_post_request(self.endpoint.clone(), self.psbt_ctx.clone()) } /// The endpoint in the Payjoin URI pub fn endpoint(&self) -> &Url { &self.endpoint } } -/// Data required to validate the response. -/// -/// This type is used to process a BIP78 response. -/// Call [`Self::process_response`] on it to continue the BIP78 flow. -#[derive(Debug, Clone)] -pub struct V1Context { - psbt_context: PsbtContext, -} - -impl V1Context { - /// Decodes and validates the response. - /// - /// Call this method with response from receiver to continue BIP78 flow. If the response is - /// valid you will get appropriate PSBT that you should sign and broadcast. - #[inline] - pub fn process_response(self, response: &[u8]) -> Result { - if response.len() > MAX_CONTENT_LENGTH { - return Err(ResponseError::from(InternalValidationError::ContentTooLarge)); - } - - let res_str = std::str::from_utf8(response).map_err(|_| InternalValidationError::Parse)?; - let proposal = Psbt::from_str(res_str).map_err(|_| ResponseError::parse(res_str))?; - self.psbt_context.process_proposal(proposal).map_err(Into::into) - } -} - #[cfg(test)] mod test { use bitcoin::FeeRate; @@ -340,7 +203,10 @@ mod test { ) .build_recommended(FeeRate::MIN); assert!(sender.is_ok(), "{:#?}", sender.err()); - assert_eq!(sender.unwrap().fee_contribution.unwrap().max_amount, Amount::from_sat(0)); + assert_eq!( + sender.unwrap().psbt_ctx.fee_contribution.unwrap().max_amount, + Amount::from_sat(0) + ); Ok(()) } @@ -365,7 +231,10 @@ mod test { ) .build_recommended(FeeRate::MIN); assert!(sender.is_ok(), "{:#?}", sender.err()); - assert_eq!(sender.unwrap().fee_contribution.unwrap().max_amount, Amount::from_sat(0)); + assert_eq!( + sender.unwrap().psbt_ctx.fee_contribution.unwrap().max_amount, + Amount::from_sat(0) + ); let mut psbt = Psbt::from_str(MULTIPARTY_ORIGINAL_PSBT_ONE).unwrap(); psbt.unsigned_tx.input.pop(); @@ -381,7 +250,7 @@ mod test { .build_recommended(FeeRate::from_sat_per_vb(170000000).expect("Could not determine feerate")); assert!(sender.is_ok(), "{:#?}", sender.err()); assert_eq!( - sender.unwrap().fee_contribution.unwrap().max_amount, + sender.unwrap().psbt_ctx.fee_contribution.unwrap().max_amount, Amount::from_sat(9999999822) ); @@ -396,12 +265,13 @@ mod test { FeeRate::from_sat_per_vb(2000000).expect("Could not determine feerate"), ) .expect("sender should succeed"); - assert_eq!(sender.output_substitution, OutputSubstitution::Disabled); - assert_eq!(&sender.payee, &pj_uri().address.script_pubkey()); - let fee_contribution = sender.fee_contribution.expect("sender should contribute fees"); + assert_eq!(sender.psbt_ctx.output_substitution, OutputSubstitution::Disabled); + assert_eq!(&sender.psbt_ctx.payee, &pj_uri().address.script_pubkey()); + let fee_contribution = + sender.psbt_ctx.fee_contribution.expect("sender should contribute fees"); assert_eq!(fee_contribution.max_amount, psbt.unsigned_tx.output[0].value); assert_eq!(fee_contribution.vout, 0); - assert_eq!(sender.min_fee_rate, FeeRate::from_sat_per_kwu(500000000)); + assert_eq!(sender.psbt_ctx.min_fee_rate, FeeRate::from_sat_per_kwu(500000000)); } #[test] @@ -409,19 +279,20 @@ mod test { let sender = SenderBuilder::new(PARSED_ORIGINAL_PSBT.clone(), pj_uri()) .build_recommended(FeeRate::BROADCAST_MIN) .expect("sender should succeed"); - assert_eq!(sender.output_substitution, OutputSubstitution::Disabled); - assert_eq!(&sender.payee, &pj_uri().address.script_pubkey()); - let fee_contribution = sender.fee_contribution.expect("sender should contribute fees"); + assert_eq!(sender.psbt_ctx.output_substitution, OutputSubstitution::Disabled); + assert_eq!(&sender.psbt_ctx.payee, &pj_uri().address.script_pubkey()); + let fee_contribution = + sender.psbt_ctx.fee_contribution.expect("sender should contribute fees"); assert_eq!(fee_contribution.max_amount, Amount::from_sat(91)); assert_eq!(fee_contribution.vout, 0); - assert_eq!(sender.min_fee_rate, FeeRate::from_sat_per_kwu(250)); + assert_eq!(sender.psbt_ctx.min_fee_rate, FeeRate::from_sat_per_kwu(250)); // Ensure the receiver's output substitution preference is respected either way let mut pj_uri = pj_uri(); pj_uri.extras.output_substitution = OutputSubstitution::Enabled; let sender = SenderBuilder::new(PARSED_ORIGINAL_PSBT.clone(), pj_uri) .build_recommended(FeeRate::from_sat_per_vb_unchecked(1)) .expect("sender should succeed"); - assert_eq!(sender.output_substitution, OutputSubstitution::Enabled); + assert_eq!(sender.psbt_ctx.output_substitution, OutputSubstitution::Enabled); } #[test] diff --git a/payjoin/src/core/send/v2/mod.rs b/payjoin/src/core/send/v2/mod.rs index 388f08987..89ff960a0 100644 --- a/payjoin/src/core/send/v2/mod.rs +++ b/payjoin/src/core/send/v2/mod.rs @@ -43,7 +43,6 @@ use crate::ohttp::{ohttp_encapsulate, process_get_res, process_post_res}; use crate::persist::{ MaybeBadInitInputsTransition, MaybeFatalTransition, MaybeSuccessTransitionWithNoResults, }; -use crate::send::v1; use crate::send::v2::session::InternalReplayError; use crate::uri::{ShortId, UrlExt}; use crate::{HpkeKeyPair, HpkePublicKey, IntoUrl, OhttpKeys, PjUri, Request}; @@ -52,15 +51,36 @@ mod error; mod session; /// A builder to construct the properties of a [`Sender`]. +/// V2 SenderBuilder differs from V1 in that it does not allow the receiver's output substitution preference to be disabled. +/// This is because all communications with the receiver are end-to-end authenticated. So a +/// malicious man in the middle can't substitute outputs, only the receiver can. +/// The receiver can always choose not to substitute outputs, however. #[derive(Clone)] -pub struct SenderBuilder<'a>(pub(crate) v1::SenderBuilder<'a>); +pub struct SenderBuilder { + endpoint: Url, + output_substitution: OutputSubstitution, + psbt_ctx_builder: PsbtContextBuilder, +} -impl<'a> SenderBuilder<'a> { +impl SenderBuilder { /// Prepare the context from which to make Sender requests /// /// Call [`SenderBuilder::build_recommended()`] or other `build` methods /// to create a [`Sender`] - pub fn new(psbt: Psbt, uri: PjUri<'a>) -> Self { Self(v1::SenderBuilder::new(psbt, uri)) } + pub fn new(psbt: Psbt, uri: PjUri) -> Self { + Self { + endpoint: uri.extras.endpoint, + // Ignore the receiver's output substitution preference, because all + // communications with the receiver are end-to-end authenticated. So a + // malicious man in the middle can't substitute outputs, only the receiver can. + output_substitution: OutputSubstitution::Enabled, + psbt_ctx_builder: PsbtContextBuilder::new( + psbt, + uri.address.script_pubkey(), + uri.amount, + ), + } + } /// Disable output substitution even if the receiver didn't. /// @@ -69,7 +89,7 @@ impl<'a> SenderBuilder<'a> { /// doing advanced operations such as opening LN channels and it also guarantees the /// receiver will **not** reward the sender with a discount. pub fn always_disable_output_substitution(self) -> Self { - Self(self.0.always_disable_output_substitution()) + Self { output_substitution: OutputSubstitution::Disabled, ..self } } // Calculate the recommended fee contribution for an Original PSBT. @@ -82,7 +102,10 @@ impl<'a> SenderBuilder<'a> { self, min_fee_rate: FeeRate, ) -> MaybeBadInitInputsTransition, BuildSenderError> { - self.v2_sender_from_v1(self.0.clone().build_recommended(min_fee_rate)) + Self::v2_sender_from_psbt_ctx_result( + self.endpoint, + self.psbt_ctx_builder.build_recommended(min_fee_rate, self.output_substitution), + ) } /// Offer the receiver contribution to pay for his input. @@ -105,12 +128,16 @@ impl<'a> SenderBuilder<'a> { min_fee_rate: FeeRate, clamp_fee_contribution: bool, ) -> MaybeBadInitInputsTransition, BuildSenderError> { - self.v2_sender_from_v1(self.0.clone().build_with_additional_fee( - max_fee_contribution, - change_index, - min_fee_rate, - clamp_fee_contribution, - )) + Self::v2_sender_from_psbt_ctx_result( + self.endpoint, + self.psbt_ctx_builder.build_with_additional_fee( + max_fee_contribution, + change_index, + min_fee_rate, + clamp_fee_contribution, + self.output_substitution, + ), + ) } /// Perform Payjoin without incentivizing the payee to cooperate. @@ -121,27 +148,24 @@ impl<'a> SenderBuilder<'a> { self, min_fee_rate: FeeRate, ) -> MaybeBadInitInputsTransition, BuildSenderError> { - self.v2_sender_from_v1(self.0.clone().build_non_incentivizing(min_fee_rate)) + Self::v2_sender_from_psbt_ctx_result( + self.endpoint, + self.psbt_ctx_builder.build_non_incentivizing(min_fee_rate, self.output_substitution), + ) } /// Helper function that takes a V1 sender build result and wraps it in a V2 Sender, /// returning the appropriate state transition. - fn v2_sender_from_v1( - &self, - v1_result: Result, + fn v2_sender_from_psbt_ctx_result( + endpoint: Url, + psbt_ctx_result: Result, ) -> MaybeBadInitInputsTransition, BuildSenderError> { - let mut v1 = match v1_result { + let psbt_ctx = match psbt_ctx_result { Ok(inner) => inner, Err(e) => return MaybeBadInitInputsTransition::bad_init_inputs(e), }; - // V2 senders may always ignore the receiver's `pjos` output substitution preference, - // because all communications with the receiver are end-to-end authenticated. - if self.0.output_substitution == OutputSubstitution::Enabled { - v1.output_substitution = OutputSubstitution::Enabled; - } - - let with_reply_key = WithReplyKey { v1, reply_key: HpkeKeyPair::gen_keypair().0 }; + let with_reply_key = WithReplyKey::new(endpoint.clone(), psbt_ctx); MaybeBadInitInputsTransition::success( SessionEvent::CreatedReplyKey(with_reply_key.clone()), Sender { state: with_reply_key }, @@ -202,18 +226,26 @@ impl SendSession { /// and the resulting [`V2PostContext`]. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct WithReplyKey { - /// The v1 Sender. - pub(crate) v1: v1::Sender, + /// The endpoint in the Payjoin URI + pub(crate) endpoint: Url, + /// The Original PSBT context + pub(crate) psbt_ctx: PsbtContext, /// The secret key to decrypt the receiver's reply. pub(crate) reply_key: HpkeSecretKey, } impl State for WithReplyKey {} +impl WithReplyKey { + pub fn new(endpoint: Url, psbt_ctx: PsbtContext) -> Self { + Self { endpoint, psbt_ctx, reply_key: HpkeKeyPair::gen_keypair().0 } + } +} + impl Sender { /// Construct serialized V1 Request and Context from a Payjoin Proposal - pub fn create_v1_post_request(&self) -> (Request, v1::V1Context) { - self.v1.create_v1_post_request() + pub fn create_v1_post_request(&self) -> (Request, V1Context) { + super::create_v1_post_request(self.endpoint.clone(), self.psbt_ctx.clone()) } /// Construct serialized Request and Context from a Payjoin Proposal. @@ -229,28 +261,25 @@ impl Sender { &self, ohttp_relay: impl IntoUrl, ) -> Result<(Request, V2PostContext), CreateRequestError> { - if let Ok(expiry) = self.v1.endpoint.exp() { + if let Ok(expiry) = self.endpoint.exp() { if std::time::SystemTime::now() > expiry { return Err(InternalCreateRequestError::Expired(expiry).into()); } } - let mut ohttp_keys = self - .v1 - .endpoint() - .ohttp() - .map_err(|_| InternalCreateRequestError::MissingOhttpConfig)?; + let mut ohttp_keys = + self.endpoint().ohttp().map_err(|_| InternalCreateRequestError::MissingOhttpConfig)?; let body = serialize_v2_body( - &self.v1.psbt, - self.v1.output_substitution, - self.v1.fee_contribution, - self.v1.min_fee_rate, + &self.psbt_ctx.original_psbt, + self.psbt_ctx.output_substitution, + self.psbt_ctx.fee_contribution, + self.psbt_ctx.min_fee_rate, )?; let (request, ohttp_ctx) = extract_request( ohttp_relay, self.reply_key.clone(), body, - self.v1.endpoint.clone(), + self.endpoint.clone(), self.extract_rs_pubkey()?, &mut ohttp_keys, )?; @@ -258,14 +287,8 @@ impl Sender { Ok(( request, V2PostContext { - endpoint: self.v1.endpoint.clone(), - psbt_ctx: PsbtContext { - original_psbt: self.v1.psbt.clone(), - output_substitution: self.v1.output_substitution, - fee_contribution: self.v1.fee_contribution, - payee: self.v1.payee.clone(), - min_fee_rate: self.v1.min_fee_rate, - }, + endpoint: self.endpoint.clone(), + psbt_ctx: self.psbt_ctx.clone(), hpke_ctx: HpkeContext::new(rs, &self.reply_key), ohttp_ctx, }, @@ -311,11 +334,11 @@ impl Sender { pub(crate) fn extract_rs_pubkey( &self, ) -> Result { - self.v1.endpoint.receiver_pubkey() + self.endpoint.receiver_pubkey() } /// The endpoint in the Payjoin URI - pub fn endpoint(&self) -> &Url { self.v1.endpoint() } + pub fn endpoint(&self) -> &Url { &self.endpoint } pub(crate) fn apply_v2_get_context(self, v2_get_context: V2GetContext) -> SendSession { SendSession::V2GetContext(Sender { state: v2_get_context }) @@ -359,13 +382,8 @@ pub(crate) fn serialize_v2_body( // Grug say localhost base be discarded anyway. no big brain needed. let base_url = Url::parse("http://localhost").expect("invalid URL"); - let placeholder_url = serialize_url( - base_url, - output_substitution, - fee_contribution, - min_fee_rate, - "2", // payjoin version - ); + let placeholder_url = + serialize_url(base_url, output_substitution, fee_contribution, min_fee_rate, Version::Two); let query_params = placeholder_url.query().unwrap_or_default(); let base64 = psbt.to_string(); Ok(format!("{base64}\n{query_params}").into_bytes()) @@ -521,9 +539,9 @@ mod test { let endpoint = Url::parse("http://localhost:1234")?; let mut sender = super::Sender { state: super::WithReplyKey { - v1: v1::Sender { - psbt: PARSED_ORIGINAL_PSBT.clone(), - endpoint, + endpoint, + psbt_ctx: PsbtContext { + original_psbt: PARSED_ORIGINAL_PSBT.clone(), output_substitution: OutputSubstitution::Enabled, fee_contribution: None, min_fee_rate: FeeRate::ZERO, @@ -532,9 +550,9 @@ mod test { reply_key: HpkeKeyPair::gen_keypair().0, }, }; - sender.v1.endpoint.set_exp(SystemTime::now() + Duration::from_secs(60)); - sender.v1.endpoint.set_receiver_pubkey(HpkeKeyPair::gen_keypair().1); - sender.v1.endpoint.set_ohttp(OhttpKeys( + sender.endpoint.set_exp(SystemTime::now() + Duration::from_secs(60)); + sender.endpoint.set_receiver_pubkey(HpkeKeyPair::gen_keypair().1); + sender.endpoint.set_ohttp(OhttpKeys( ohttp::KeyConfig::new(KEY_ID, KEM, Vec::from(SYMMETRIC)).expect("valid key config"), )); @@ -545,10 +563,10 @@ mod test { fn test_serialize_v2() -> Result<(), BoxError> { let sender = create_sender_context()?; let body = serialize_v2_body( - &sender.v1.psbt, - sender.v1.output_substitution, - sender.v1.fee_contribution, - sender.v1.min_fee_rate, + &sender.psbt_ctx.original_psbt, + sender.psbt_ctx.output_substitution, + sender.psbt_ctx.fee_contribution, + sender.psbt_ctx.min_fee_rate, ); assert_eq!(body.as_ref().unwrap(), & as FromHex>::from_hex(SERIALIZED_BODY_V2)?,); Ok(()) @@ -563,10 +581,10 @@ mod test { assert!(!request.body.is_empty(), "Request body should not be empty"); assert_eq!( request.url.to_string(), - format!("{}{}", EXAMPLE_URL.clone(), sender.v1.endpoint.join("/")?) + format!("{}{}", EXAMPLE_URL.clone(), sender.endpoint.join("/")?) ); - assert_eq!(context.endpoint, sender.v1.endpoint); - assert_eq!(context.psbt_ctx.original_psbt, sender.v1.psbt); + assert_eq!(context.endpoint, sender.endpoint); + assert_eq!(context.psbt_ctx.original_psbt, sender.psbt_ctx.original_psbt); Ok(()) } @@ -574,9 +592,9 @@ mod test { fn test_extract_v2_fails_missing_pubkey() -> Result<(), BoxError> { let expected_error = "cannot parse receiver public key: receiver public key is missing"; let mut sender = create_sender_context()?; - sender.v1.endpoint.set_fragment(Some("")); - sender.v1.endpoint.set_exp(SystemTime::now() + Duration::from_secs(60)); - sender.v1.endpoint.set_ohttp(OhttpKeys( + sender.endpoint.set_fragment(Some("")); + sender.endpoint.set_exp(SystemTime::now() + Duration::from_secs(60)); + sender.endpoint.set_ohttp(OhttpKeys( ohttp::KeyConfig::new(KEY_ID, KEM, Vec::from(SYMMETRIC)).expect("valid key config"), )); let ohttp_relay = EXAMPLE_URL.clone(); @@ -594,9 +612,9 @@ mod test { fn test_extract_v2_fails_missing_ohttp_config() -> Result<(), BoxError> { let expected_error = "no ohttp configuration with which to make a v2 request available"; let mut sender = create_sender_context()?; - sender.v1.endpoint.set_fragment(Some("")); - sender.v1.endpoint.set_exp(SystemTime::now() + Duration::from_secs(60)); - sender.v1.endpoint.set_receiver_pubkey(HpkeKeyPair::gen_keypair().1); + sender.endpoint.set_fragment(Some("")); + sender.endpoint.set_exp(SystemTime::now() + Duration::from_secs(60)); + sender.endpoint.set_receiver_pubkey(HpkeKeyPair::gen_keypair().1); let ohttp_relay = EXAMPLE_URL.clone(); let result = sender.create_v2_post_request(ohttp_relay); assert!(result.is_err(), "Extract v2 expected missing ohttp error, but it succeeded"); @@ -613,7 +631,7 @@ mod test { let expected_error = "session expired at SystemTime"; let mut sender = create_sender_context()?; let exp_time = std::time::SystemTime::now(); - sender.v1.endpoint.set_exp(exp_time); + sender.endpoint.set_exp(exp_time); let ohttp_relay = EXAMPLE_URL.clone(); let result = sender.create_v2_post_request(ohttp_relay); assert!(result.is_err(), "Extract v2 expected expiry error, but it succeeded"); @@ -649,29 +667,30 @@ mod test { .expect("sender should succeed"); // v2 senders may always override the receiver's `pjos` parameter to enable output // substitution - assert_eq!(req_ctx.v1.output_substitution, OutputSubstitution::Enabled); - assert_eq!(&req_ctx.v1.payee, &address.script_pubkey()); - let fee_contribution = req_ctx.v1.fee_contribution.expect("sender should contribute fees"); + assert_eq!(req_ctx.state.psbt_ctx.output_substitution, OutputSubstitution::Enabled); + assert_eq!(&req_ctx.state.psbt_ctx.payee, &address.script_pubkey()); + let fee_contribution = + req_ctx.state.psbt_ctx.fee_contribution.expect("sender should contribute fees"); assert_eq!(fee_contribution.max_amount, Amount::from_sat(91)); assert_eq!(fee_contribution.vout, 0); - assert_eq!(req_ctx.v1.min_fee_rate, FeeRate::from_sat_per_kwu(250)); + assert_eq!(req_ctx.state.psbt_ctx.min_fee_rate, FeeRate::from_sat_per_kwu(250)); // ensure that the other builder methods also enable output substitution let req_ctx = SenderBuilder::new(PARSED_ORIGINAL_PSBT.clone(), pj_uri.clone()) .build_non_incentivizing(FeeRate::BROADCAST_MIN) .save(&NoopSessionPersister::default()) .expect("sender should succeed"); - assert_eq!(req_ctx.v1.output_substitution, OutputSubstitution::Enabled); + assert_eq!(req_ctx.state.psbt_ctx.output_substitution, OutputSubstitution::Enabled); let req_ctx = SenderBuilder::new(PARSED_ORIGINAL_PSBT.clone(), pj_uri.clone()) .build_with_additional_fee(Amount::ZERO, Some(0), FeeRate::BROADCAST_MIN, false) .save(&NoopSessionPersister::default()) .expect("sender should succeed"); - assert_eq!(req_ctx.v1.output_substitution, OutputSubstitution::Enabled); + assert_eq!(req_ctx.state.psbt_ctx.output_substitution, OutputSubstitution::Enabled); // ensure that a v2 sender may still disable output substitution if they prefer. let req_ctx = SenderBuilder::new(PARSED_ORIGINAL_PSBT.clone(), pj_uri) .always_disable_output_substitution() .build_recommended(FeeRate::BROADCAST_MIN) .save(&NoopSessionPersister::default()) .expect("sender should succeed"); - assert_eq!(req_ctx.v1.output_substitution, OutputSubstitution::Disabled); + assert_eq!(req_ctx.state.psbt_ctx.output_substitution, OutputSubstitution::Disabled); } } diff --git a/payjoin/src/core/send/v2/session.rs b/payjoin/src/core/send/v2/session.rs index 95be6dcb2..1218a7045 100644 --- a/payjoin/src/core/send/v2/session.rs +++ b/payjoin/src/core/send/v2/session.rs @@ -71,14 +71,14 @@ impl SessionHistory { pub fn fallback_tx(&self) -> Option { self.events.iter().find_map(|event| match event { SessionEvent::CreatedReplyKey(proposal) => - Some(proposal.v1.psbt.clone().extract_tx_unchecked_fee_rate()), + Some(proposal.psbt_ctx.original_psbt.clone().extract_tx_unchecked_fee_rate()), _ => None, }) } pub fn endpoint(&self) -> Option<&Url> { self.events.iter().find_map(|event| match event { - SessionEvent::CreatedReplyKey(proposal) => Some(&proposal.v1.endpoint), + SessionEvent::CreatedReplyKey(proposal) => Some(&proposal.endpoint), _ => None, }) } @@ -106,7 +106,7 @@ mod tests { use crate::persist::test_utils::InMemoryTestPersister; use crate::send::v1::SenderBuilder; use crate::send::v2::{HpkeContext, Sender}; - use crate::send::{v1, PsbtContext}; + use crate::send::PsbtContext; use crate::{HpkeKeyPair, Uri, UriExt}; const PJ_URI: &str = @@ -117,9 +117,9 @@ mod tests { let endpoint = Url::parse("http://localhost:1234").expect("Valid URL"); let keypair = HpkeKeyPair::gen_keypair(); let sender_with_reply_key = WithReplyKey { - v1: v1::Sender { - psbt: PARSED_ORIGINAL_PSBT.clone(), - endpoint: endpoint.clone(), + endpoint: endpoint.clone(), + psbt_ctx: PsbtContext { + original_psbt: PARSED_ORIGINAL_PSBT.clone(), output_substitution: OutputSubstitution::Enabled, fee_contribution: None, min_fee_rate: FeeRate::ZERO, @@ -194,8 +194,12 @@ mod tests { .unwrap(); let reply_key = HpkeKeyPair::gen_keypair(); let endpoint = sender.endpoint().clone(); - let fallback_tx = sender.psbt.clone().extract_tx_unchecked_fee_rate(); - let with_reply_key = WithReplyKey { v1: sender, reply_key: reply_key.0 }; + let fallback_tx = sender.psbt_ctx.original_psbt.clone().extract_tx_unchecked_fee_rate(); + let with_reply_key = WithReplyKey { + endpoint: endpoint.clone(), + psbt_ctx: sender.psbt_ctx.clone(), + reply_key: reply_key.0, + }; let sender = Sender { state: with_reply_key.clone() }; let test = SessionHistoryTest { events: vec![SessionEvent::CreatedReplyKey(with_reply_key)],