From 109f85d532e31c41a10cf06feee9b1fe82fc1689 Mon Sep 17 00:00:00 2001 From: DanGould Date: Fri, 27 Jun 2025 14:17:48 -0400 Subject: [PATCH 1/4] Share PsbtContext between Sender structs The PsbtContext is used in both v1 and v2 and can be shared. --- payjoin/src/core/send/multiparty/mod.rs | 16 ++--- payjoin/src/core/send/v1.rs | 79 +++++++++++++------------ payjoin/src/core/send/v2/mod.rs | 55 ++++++++--------- payjoin/src/core/send/v2/session.rs | 16 ++--- 4 files changed, 81 insertions(+), 85 deletions(-) diff --git a/payjoin/src/core/send/multiparty/mod.rs b/payjoin/src/core/send/multiparty/mod.rs index 6a136b09f..48eb8c8ab 100644 --- a/payjoin/src/core/send/multiparty/mod.rs +++ b/payjoin/src/core/send/multiparty/mod.rs @@ -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.v1.psbt_ctx.original_psbt, + self.0.v1.psbt_ctx.output_substitution, + self.0.v1.psbt_ctx.fee_contribution, + self.0.v1.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.v1.psbt_ctx.clone(), hpke_ctx: HpkeContext::new(rs, &self.0.reply_key), ohttp_ctx, }; diff --git a/payjoin/src/core/send/v1.rs b/payjoin/src/core/send/v1.rs index de0f6f5db..b84aa29a0 100644 --- a/payjoin/src/core/send/v1.rs +++ b/payjoin/src/core/send/v1.rs @@ -22,7 +22,7 @@ //! wallet and http client. use bitcoin::psbt::Psbt; -use bitcoin::{FeeRate, ScriptBuf, Weight}; +use bitcoin::{FeeRate, Weight}; use error::{BuildSenderError, InternalBuildSenderError}; use url::Url; @@ -205,32 +205,27 @@ impl<'a> SenderBuilder<'a> { clear_unneeded_fields(&mut psbt); Ok(Sender { - psbt, endpoint, - output_substitution, - fee_contribution, - payee, - min_fee_rate: self.min_fee_rate, + psbt_ctx: PsbtContext { + original_psbt: psbt, + output_substitution, + fee_contribution, + payee, + min_fee_rate: self.min_fee_rate, + }, }) } } /// 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 { @@ -238,21 +233,21 @@ impl Sender { 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, + self.psbt_ctx.output_substitution, + self.psbt_ctx.fee_contribution, + self.psbt_ctx.min_fee_rate, "1", // payjoin version ); - let body = self.psbt.to_string().as_bytes().to_vec(); + let body = self.psbt_ctx.original_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, + original_psbt: self.psbt_ctx.original_psbt.clone(), + output_substitution: self.psbt_ctx.output_substitution, + fee_contribution: self.psbt_ctx.fee_contribution, + payee: self.psbt_ctx.payee.clone(), + min_fee_rate: self.psbt_ctx.min_fee_rate, }, }, ) @@ -340,7 +335,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 +363,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 +382,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 +397,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 +411,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..66269ecfb 100644 --- a/payjoin/src/core/send/v2/mod.rs +++ b/payjoin/src/core/send/v2/mod.rs @@ -138,7 +138,7 @@ impl<'a> SenderBuilder<'a> { // 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; + v1.psbt_ctx.output_substitution = OutputSubstitution::Enabled; } let with_reply_key = WithReplyKey { v1, reply_key: HpkeKeyPair::gen_keypair().0 }; @@ -241,10 +241,10 @@ impl Sender { .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.v1.psbt_ctx.original_psbt, + self.v1.psbt_ctx.output_substitution, + self.v1.psbt_ctx.fee_contribution, + self.v1.psbt_ctx.min_fee_rate, )?; let (request, ohttp_ctx) = extract_request( ohttp_relay, @@ -259,13 +259,7 @@ impl Sender { 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, - }, + psbt_ctx: self.v1.psbt_ctx.clone(), hpke_ctx: HpkeContext::new(rs, &self.reply_key), ohttp_ctx, }, @@ -522,12 +516,14 @@ mod test { let mut sender = super::Sender { state: super::WithReplyKey { v1: v1::Sender { - psbt: PARSED_ORIGINAL_PSBT.clone(), endpoint, - output_substitution: OutputSubstitution::Enabled, - fee_contribution: None, - min_fee_rate: FeeRate::ZERO, - payee: ScriptBuf::from(vec![0x00]), + psbt_ctx: PsbtContext { + original_psbt: PARSED_ORIGINAL_PSBT.clone(), + output_substitution: OutputSubstitution::Enabled, + fee_contribution: None, + min_fee_rate: FeeRate::ZERO, + payee: ScriptBuf::from(vec![0x00]), + }, }, reply_key: HpkeKeyPair::gen_keypair().0, }, @@ -545,10 +541,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.v1.psbt_ctx.original_psbt, + sender.v1.psbt_ctx.output_substitution, + sender.v1.psbt_ctx.fee_contribution, + sender.v1.psbt_ctx.min_fee_rate, ); assert_eq!(body.as_ref().unwrap(), & as FromHex>::from_hex(SERIALIZED_BODY_V2)?,); Ok(()) @@ -566,7 +562,7 @@ mod test { format!("{}{}", EXAMPLE_URL.clone(), sender.v1.endpoint.join("/")?) ); assert_eq!(context.endpoint, sender.v1.endpoint); - assert_eq!(context.psbt_ctx.original_psbt, sender.v1.psbt); + assert_eq!(context.psbt_ctx.original_psbt, sender.v1.psbt_ctx.original_psbt); Ok(()) } @@ -649,29 +645,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.v1.psbt_ctx.output_substitution, OutputSubstitution::Enabled); + assert_eq!(&req_ctx.v1.psbt_ctx.payee, &address.script_pubkey()); + let fee_contribution = + req_ctx.v1.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.v1.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.v1.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.v1.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.v1.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..cf5a4a336 100644 --- a/payjoin/src/core/send/v2/session.rs +++ b/payjoin/src/core/send/v2/session.rs @@ -71,7 +71,7 @@ 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.v1.psbt_ctx.original_psbt.clone().extract_tx_unchecked_fee_rate()), _ => None, }) } @@ -118,12 +118,14 @@ mod tests { let keypair = HpkeKeyPair::gen_keypair(); let sender_with_reply_key = WithReplyKey { v1: v1::Sender { - psbt: PARSED_ORIGINAL_PSBT.clone(), endpoint: endpoint.clone(), - output_substitution: OutputSubstitution::Enabled, - fee_contribution: None, - min_fee_rate: FeeRate::ZERO, - payee: ScriptBuf::from(vec![0x00]), + psbt_ctx: PsbtContext { + original_psbt: PARSED_ORIGINAL_PSBT.clone(), + output_substitution: OutputSubstitution::Enabled, + fee_contribution: None, + min_fee_rate: FeeRate::ZERO, + payee: ScriptBuf::from(vec![0x00]), + }, }, reply_key: keypair.0.clone(), }; @@ -194,7 +196,7 @@ 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 fallback_tx = sender.psbt_ctx.original_psbt.clone().extract_tx_unchecked_fee_rate(); let with_reply_key = WithReplyKey { v1: sender, reply_key: reply_key.0 }; let sender = Sender { state: with_reply_key.clone() }; let test = SessionHistoryTest { From ba772a2a0f9b7afd6cbcee02f056be4365b28cb5 Mon Sep 17 00:00:00 2001 From: DanGould Date: Wed, 9 Jul 2025 12:07:39 -0400 Subject: [PATCH 2/4] Check fees in multiparty process_proposal The distinction beetween 1s1r async payjoin and ns1r is that the sender checks the proposal before the receiver signs in the first round of communication, so the receiver inputs won't yet be finalized. Therefore, in order to share functionality, the receiver input finalized check needs to be triggered by a condition. This does that. --- payjoin/src/core/send/mod.rs | 22 +++++++++++------ payjoin/src/core/send/multiparty/mod.rs | 33 +++++++++++-------------- 2 files changed, 29 insertions(+), 26 deletions(-) diff --git a/payjoin/src/core/send/mod.rs b/payjoin/src/core/send/mod.rs index cab91c500..5daea9ea2 100644 --- a/payjoin/src/core/send/mod.rs +++ b/payjoin/src/core/send/mod.rs @@ -88,7 +88,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 +161,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 +204,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() diff --git a/payjoin/src/core/send/multiparty/mod.rs b/payjoin/src/core/send/multiparty/mod.rs index 48eb8c8ab..cbb612175 100644 --- a/payjoin/src/core/send/multiparty/mod.rs +++ b/payjoin/src/core/send/multiparty/mod.rs @@ -6,7 +6,7 @@ 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; @@ -137,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)), @@ -150,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(), @@ -207,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) { From abf2a63ef5a6e5349d86c2f24d7c659d1a89a53b Mon Sep 17 00:00:00 2001 From: DanGould Date: Mon, 14 Jul 2025 14:24:10 -0400 Subject: [PATCH 3/4] Use crate::Version instead of string The Version type exists for this less-prone-to-error purpose. --- payjoin/src/core/send/mod.rs | 20 +++++++++----------- payjoin/src/core/send/multiparty/mod.rs | 9 +++++---- payjoin/src/core/send/v1.rs | 2 +- payjoin/src/core/send/v2/mod.rs | 9 ++------- 4 files changed, 17 insertions(+), 23 deletions(-) diff --git a/payjoin/src/core/send/mod.rs b/payjoin/src/core/send/mod.rs index 5daea9ea2..68abe5625 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::Version; // See usize casts #[cfg(not(any(target_pointer_width = "32", target_pointer_width = "64")))] @@ -457,10 +458,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"); } @@ -485,7 +486,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, }; @@ -495,9 +495,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}; @@ -799,7 +797,7 @@ mod test { OutputSubstitution::Disabled, None, FeeRate::ZERO, - "2", + Version::Two, ); assert_eq!(url, Url::parse("http://localhost?v=2&disableoutputsubstitution=true")?); @@ -808,7 +806,7 @@ mod test { OutputSubstitution::Enabled, None, FeeRate::ZERO, - "2", + Version::Two, ); assert_eq!(url, Url::parse("http://localhost?v=2")?); Ok(()) @@ -821,7 +819,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(()) @@ -834,7 +832,7 @@ mod test { OutputSubstitution::Enabled, Some(AdditionalFeeContribution { max_amount: Amount::from_sat(1000), vout: 0 }), FeeRate::ZERO, - "2", + Version::Two, ); assert_eq!( url, @@ -856,7 +854,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 cbb612175..85fd02f85 100644 --- a/payjoin/src/core/send/multiparty/mod.rs +++ b/payjoin/src/core/send/multiparty/mod.rs @@ -13,7 +13,7 @@ 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; @@ -105,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(); @@ -231,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; @@ -242,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")?); @@ -252,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 b84aa29a0..7417fc80b 100644 --- a/payjoin/src/core/send/v1.rs +++ b/payjoin/src/core/send/v1.rs @@ -236,7 +236,7 @@ impl Sender { self.psbt_ctx.output_substitution, self.psbt_ctx.fee_contribution, self.psbt_ctx.min_fee_rate, - "1", // payjoin version + Version::One, ); let body = self.psbt_ctx.original_psbt.to_string().as_bytes().to_vec(); ( diff --git a/payjoin/src/core/send/v2/mod.rs b/payjoin/src/core/send/v2/mod.rs index 66269ecfb..ec7aacf2c 100644 --- a/payjoin/src/core/send/v2/mod.rs +++ b/payjoin/src/core/send/v2/mod.rs @@ -353,13 +353,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()) From 8ceccaab611e4a075dada66718b1ecd1d2e4a4ee Mon Sep 17 00:00:00 2001 From: DanGould Date: Tue, 15 Jul 2025 13:08:17 -0400 Subject: [PATCH 4/4] Abstract common v1,v2 SenderBuilder logic Introduce PsbtContextBuilder which has no ouptut_substitution field, allowing parent consumer SenderBuilders to handle output_substitution and the creation of types relevant to their independent state machines. Move common v1 logic used in both v1 and v2 flows to the send module. --- payjoin-ffi/src/send/mod.rs | 14 +- payjoin/src/core/output_substitution.rs | 13 -- payjoin/src/core/send/mod.rs | 235 +++++++++++++++++++++++- payjoin/src/core/send/multiparty/mod.rs | 16 +- payjoin/src/core/send/v1.rs | 214 +++++---------------- payjoin/src/core/send/v2/mod.rs | 179 ++++++++++-------- payjoin/src/core/send/v2/session.rs | 28 +-- 7 files changed, 404 insertions(+), 295 deletions(-) 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 68abe5625..fdc0b45dc 100644 --- a/payjoin/src/core/send/mod.rs +++ b/payjoin/src/core/send/mod.rs @@ -26,7 +26,7 @@ use url::Url; use crate::output_substitution::OutputSubstitution; use crate::psbt::PsbtExt; -use crate::Version; +use crate::{Request, Version, MAX_CONTENT_LENGTH}; // See usize casts #[cfg(not(any(target_pointer_width = "32", target_pointer_width = "64")))] @@ -37,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 { @@ -478,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; diff --git a/payjoin/src/core/send/multiparty/mod.rs b/payjoin/src/core/send/multiparty/mod.rs index 85fd02f85..47b354b32 100644 --- a/payjoin/src/core/send/multiparty/mod.rs +++ b/payjoin/src/core/send/multiparty/mod.rs @@ -18,10 +18,10 @@ 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_ctx.original_psbt, - self.0.v1.psbt_ctx.output_substitution, - self.0.v1.psbt_ctx.fee_contribution, - self.0.v1.psbt_ctx.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,7 +72,7 @@ impl Sender { .map_err(InternalCreateRequestError::V2CreateRequest)?; let v2_post_ctx = V2PostContext { endpoint: self.0.endpoint().clone(), - psbt_ctx: self.0.v1.psbt_ctx.clone(), + psbt_ctx: self.0.state.psbt_ctx.clone(), hpke_ctx: HpkeContext::new(rs, &self.0.reply_key), ohttp_ctx, }; diff --git a/payjoin/src/core/send/v1.rs b/payjoin/src/core/send/v1.rs index 7417fc80b..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, 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,44 +118,14 @@ 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 { - endpoint, - psbt_ctx: PsbtContext { - original_psbt: psbt, - 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)?, }) } } @@ -231,58 +144,13 @@ pub struct Sender { 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.psbt_ctx.output_substitution, - self.psbt_ctx.fee_contribution, - self.psbt_ctx.min_fee_rate, - Version::One, - ); - let body = self.psbt_ctx.original_psbt.to_string().as_bytes().to_vec(); - ( - Request::new_v1(&url, &body), - V1Context { - psbt_context: PsbtContext { - original_psbt: self.psbt_ctx.original_psbt.clone(), - output_substitution: self.psbt_ctx.output_substitution, - fee_contribution: self.psbt_ctx.fee_contribution, - payee: self.psbt_ctx.payee.clone(), - min_fee_rate: self.psbt_ctx.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; diff --git a/payjoin/src/core/send/v2/mod.rs b/payjoin/src/core/send/v2/mod.rs index ec7aacf2c..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.psbt_ctx.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_ctx.original_psbt, - self.v1.psbt_ctx.output_substitution, - self.v1.psbt_ctx.fee_contribution, - self.v1.psbt_ctx.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,8 +287,8 @@ impl Sender { Ok(( request, V2PostContext { - endpoint: self.v1.endpoint.clone(), - psbt_ctx: self.v1.psbt_ctx.clone(), + endpoint: self.endpoint.clone(), + psbt_ctx: self.psbt_ctx.clone(), hpke_ctx: HpkeContext::new(rs, &self.reply_key), ohttp_ctx, }, @@ -305,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 }) @@ -510,22 +539,20 @@ mod test { let endpoint = Url::parse("http://localhost:1234")?; let mut sender = super::Sender { state: super::WithReplyKey { - v1: v1::Sender { - endpoint, - psbt_ctx: PsbtContext { - original_psbt: PARSED_ORIGINAL_PSBT.clone(), - output_substitution: OutputSubstitution::Enabled, - fee_contribution: None, - min_fee_rate: FeeRate::ZERO, - payee: ScriptBuf::from(vec![0x00]), - }, + endpoint, + psbt_ctx: PsbtContext { + original_psbt: PARSED_ORIGINAL_PSBT.clone(), + output_substitution: OutputSubstitution::Enabled, + fee_contribution: None, + min_fee_rate: FeeRate::ZERO, + payee: ScriptBuf::from(vec![0x00]), }, 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"), )); @@ -536,10 +563,10 @@ mod test { fn test_serialize_v2() -> Result<(), BoxError> { let sender = create_sender_context()?; let body = serialize_v2_body( - &sender.v1.psbt_ctx.original_psbt, - sender.v1.psbt_ctx.output_substitution, - sender.v1.psbt_ctx.fee_contribution, - sender.v1.psbt_ctx.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(()) @@ -554,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_ctx.original_psbt); + assert_eq!(context.endpoint, sender.endpoint); + assert_eq!(context.psbt_ctx.original_psbt, sender.psbt_ctx.original_psbt); Ok(()) } @@ -565,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(); @@ -585,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"); @@ -604,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"); @@ -640,30 +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.psbt_ctx.output_substitution, OutputSubstitution::Enabled); - assert_eq!(&req_ctx.v1.psbt_ctx.payee, &address.script_pubkey()); + 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.v1.psbt_ctx.fee_contribution.expect("sender should contribute fees"); + 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.psbt_ctx.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.psbt_ctx.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.psbt_ctx.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.psbt_ctx.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 cf5a4a336..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_ctx.original_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,15 +117,13 @@ 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 { - endpoint: endpoint.clone(), - psbt_ctx: PsbtContext { - original_psbt: PARSED_ORIGINAL_PSBT.clone(), - output_substitution: OutputSubstitution::Enabled, - fee_contribution: None, - min_fee_rate: FeeRate::ZERO, - payee: ScriptBuf::from(vec![0x00]), - }, + endpoint: endpoint.clone(), + psbt_ctx: PsbtContext { + original_psbt: PARSED_ORIGINAL_PSBT.clone(), + output_substitution: OutputSubstitution::Enabled, + fee_contribution: None, + min_fee_rate: FeeRate::ZERO, + payee: ScriptBuf::from(vec![0x00]), }, reply_key: keypair.0.clone(), }; @@ -197,7 +195,11 @@ mod tests { let reply_key = HpkeKeyPair::gen_keypair(); let endpoint = sender.endpoint().clone(); let fallback_tx = sender.psbt_ctx.original_psbt.clone().extract_tx_unchecked_fee_rate(); - let with_reply_key = WithReplyKey { v1: sender, reply_key: reply_key.0 }; + 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)],