From 77b914f05e785f49372d0d39d9cd187311a00f89 Mon Sep 17 00:00:00 2001 From: DanGould Date: Wed, 1 Jan 2025 16:39:16 -0500 Subject: [PATCH 1/4] Move CreateRequestError up to top of file This error is the first one that appears in the state machine. --- payjoin/src/send/error.rs | 238 +++++++++++++++++++------------------- 1 file changed, 119 insertions(+), 119 deletions(-) diff --git a/payjoin/src/send/error.rs b/payjoin/src/send/error.rs index 68c3f420..c73842bc 100644 --- a/payjoin/src/send/error.rs +++ b/payjoin/src/send/error.rs @@ -7,6 +7,125 @@ use bitcoin::{AddressType, Sequence}; #[cfg(feature = "v2")] use crate::uri::url_ext::ParseReceiverPubkeyParamError; +/// Error returned when request could not be created. +/// +/// This error can currently only happen due to programmer mistake. +/// `unwrap()`ing it is thus considered OK in Rust but you may achieve nicer message by displaying +/// it. +#[derive(Debug)] +pub struct CreateRequestError(InternalCreateRequestError); + +#[derive(Debug)] +pub(crate) enum InternalCreateRequestError { + InvalidOriginalInput(crate::psbt::PsbtInputsError), + InconsistentOriginalPsbt(crate::psbt::InconsistentPsbt), + NoInputs, + PayeeValueNotEqual, + NoOutputs, + MultiplePayeeOutputs, + MissingPayeeOutput, + FeeOutputValueLowerThanFeeContribution, + AmbiguousChangeOutput, + ChangeIndexOutOfBounds, + ChangeIndexPointsAtPayee, + Url(url::ParseError), + AddressType(crate::psbt::AddressTypeError), + InputWeight(crate::psbt::InputWeightError), + #[cfg(feature = "v2")] + Hpke(crate::hpke::HpkeError), + #[cfg(feature = "v2")] + OhttpEncapsulation(crate::ohttp::OhttpEncapsulationError), + #[cfg(feature = "v2")] + ParseReceiverPubkey(ParseReceiverPubkeyParamError), + #[cfg(feature = "v2")] + MissingOhttpConfig, + #[cfg(feature = "v2")] + Expired(std::time::SystemTime), +} + +impl fmt::Display for CreateRequestError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + use InternalCreateRequestError::*; + + match &self.0 { + InvalidOriginalInput(e) => write!(f, "an input in the original transaction is invalid: {:#?}", e), + InconsistentOriginalPsbt(e) => write!(f, "the original transaction is inconsistent: {:#?}", e), + NoInputs => write!(f, "the original transaction has no inputs"), + PayeeValueNotEqual => write!(f, "the value in original transaction doesn't equal value requested in the payment link"), + NoOutputs => write!(f, "the original transaction has no outputs"), + MultiplePayeeOutputs => write!(f, "the original transaction has more than one output belonging to the payee"), + MissingPayeeOutput => write!(f, "the output belonging to payee is missing from the original transaction"), + FeeOutputValueLowerThanFeeContribution => write!(f, "the value of fee output is lower than maximum allowed contribution"), + AmbiguousChangeOutput => write!(f, "can not determine which output is change because there's more than two outputs"), + ChangeIndexOutOfBounds => write!(f, "fee output index is points out of bounds"), + ChangeIndexPointsAtPayee => write!(f, "fee output index is points at output belonging to the payee"), + Url(e) => write!(f, "cannot parse url: {:#?}", e), + AddressType(e) => write!(f, "can not determine input address type: {}", e), + InputWeight(e) => write!(f, "can not determine expected input weight: {}", e), + #[cfg(feature = "v2")] + Hpke(e) => write!(f, "v2 error: {}", e), + #[cfg(feature = "v2")] + OhttpEncapsulation(e) => write!(f, "v2 error: {}", e), + #[cfg(feature = "v2")] + ParseReceiverPubkey(e) => write!(f, "cannot parse receiver public key: {}", e), + #[cfg(feature = "v2")] + MissingOhttpConfig => write!(f, "no ohttp configuration with which to make a v2 request available"), + #[cfg(feature = "v2")] + Expired(expiry) => write!(f, "session expired at {:?}", expiry), + } + } +} + +impl std::error::Error for CreateRequestError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + use InternalCreateRequestError::*; + + match &self.0 { + InvalidOriginalInput(error) => Some(error), + InconsistentOriginalPsbt(error) => Some(error), + NoInputs => None, + PayeeValueNotEqual => None, + NoOutputs => None, + MultiplePayeeOutputs => None, + MissingPayeeOutput => None, + FeeOutputValueLowerThanFeeContribution => None, + AmbiguousChangeOutput => None, + ChangeIndexOutOfBounds => None, + ChangeIndexPointsAtPayee => None, + Url(error) => Some(error), + AddressType(error) => Some(error), + InputWeight(error) => Some(error), + #[cfg(feature = "v2")] + Hpke(error) => Some(error), + #[cfg(feature = "v2")] + OhttpEncapsulation(error) => Some(error), + #[cfg(feature = "v2")] + ParseReceiverPubkey(error) => Some(error), + #[cfg(feature = "v2")] + MissingOhttpConfig => None, + #[cfg(feature = "v2")] + Expired(_) => None, + } + } +} + +impl From for CreateRequestError { + fn from(value: InternalCreateRequestError) -> Self { CreateRequestError(value) } +} + +impl From for CreateRequestError { + fn from(value: crate::psbt::AddressTypeError) -> Self { + CreateRequestError(InternalCreateRequestError::AddressType(value)) + } +} + +#[cfg(feature = "v2")] +impl From for CreateRequestError { + fn from(value: ParseReceiverPubkeyParamError) -> Self { + CreateRequestError(InternalCreateRequestError::ParseReceiverPubkey(value)) + } +} + /// Error that may occur when the response from receiver is malformed. /// /// This is currently opaque type because we aren't sure which variants will stay. @@ -174,125 +293,6 @@ impl std::error::Error for ValidationError { } } -/// Error returned when request could not be created. -/// -/// This error can currently only happen due to programmer mistake. -/// `unwrap()`ing it is thus considered OK in Rust but you may achieve nicer message by displaying -/// it. -#[derive(Debug)] -pub struct CreateRequestError(InternalCreateRequestError); - -#[derive(Debug)] -pub(crate) enum InternalCreateRequestError { - InvalidOriginalInput(crate::psbt::PsbtInputsError), - InconsistentOriginalPsbt(crate::psbt::InconsistentPsbt), - NoInputs, - PayeeValueNotEqual, - NoOutputs, - MultiplePayeeOutputs, - MissingPayeeOutput, - FeeOutputValueLowerThanFeeContribution, - AmbiguousChangeOutput, - ChangeIndexOutOfBounds, - ChangeIndexPointsAtPayee, - Url(url::ParseError), - AddressType(crate::psbt::AddressTypeError), - InputWeight(crate::psbt::InputWeightError), - #[cfg(feature = "v2")] - Hpke(crate::hpke::HpkeError), - #[cfg(feature = "v2")] - OhttpEncapsulation(crate::ohttp::OhttpEncapsulationError), - #[cfg(feature = "v2")] - ParseReceiverPubkey(ParseReceiverPubkeyParamError), - #[cfg(feature = "v2")] - MissingOhttpConfig, - #[cfg(feature = "v2")] - Expired(std::time::SystemTime), -} - -impl fmt::Display for CreateRequestError { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - use InternalCreateRequestError::*; - - match &self.0 { - InvalidOriginalInput(e) => write!(f, "an input in the original transaction is invalid: {:#?}", e), - InconsistentOriginalPsbt(e) => write!(f, "the original transaction is inconsistent: {:#?}", e), - NoInputs => write!(f, "the original transaction has no inputs"), - PayeeValueNotEqual => write!(f, "the value in original transaction doesn't equal value requested in the payment link"), - NoOutputs => write!(f, "the original transaction has no outputs"), - MultiplePayeeOutputs => write!(f, "the original transaction has more than one output belonging to the payee"), - MissingPayeeOutput => write!(f, "the output belonging to payee is missing from the original transaction"), - FeeOutputValueLowerThanFeeContribution => write!(f, "the value of fee output is lower than maximum allowed contribution"), - AmbiguousChangeOutput => write!(f, "can not determine which output is change because there's more than two outputs"), - ChangeIndexOutOfBounds => write!(f, "fee output index is points out of bounds"), - ChangeIndexPointsAtPayee => write!(f, "fee output index is points at output belonging to the payee"), - Url(e) => write!(f, "cannot parse url: {:#?}", e), - AddressType(e) => write!(f, "can not determine input address type: {}", e), - InputWeight(e) => write!(f, "can not determine expected input weight: {}", e), - #[cfg(feature = "v2")] - Hpke(e) => write!(f, "v2 error: {}", e), - #[cfg(feature = "v2")] - OhttpEncapsulation(e) => write!(f, "v2 error: {}", e), - #[cfg(feature = "v2")] - ParseReceiverPubkey(e) => write!(f, "cannot parse receiver public key: {}", e), - #[cfg(feature = "v2")] - MissingOhttpConfig => write!(f, "no ohttp configuration with which to make a v2 request available"), - #[cfg(feature = "v2")] - Expired(expiry) => write!(f, "session expired at {:?}", expiry), - } - } -} - -impl std::error::Error for CreateRequestError { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { - use InternalCreateRequestError::*; - - match &self.0 { - InvalidOriginalInput(error) => Some(error), - InconsistentOriginalPsbt(error) => Some(error), - NoInputs => None, - PayeeValueNotEqual => None, - NoOutputs => None, - MultiplePayeeOutputs => None, - MissingPayeeOutput => None, - FeeOutputValueLowerThanFeeContribution => None, - AmbiguousChangeOutput => None, - ChangeIndexOutOfBounds => None, - ChangeIndexPointsAtPayee => None, - Url(error) => Some(error), - AddressType(error) => Some(error), - InputWeight(error) => Some(error), - #[cfg(feature = "v2")] - Hpke(error) => Some(error), - #[cfg(feature = "v2")] - OhttpEncapsulation(error) => Some(error), - #[cfg(feature = "v2")] - ParseReceiverPubkey(error) => Some(error), - #[cfg(feature = "v2")] - MissingOhttpConfig => None, - #[cfg(feature = "v2")] - Expired(_) => None, - } - } -} - -impl From for CreateRequestError { - fn from(value: InternalCreateRequestError) -> Self { CreateRequestError(value) } -} - -impl From for CreateRequestError { - fn from(value: crate::psbt::AddressTypeError) -> Self { - CreateRequestError(InternalCreateRequestError::AddressType(value)) - } -} - -#[cfg(feature = "v2")] -impl From for CreateRequestError { - fn from(value: ParseReceiverPubkeyParamError) -> Self { - CreateRequestError(InternalCreateRequestError::ParseReceiverPubkey(value)) - } -} - /// Represent an error returned by Payjoin receiver. pub enum ResponseError { /// `WellKnown` Errors are defined in the [`BIP78::ReceiverWellKnownError`] spec. From 30c2a077b4e8e6944b3e1b3856ce98fb75e83115 Mon Sep 17 00:00:00 2001 From: DanGould Date: Wed, 1 Jan 2025 17:13:43 -0500 Subject: [PATCH 2/4] Define SenderBuilder's own error SenderBuilder was using vestigial CreateRequestError even though it was not creating any requests. It only produces errors when building Sender. Define a BuildSenderError and Internal counterpart separate from CreateRequestError. Use best practice `new` instead of `from_request_and_uri`, and provide an accurate docstring. --- payjoin-cli/src/app/mod.rs | 3 +- payjoin/src/send/error.rs | 113 ++++++++++++++++++++++------------- payjoin/src/send/mod.rs | 69 +++++++++++---------- payjoin/tests/integration.rs | 18 +++--- 4 files changed, 117 insertions(+), 86 deletions(-) diff --git a/payjoin-cli/src/app/mod.rs b/payjoin-cli/src/app/mod.rs index 91adc160..4b47b43a 100644 --- a/payjoin-cli/src/app/mod.rs +++ b/payjoin-cli/src/app/mod.rs @@ -67,8 +67,7 @@ pub trait App { .psbt; let psbt = Psbt::from_str(&psbt).with_context(|| "Failed to load PSBT from base64")?; log::debug!("Original psbt: {:#?}", psbt); - let req_ctx = payjoin::send::SenderBuilder::from_psbt_and_uri(psbt, uri.clone()) - .with_context(|| "Failed to build payjoin request")? + let req_ctx = payjoin::send::SenderBuilder::new(psbt, uri.clone()) .build_recommended(fee_rate) .with_context(|| "Failed to build payjoin request")?; diff --git a/payjoin/src/send/error.rs b/payjoin/src/send/error.rs index c73842bc..0b633016 100644 --- a/payjoin/src/send/error.rs +++ b/payjoin/src/send/error.rs @@ -7,16 +7,14 @@ use bitcoin::{AddressType, Sequence}; #[cfg(feature = "v2")] use crate::uri::url_ext::ParseReceiverPubkeyParamError; -/// Error returned when request could not be created. +/// Error building a Sender from a SenderBuilder. /// -/// This error can currently only happen due to programmer mistake. -/// `unwrap()`ing it is thus considered OK in Rust but you may achieve nicer message by displaying -/// it. +/// This error is unrecoverable. #[derive(Debug)] -pub struct CreateRequestError(InternalCreateRequestError); +pub struct BuildSenderError(InternalBuildSenderError); #[derive(Debug)] -pub(crate) enum InternalCreateRequestError { +pub(crate) enum InternalBuildSenderError { InvalidOriginalInput(crate::psbt::PsbtInputsError), InconsistentOriginalPsbt(crate::psbt::InconsistentPsbt), NoInputs, @@ -28,24 +26,23 @@ pub(crate) enum InternalCreateRequestError { AmbiguousChangeOutput, ChangeIndexOutOfBounds, ChangeIndexPointsAtPayee, - Url(url::ParseError), - AddressType(crate::psbt::AddressTypeError), InputWeight(crate::psbt::InputWeightError), - #[cfg(feature = "v2")] - Hpke(crate::hpke::HpkeError), - #[cfg(feature = "v2")] - OhttpEncapsulation(crate::ohttp::OhttpEncapsulationError), - #[cfg(feature = "v2")] - ParseReceiverPubkey(ParseReceiverPubkeyParamError), - #[cfg(feature = "v2")] - MissingOhttpConfig, - #[cfg(feature = "v2")] - Expired(std::time::SystemTime), + AddressType(crate::psbt::AddressTypeError), } -impl fmt::Display for CreateRequestError { +impl From for BuildSenderError { + fn from(value: InternalBuildSenderError) -> Self { BuildSenderError(value) } +} + +impl From for BuildSenderError { + fn from(value: crate::psbt::AddressTypeError) -> Self { + BuildSenderError(InternalBuildSenderError::AddressType(value)) + } +} + +impl fmt::Display for BuildSenderError { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - use InternalCreateRequestError::*; + use InternalBuildSenderError::*; match &self.0 { InvalidOriginalInput(e) => write!(f, "an input in the original transaction is invalid: {:#?}", e), @@ -59,26 +56,15 @@ impl fmt::Display for CreateRequestError { AmbiguousChangeOutput => write!(f, "can not determine which output is change because there's more than two outputs"), ChangeIndexOutOfBounds => write!(f, "fee output index is points out of bounds"), ChangeIndexPointsAtPayee => write!(f, "fee output index is points at output belonging to the payee"), - Url(e) => write!(f, "cannot parse url: {:#?}", e), AddressType(e) => write!(f, "can not determine input address type: {}", e), InputWeight(e) => write!(f, "can not determine expected input weight: {}", e), - #[cfg(feature = "v2")] - Hpke(e) => write!(f, "v2 error: {}", e), - #[cfg(feature = "v2")] - OhttpEncapsulation(e) => write!(f, "v2 error: {}", e), - #[cfg(feature = "v2")] - ParseReceiverPubkey(e) => write!(f, "cannot parse receiver public key: {}", e), - #[cfg(feature = "v2")] - MissingOhttpConfig => write!(f, "no ohttp configuration with which to make a v2 request available"), - #[cfg(feature = "v2")] - Expired(expiry) => write!(f, "session expired at {:?}", expiry), } } } -impl std::error::Error for CreateRequestError { +impl std::error::Error for BuildSenderError { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { - use InternalCreateRequestError::*; + use InternalBuildSenderError::*; match &self.0 { InvalidOriginalInput(error) => Some(error), @@ -92,9 +78,62 @@ impl std::error::Error for CreateRequestError { AmbiguousChangeOutput => None, ChangeIndexOutOfBounds => None, ChangeIndexPointsAtPayee => None, - Url(error) => Some(error), AddressType(error) => Some(error), InputWeight(error) => Some(error), + } + } +} + +/// Error returned when request could not be created. +/// +/// This error can currently only happen due to programmer mistake. +/// `unwrap()`ing it is thus considered OK in Rust but you may achieve nicer message by displaying +/// it. +#[derive(Debug)] +pub struct CreateRequestError(InternalCreateRequestError); + +#[derive(Debug)] +pub(crate) enum InternalCreateRequestError { + Url(url::ParseError), + #[cfg(feature = "v2")] + Hpke(crate::hpke::HpkeError), + #[cfg(feature = "v2")] + OhttpEncapsulation(crate::ohttp::OhttpEncapsulationError), + #[cfg(feature = "v2")] + ParseReceiverPubkey(ParseReceiverPubkeyParamError), + #[cfg(feature = "v2")] + MissingOhttpConfig, + #[cfg(feature = "v2")] + Expired(std::time::SystemTime), +} + +impl fmt::Display for CreateRequestError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + use InternalCreateRequestError::*; + + match &self.0 { + Url(e) => write!(f, "cannot parse url: {:#?}", e), + #[cfg(feature = "v2")] + Hpke(e) => write!(f, "v2 error: {}", e), + #[cfg(feature = "v2")] + OhttpEncapsulation(e) => write!(f, "v2 error: {}", e), + #[cfg(feature = "v2")] + ParseReceiverPubkey(e) => write!(f, "cannot parse receiver public key: {}", e), + #[cfg(feature = "v2")] + MissingOhttpConfig => + write!(f, "no ohttp configuration with which to make a v2 request available"), + #[cfg(feature = "v2")] + Expired(expiry) => write!(f, "session expired at {:?}", expiry), + } + } +} + +impl std::error::Error for CreateRequestError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + use InternalCreateRequestError::*; + + match &self.0 { + Url(error) => Some(error), #[cfg(feature = "v2")] Hpke(error) => Some(error), #[cfg(feature = "v2")] @@ -113,12 +152,6 @@ impl From for CreateRequestError { fn from(value: InternalCreateRequestError) -> Self { CreateRequestError(value) } } -impl From for CreateRequestError { - fn from(value: crate::psbt::AddressTypeError) -> Self { - CreateRequestError(InternalCreateRequestError::AddressType(value)) - } -} - #[cfg(feature = "v2")] impl From for CreateRequestError { fn from(value: ParseReceiverPubkeyParamError) -> Self { diff --git a/payjoin/src/send/mod.rs b/payjoin/src/send/mod.rs index 339c5755..833e7a14 100644 --- a/payjoin/src/send/mod.rs +++ b/payjoin/src/send/mod.rs @@ -27,8 +27,10 @@ use std::str::FromStr; use bitcoin::hashes::{sha256, Hash}; use bitcoin::psbt::Psbt; use bitcoin::{Amount, FeeRate, Script, ScriptBuf, TxOut, Weight}; -pub use error::{CreateRequestError, ResponseError, ValidationError}; -pub(crate) use error::{InternalCreateRequestError, InternalValidationError}; +pub use error::{BuildSenderError, CreateRequestError, ResponseError, ValidationError}; +pub(crate) use error::{ + InternalBuildSenderError, InternalCreateRequestError, InternalValidationError, +}; #[cfg(feature = "v2")] use serde::{Deserialize, Serialize}; use url::Url; @@ -69,13 +71,12 @@ pub struct SenderBuilder<'a> { } impl<'a> SenderBuilder<'a> { - /// Prepare an HTTP request and request context to process the response + /// Prepare the context from which to make Sender requests /// - /// An HTTP client will own the Request data while Context sticks around so - /// a `(Request, Context)` tuple is returned from [`SenderBuilder::build_recommended()`] - /// (or other `build` methods) to keep them separated. - pub fn from_psbt_and_uri(psbt: Psbt, uri: PjUri<'a>) -> Result { - Ok(Self { + /// Call [`SenderBuilder::build_recommended()`] or other `build` methods + /// to create a [`Sender`] + pub fn new(psbt: Psbt, uri: PjUri<'a>) -> Self { + Self { psbt, uri, // Sender's optional parameters @@ -83,7 +84,7 @@ impl<'a> SenderBuilder<'a> { fee_contribution: None, clamp_fee_contribution: false, min_fee_rate: FeeRate::ZERO, - }) + } } /// Disable output substitution even if the receiver didn't. @@ -103,7 +104,7 @@ impl<'a> SenderBuilder<'a> { // 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) -> Result { + 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()); @@ -126,11 +127,10 @@ impl<'a> SenderBuilder<'a> { .map(|(i, txo)| (i, txo.value)) { let mut input_pairs = self.psbt.input_pairs(); - let first_input_pair = - input_pairs.next().ok_or(InternalCreateRequestError::NoInputs)?; + let first_input_pair = input_pairs.next().ok_or(InternalBuildSenderError::NoInputs)?; let mut input_weight = first_input_pair .expected_input_weight() - .map_err(InternalCreateRequestError::InputWeight)?; + .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()? { @@ -181,7 +181,7 @@ impl<'a> SenderBuilder<'a> { change_index: Option, min_fee_rate: FeeRate, clamp_fee_contribution: bool, - ) -> Result { + ) -> Result { self.fee_contribution = Some((max_fee_contribution, change_index)); self.clamp_fee_contribution = clamp_fee_contribution; self.min_fee_rate = min_fee_rate; @@ -195,7 +195,7 @@ impl<'a> SenderBuilder<'a> { pub fn build_non_incentivizing( mut self, min_fee_rate: FeeRate, - ) -> Result { + ) -> Result { // since this is a builder, these should already be cleared // but we'll reset them to be sure self.fee_contribution = None; @@ -204,11 +204,10 @@ impl<'a> SenderBuilder<'a> { self.build() } - fn build(self) -> Result { + fn build(self) -> Result { let mut psbt = - self.psbt.validate().map_err(InternalCreateRequestError::InconsistentOriginalPsbt)?; - psbt.validate_input_utxos(true) - .map_err(InternalCreateRequestError::InvalidOriginalInput)?; + self.psbt.validate().map_err(InternalBuildSenderError::InconsistentOriginalPsbt)?; + psbt.validate_input_utxos(true).map_err(InternalBuildSenderError::InvalidOriginalInput)?; let endpoint = self.uri.extras.endpoint.clone(); let disable_output_substitution = self.uri.extras.disable_output_substitution || self.disable_output_substitution; @@ -736,17 +735,17 @@ fn check_single_payee( psbt: &Psbt, script_pubkey: &Script, amount: Option, -) -> Result<(), InternalCreateRequestError> { +) -> Result<(), InternalBuildSenderError> { let mut payee_found = false; for output in &psbt.unsigned_tx.output { if output.script_pubkey == *script_pubkey { if let Some(amount) = amount { if output.value != amount { - return Err(InternalCreateRequestError::PayeeValueNotEqual); + return Err(InternalBuildSenderError::PayeeValueNotEqual); } } if payee_found { - return Err(InternalCreateRequestError::MultiplePayeeOutputs); + return Err(InternalBuildSenderError::MultiplePayeeOutputs); } payee_found = true; } @@ -754,7 +753,7 @@ fn check_single_payee( if payee_found { Ok(()) } else { - Err(InternalCreateRequestError::MissingPayeeOutput) + Err(InternalBuildSenderError::MissingPayeeOutput) } } @@ -786,12 +785,12 @@ fn check_fee_output_amount( output: &TxOut, fee: bitcoin::Amount, clamp_fee_contribution: bool, -) -> Result { +) -> Result { if output.value < fee { if clamp_fee_contribution { Ok(output.value) } else { - Err(InternalCreateRequestError::FeeOutputValueLowerThanFeeContribution) + Err(InternalBuildSenderError::FeeOutputValueLowerThanFeeContribution) } } else { Ok(fee) @@ -804,15 +803,15 @@ fn find_change_index( payee: &Script, fee: bitcoin::Amount, clamp_fee_contribution: bool, -) -> Result, InternalCreateRequestError> { +) -> Result, InternalBuildSenderError> { match (psbt.unsigned_tx.output.len(), clamp_fee_contribution) { - (0, _) => return Err(InternalCreateRequestError::NoOutputs), + (0, _) => return Err(InternalBuildSenderError::NoOutputs), (1, false) if psbt.unsigned_tx.output[0].script_pubkey == *payee => - return Err(InternalCreateRequestError::FeeOutputValueLowerThanFeeContribution), + return Err(InternalBuildSenderError::FeeOutputValueLowerThanFeeContribution), (1, true) if psbt.unsigned_tx.output[0].script_pubkey == *payee => return Ok(None), - (1, _) => return Err(InternalCreateRequestError::MissingPayeeOutput), + (1, _) => return Err(InternalBuildSenderError::MissingPayeeOutput), (2, _) => (), - _ => return Err(InternalCreateRequestError::AmbiguousChangeOutput), + _ => return Err(InternalBuildSenderError::AmbiguousChangeOutput), } let (index, output) = psbt .unsigned_tx @@ -820,7 +819,7 @@ fn find_change_index( .iter() .enumerate() .find(|(_, output)| output.script_pubkey != *payee) - .ok_or(InternalCreateRequestError::MultiplePayeeOutputs)?; + .ok_or(InternalBuildSenderError::MultiplePayeeOutputs)?; Ok(Some((check_fee_output_amount(output, fee, clamp_fee_contribution)?, index))) } @@ -833,14 +832,14 @@ fn check_change_index( fee: bitcoin::Amount, index: usize, clamp_fee_contribution: bool, -) -> Result<(bitcoin::Amount, usize), InternalCreateRequestError> { +) -> Result<(bitcoin::Amount, usize), InternalBuildSenderError> { let output = psbt .unsigned_tx .output .get(index) - .ok_or(InternalCreateRequestError::ChangeIndexOutOfBounds)?; + .ok_or(InternalBuildSenderError::ChangeIndexOutOfBounds)?; if output.script_pubkey == *payee { - return Err(InternalCreateRequestError::ChangeIndexPointsAtPayee); + return Err(InternalBuildSenderError::ChangeIndexPointsAtPayee); } Ok((check_fee_output_amount(output, fee, clamp_fee_contribution)?, index)) } @@ -850,7 +849,7 @@ fn determine_fee_contribution( payee: &Script, fee_contribution: Option<(bitcoin::Amount, Option)>, clamp_fee_contribution: bool, -) -> Result, InternalCreateRequestError> { +) -> Result, InternalBuildSenderError> { Ok(match fee_contribution { Some((fee, None)) => find_change_index(psbt, payee, fee, clamp_fee_contribution)?, Some((fee, Some(index))) => diff --git a/payjoin/tests/integration.rs b/payjoin/tests/integration.rs index b3c6f447..11687f2f 100644 --- a/payjoin/tests/integration.rs +++ b/payjoin/tests/integration.rs @@ -93,7 +93,7 @@ mod integration { .unwrap(); let psbt = build_original_psbt(&sender, &uri)?; debug!("Original psbt: {:#?}", psbt); - let (req, ctx) = SenderBuilder::from_psbt_and_uri(psbt, uri)? + let (req, ctx) = SenderBuilder::new(psbt, uri) .build_with_additional_fee(Amount::from_sat(10000), None, FeeRate::ZERO, false)? .extract_v1()?; let headers = HeaderMock::new(&req.body, req.content_type); @@ -158,7 +158,7 @@ mod integration { .unwrap(); let psbt = build_original_psbt(&sender, &uri)?; debug!("Original psbt: {:#?}", psbt); - let (req, _ctx) = SenderBuilder::from_psbt_and_uri(psbt, uri)? + let (req, _ctx) = SenderBuilder::new(psbt, uri) .build_with_additional_fee(Amount::from_sat(10000), None, FeeRate::ZERO, false)? .extract_v1()?; let headers = HeaderMock::new(&req.body, req.content_type); @@ -305,7 +305,7 @@ mod integration { Some(std::time::SystemTime::now()), ) .build(); - let expired_req_ctx = SenderBuilder::from_psbt_and_uri(psbt, expired_pj_uri)? + let expired_req_ctx = SenderBuilder::new(psbt, expired_pj_uri) .build_non_incentivizing(FeeRate::BROADCAST_MIN)?; match expired_req_ctx.extract_v2(directory.to_owned()) { // Internal error types are private, so check against a string @@ -387,7 +387,7 @@ mod integration { .check_pj_supported() .unwrap(); let psbt = build_sweep_psbt(&sender, &pj_uri)?; - let req_ctx = SenderBuilder::from_psbt_and_uri(psbt.clone(), pj_uri.clone())? + let req_ctx = SenderBuilder::new(psbt.clone(), pj_uri.clone()) .build_recommended(FeeRate::BROADCAST_MIN)?; let (Request { url, body, content_type, .. }, send_ctx) = req_ctx.extract_v2(directory.to_owned())?; @@ -557,7 +557,7 @@ mod integration { .check_pj_supported() .unwrap(); let psbt = build_sweep_psbt(&sender, &pj_uri)?; - let req_ctx = SenderBuilder::from_psbt_and_uri(psbt.clone(), pj_uri.clone())? + let req_ctx = SenderBuilder::new(psbt.clone(), pj_uri.clone()) .build_recommended(FeeRate::BROADCAST_MIN)?; let (Request { url, body, content_type, .. }, post_ctx) = req_ctx.extract_v2(directory.to_owned())?; @@ -651,7 +651,7 @@ mod integration { .check_pj_supported() .unwrap(); let psbt = build_original_psbt(&sender, &pj_uri)?; - let req_ctx = SenderBuilder::from_psbt_and_uri(psbt.clone(), pj_uri.clone())? + let req_ctx = SenderBuilder::new(psbt.clone(), pj_uri.clone()) .build_recommended(FeeRate::BROADCAST_MIN)?; let (req, ctx) = req_ctx.extract_v1()?; let headers = HeaderMock::new(&req.body, req.content_type); @@ -736,7 +736,7 @@ mod integration { .unwrap(); let psbt = build_original_psbt(&sender, &pj_uri)?; let (Request { url, body, content_type, .. }, send_ctx) = - SenderBuilder::from_psbt_and_uri(psbt, pj_uri)? + SenderBuilder::new(psbt, pj_uri) .build_with_additional_fee( Amount::from_sat(10000), None, @@ -1047,7 +1047,7 @@ mod integration { let psbt = build_original_psbt(&sender, &uri)?; log::debug!("Original psbt: {:#?}", psbt); let max_additional_fee = Amount::from_sat(1000); - let (req, ctx) = SenderBuilder::from_psbt_and_uri(psbt.clone(), uri)? + let (req, ctx) = SenderBuilder::new(psbt.clone(), uri) .build_with_additional_fee(max_additional_fee, None, FeeRate::ZERO, false)? .extract_v1()?; let headers = HeaderMock::new(&req.body, req.content_type); @@ -1124,7 +1124,7 @@ mod integration { .unwrap(); let psbt = build_original_psbt(&sender, &uri)?; log::debug!("Original psbt: {:#?}", psbt); - let (req, ctx) = SenderBuilder::from_psbt_and_uri(psbt.clone(), uri)? + let (req, ctx) = SenderBuilder::new(psbt.clone(), uri) .build_with_additional_fee(Amount::from_sat(10000), None, FeeRate::ZERO, false)? .extract_v1()?; let headers = HeaderMock::new(&req.body, req.content_type); From d15d6952e2c8930e44448086b9c2796e57f61305 Mon Sep 17 00:00:00 2001 From: DanGould Date: Wed, 1 Jan 2025 17:55:26 -0500 Subject: [PATCH 3/4] Make CreateRequestError v2-only Its variants only apply to extracting v2 requests. --- payjoin/src/send/error.rs | 20 +++++--------------- payjoin/src/send/mod.rs | 15 ++++++++------- 2 files changed, 13 insertions(+), 22 deletions(-) diff --git a/payjoin/src/send/error.rs b/payjoin/src/send/error.rs index 0b633016..4f9ca6a3 100644 --- a/payjoin/src/send/error.rs +++ b/payjoin/src/send/error.rs @@ -90,64 +90,54 @@ impl std::error::Error for BuildSenderError { /// `unwrap()`ing it is thus considered OK in Rust but you may achieve nicer message by displaying /// it. #[derive(Debug)] +#[cfg(feature = "v2")] pub struct CreateRequestError(InternalCreateRequestError); #[derive(Debug)] +#[cfg(feature = "v2")] pub(crate) enum InternalCreateRequestError { Url(url::ParseError), - #[cfg(feature = "v2")] Hpke(crate::hpke::HpkeError), - #[cfg(feature = "v2")] OhttpEncapsulation(crate::ohttp::OhttpEncapsulationError), - #[cfg(feature = "v2")] ParseReceiverPubkey(ParseReceiverPubkeyParamError), - #[cfg(feature = "v2")] MissingOhttpConfig, - #[cfg(feature = "v2")] Expired(std::time::SystemTime), } +#[cfg(feature = "v2")] impl fmt::Display for CreateRequestError { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { use InternalCreateRequestError::*; match &self.0 { Url(e) => write!(f, "cannot parse url: {:#?}", e), - #[cfg(feature = "v2")] Hpke(e) => write!(f, "v2 error: {}", e), - #[cfg(feature = "v2")] OhttpEncapsulation(e) => write!(f, "v2 error: {}", e), - #[cfg(feature = "v2")] ParseReceiverPubkey(e) => write!(f, "cannot parse receiver public key: {}", e), - #[cfg(feature = "v2")] MissingOhttpConfig => write!(f, "no ohttp configuration with which to make a v2 request available"), - #[cfg(feature = "v2")] Expired(expiry) => write!(f, "session expired at {:?}", expiry), } } } +#[cfg(feature = "v2")] impl std::error::Error for CreateRequestError { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { use InternalCreateRequestError::*; match &self.0 { Url(error) => Some(error), - #[cfg(feature = "v2")] Hpke(error) => Some(error), - #[cfg(feature = "v2")] OhttpEncapsulation(error) => Some(error), - #[cfg(feature = "v2")] ParseReceiverPubkey(error) => Some(error), - #[cfg(feature = "v2")] MissingOhttpConfig => None, - #[cfg(feature = "v2")] Expired(_) => None, } } } +#[cfg(feature = "v2")] impl From for CreateRequestError { fn from(value: InternalCreateRequestError) -> Self { CreateRequestError(value) } } diff --git a/payjoin/src/send/mod.rs b/payjoin/src/send/mod.rs index 833e7a14..98d6d34a 100644 --- a/payjoin/src/send/mod.rs +++ b/payjoin/src/send/mod.rs @@ -27,10 +27,12 @@ use std::str::FromStr; use bitcoin::hashes::{sha256, Hash}; use bitcoin::psbt::Psbt; use bitcoin::{Amount, FeeRate, Script, ScriptBuf, TxOut, Weight}; -pub use error::{BuildSenderError, CreateRequestError, ResponseError, ValidationError}; -pub(crate) use error::{ - InternalBuildSenderError, InternalCreateRequestError, InternalValidationError, -}; +#[cfg(feature = "v2")] +pub use error::CreateRequestError; +#[cfg(feature = "v2")] +pub(crate) use error::InternalCreateRequestError; +pub use error::{BuildSenderError, ResponseError, ValidationError}; +pub(crate) use error::{InternalBuildSenderError, InternalValidationError}; #[cfg(feature = "v2")] use serde::{Deserialize, Serialize}; use url::Url; @@ -255,15 +257,14 @@ pub struct Sender { impl Sender { /// Extract serialized V1 Request and Context from a Payjoin Proposal - pub fn extract_v1(&self) -> Result<(Request, V1Context), CreateRequestError> { + pub fn extract_v1(&self) -> Result<(Request, V1Context), url::ParseError> { let url = serialize_url( self.endpoint.clone(), self.disable_output_substitution, self.fee_contribution, self.min_fee_rate, "1", // payjoin version - ) - .map_err(InternalCreateRequestError::Url)?; + )?; let body = self.psbt.to_string().as_bytes().to_vec(); Ok(( Request::new_v1(url, body), From 8480c3c62fc47aa3b666cd71b7ea6a8f1e997a8b Mon Sep 17 00:00:00 2001 From: DanGould Date: Wed, 1 Jan 2025 23:13:15 -0500 Subject: [PATCH 4/4] Separate send::{v1,v2} modules This reduces the number of feature gates and works toward a crate where the features are additive and potentially both available at the same time. Right now they're still mutually exclusive (v1 is enabled by not(v2)) --- payjoin-cli/src/app/mod.rs | 9 +- payjoin-cli/src/app/v1.rs | 9 +- payjoin-cli/src/app/v2.rs | 11 +- payjoin-cli/src/db/v2.rs | 2 +- payjoin/src/send/error.rs | 68 ----- payjoin/src/send/mod.rs | 491 +---------------------------------- payjoin/src/send/v1.rs | 255 ++++++++++++++++++ payjoin/src/send/v2/error.rs | 62 +++++ payjoin/src/send/v2/mod.rs | 346 ++++++++++++++++++++++++ payjoin/tests/integration.rs | 4 +- 10 files changed, 696 insertions(+), 561 deletions(-) create mode 100644 payjoin/src/send/v1.rs create mode 100644 payjoin/src/send/v2/error.rs create mode 100644 payjoin/src/send/v2/mod.rs diff --git a/payjoin-cli/src/app/mod.rs b/payjoin-cli/src/app/mod.rs index 4b47b43a..c62df878 100644 --- a/payjoin-cli/src/app/mod.rs +++ b/payjoin-cli/src/app/mod.rs @@ -8,7 +8,6 @@ use bitcoincore_rpc::bitcoin::Amount; use bitcoincore_rpc::RpcApi; use payjoin::bitcoin::psbt::Psbt; use payjoin::receive::InputPair; -use payjoin::send::Sender; use payjoin::{bitcoin, PjUri}; pub mod config; @@ -31,7 +30,7 @@ pub trait App { async fn send_payjoin(&self, bip21: &str, fee_rate: &f32) -> Result<()>; async fn receive_payjoin(self, amount_arg: &str) -> Result<()>; - fn create_pj_request(&self, uri: &PjUri, fee_rate: &f32) -> Result { + fn create_original_psbt(&self, uri: &PjUri, fee_rate: &f32) -> Result { let amount = uri.amount.ok_or_else(|| anyhow!("please specify the amount in the Uri"))?; // wallet_create_funded_psbt requires a HashMap @@ -67,11 +66,7 @@ pub trait App { .psbt; let psbt = Psbt::from_str(&psbt).with_context(|| "Failed to load PSBT from base64")?; log::debug!("Original psbt: {:#?}", psbt); - let req_ctx = payjoin::send::SenderBuilder::new(psbt, uri.clone()) - .build_recommended(fee_rate) - .with_context(|| "Failed to build payjoin request")?; - - Ok(req_ctx) + Ok(psbt) } fn process_pj_response(&self, psbt: Psbt) -> Result { diff --git a/payjoin-cli/src/app/v1.rs b/payjoin-cli/src/app/v1.rs index 48a48baa..5636ff38 100644 --- a/payjoin-cli/src/app/v1.rs +++ b/payjoin-cli/src/app/v1.rs @@ -16,6 +16,7 @@ use hyper_util::rt::TokioIo; use payjoin::bitcoin::psbt::Psbt; use payjoin::bitcoin::{self, FeeRate}; use payjoin::receive::{PayjoinProposal, UncheckedProposal}; +use payjoin::send::v1::SenderBuilder; use payjoin::{Error, PjUriBuilder, Uri, UriExt}; use tokio::net::TcpListener; @@ -72,7 +73,13 @@ impl AppTrait for App { Uri::try_from(bip21).map_err(|e| anyhow!("Failed to create URI from BIP21: {}", e))?; let uri = uri.assume_checked(); let uri = uri.check_pj_supported().map_err(|_| anyhow!("URI does not support Payjoin"))?; - let (req, ctx) = self.create_pj_request(&uri, fee_rate)?.extract_v1()?; + let psbt = self.create_original_psbt(&uri, fee_rate)?; + let fee_rate_sat_per_kwu = fee_rate * 250.0_f32; + let fee_rate = FeeRate::from_sat_per_kwu(fee_rate_sat_per_kwu.ceil() as u64); + let (req, ctx) = SenderBuilder::new(psbt, uri.clone()) + .build_recommended(fee_rate) + .with_context(|| "Failed to build payjoin request")? + .extract_v1()?; let http = http_agent()?; let body = String::from_utf8(req.body.clone()).unwrap(); println!("Sending fallback request to {}", &req.url); diff --git a/payjoin-cli/src/app/v2.rs b/payjoin-cli/src/app/v2.rs index 55988ae4..8e004952 100644 --- a/payjoin-cli/src/app/v2.rs +++ b/payjoin-cli/src/app/v2.rs @@ -7,7 +7,7 @@ use payjoin::bitcoin::consensus::encode::serialize_hex; use payjoin::bitcoin::psbt::Psbt; use payjoin::bitcoin::{Amount, FeeRate}; use payjoin::receive::v2::Receiver; -use payjoin::send::Sender; +use payjoin::send::v2::{Sender, SenderBuilder}; use payjoin::{bitcoin, Error, Uri}; use tokio::signal; use tokio::sync::watch; @@ -65,7 +65,12 @@ impl AppTrait for App { let req_ctx = match self.db.get_send_session(url)? { Some(send_session) => send_session, None => { - let mut req_ctx = self.create_pj_request(&uri, fee_rate)?; + let psbt = self.create_original_psbt(&uri, fee_rate)?; + let fee_rate_sat_per_kwu = fee_rate * 250.0_f32; + let fee_rate = FeeRate::from_sat_per_kwu(fee_rate_sat_per_kwu.ceil() as u64); + let mut req_ctx = SenderBuilder::new(psbt, uri.clone()) + .build_recommended(fee_rate) + .with_context(|| "Failed to build payjoin request")?; self.db.insert_send_session(&mut req_ctx, url)?; req_ctx } @@ -192,7 +197,7 @@ impl App { Ok(()) } - async fn long_poll_post(&self, req_ctx: &mut payjoin::send::Sender) -> Result { + async fn long_poll_post(&self, req_ctx: &mut Sender) -> Result { match req_ctx.extract_v2(self.config.ohttp_relay.clone()) { Ok((req, ctx)) => { println!("Posting Original PSBT Payload request..."); diff --git a/payjoin-cli/src/db/v2.rs b/payjoin-cli/src/db/v2.rs index a2168647..136c8894 100644 --- a/payjoin-cli/src/db/v2.rs +++ b/payjoin-cli/src/db/v2.rs @@ -1,6 +1,6 @@ use bitcoincore_rpc::jsonrpc::serde_json; use payjoin::receive::v2::Receiver; -use payjoin::send::Sender; +use payjoin::send::v2::Sender; use sled::{IVec, Tree}; use url::Url; diff --git a/payjoin/src/send/error.rs b/payjoin/src/send/error.rs index 4f9ca6a3..89d087be 100644 --- a/payjoin/src/send/error.rs +++ b/payjoin/src/send/error.rs @@ -4,9 +4,6 @@ use bitcoin::locktime::absolute::LockTime; use bitcoin::transaction::Version; use bitcoin::{AddressType, Sequence}; -#[cfg(feature = "v2")] -use crate::uri::url_ext::ParseReceiverPubkeyParamError; - /// Error building a Sender from a SenderBuilder. /// /// This error is unrecoverable. @@ -84,71 +81,6 @@ impl std::error::Error for BuildSenderError { } } -/// Error returned when request could not be created. -/// -/// This error can currently only happen due to programmer mistake. -/// `unwrap()`ing it is thus considered OK in Rust but you may achieve nicer message by displaying -/// it. -#[derive(Debug)] -#[cfg(feature = "v2")] -pub struct CreateRequestError(InternalCreateRequestError); - -#[derive(Debug)] -#[cfg(feature = "v2")] -pub(crate) enum InternalCreateRequestError { - Url(url::ParseError), - Hpke(crate::hpke::HpkeError), - OhttpEncapsulation(crate::ohttp::OhttpEncapsulationError), - ParseReceiverPubkey(ParseReceiverPubkeyParamError), - MissingOhttpConfig, - Expired(std::time::SystemTime), -} - -#[cfg(feature = "v2")] -impl fmt::Display for CreateRequestError { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - use InternalCreateRequestError::*; - - match &self.0 { - Url(e) => write!(f, "cannot parse url: {:#?}", e), - Hpke(e) => write!(f, "v2 error: {}", e), - OhttpEncapsulation(e) => write!(f, "v2 error: {}", e), - ParseReceiverPubkey(e) => write!(f, "cannot parse receiver public key: {}", e), - MissingOhttpConfig => - write!(f, "no ohttp configuration with which to make a v2 request available"), - Expired(expiry) => write!(f, "session expired at {:?}", expiry), - } - } -} - -#[cfg(feature = "v2")] -impl std::error::Error for CreateRequestError { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { - use InternalCreateRequestError::*; - - match &self.0 { - Url(error) => Some(error), - Hpke(error) => Some(error), - OhttpEncapsulation(error) => Some(error), - ParseReceiverPubkey(error) => Some(error), - MissingOhttpConfig => None, - Expired(_) => None, - } - } -} - -#[cfg(feature = "v2")] -impl From for CreateRequestError { - fn from(value: InternalCreateRequestError) -> Self { CreateRequestError(value) } -} - -#[cfg(feature = "v2")] -impl From for CreateRequestError { - fn from(value: ParseReceiverPubkeyParamError) -> Self { - CreateRequestError(InternalCreateRequestError::ParseReceiverPubkey(value)) - } -} - /// Error that may occur when the response from receiver is malformed. /// /// This is currently opaque type because we aren't sure which variants will stay. diff --git a/payjoin/src/send/mod.rs b/payjoin/src/send/mod.rs index 98d6d34a..cf7f8df2 100644 --- a/payjoin/src/send/mod.rs +++ b/payjoin/src/send/mod.rs @@ -1,353 +1,35 @@ -//! Send a Payjoin +//! Send Payjoin //! -//! This module contains types and methods used to implement sending via [BIP 78 -//! Payjoin](https://github.com/bitcoin/bips/blob/master/bip-0078.mediawiki). +//! This module contains types and methods used to implement sending via Payjoin. //! -//! Usage is pretty simple: -//! -//! 1. Parse BIP21 as [`payjoin::Uri`](crate::Uri) -//! 2. Construct URI request parameters, a finalized “Original PSBT” paying .amount to .address -//! 3. (optional) Spawn a thread or async task that will broadcast the original PSBT fallback after -//! delay (e.g. 1 minute) unless canceled -//! 4. Construct the [`Sender`] using [`SenderBuilder`] with the PSBT and payjoin uri -//! 5. Send the request(s) and receive response(s) by following on the extracted Context -//! 6. Sign and finalize the Payjoin Proposal PSBT -//! 7. Broadcast the Payjoin Transaction (and cancel the optional fallback broadcast) -//! -//! This crate is runtime-agnostic. Data persistence, chain interactions, and networking may be -//! provided by custom implementations or copy the reference -//! [`payjoin-cli`](https://github.com/payjoin/rust-payjoin/tree/master/payjoin-cli) for bitcoind, -//! [`nolooking`](https://github.com/chaincase-app/nolooking) for LND, or -//! [`bitmask-core`](https://github.com/diba-io/bitmask-core) BDK integration. Bring your own -//! wallet and http client. +//! For most use cases, it is recommended to start with the [`v2`] module, as it is +//! backwards compatible and provides the latest features. If you specifically need to use +//! version 1, refer to the [`v1`] module documentation. use std::str::FromStr; -#[cfg(feature = "v2")] -use bitcoin::hashes::{sha256, Hash}; use bitcoin::psbt::Psbt; use bitcoin::{Amount, FeeRate, Script, ScriptBuf, TxOut, Weight}; -#[cfg(feature = "v2")] -pub use error::CreateRequestError; -#[cfg(feature = "v2")] -pub(crate) use error::InternalCreateRequestError; pub use error::{BuildSenderError, ResponseError, ValidationError}; pub(crate) use error::{InternalBuildSenderError, InternalValidationError}; -#[cfg(feature = "v2")] -use serde::{Deserialize, Serialize}; use url::Url; -#[cfg(feature = "v2")] -use crate::hpke::{ - decrypt_message_b, encrypt_message_a, HpkeKeyPair, HpkePublicKey, HpkeSecretKey, -}; -#[cfg(feature = "v2")] -use crate::ohttp::{ohttp_decapsulate, ohttp_encapsulate}; use crate::psbt::PsbtExt; -use crate::request::Request; -#[cfg(feature = "v2")] -use crate::uri::ShortId; -use crate::PjUri; // See usize casts #[cfg(not(any(target_pointer_width = "32", target_pointer_width = "64")))] compile_error!("This crate currently only supports 32 bit and 64 bit architectures"); mod error; +pub mod v1; +#[cfg(feature = "v2")] +pub mod v2; type InternalResult = Result; -#[derive(Clone)] -pub struct SenderBuilder<'a> { - psbt: Psbt, - uri: PjUri<'a>, - disable_output_substitution: bool, - 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. - clamp_fee_contribution: bool, - min_fee_rate: FeeRate, -} - -impl<'a> SenderBuilder<'a> { - /// 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 { - psbt, - uri, - // Sender's optional parameters - disable_output_substitution: false, - fee_contribution: None, - clamp_fee_contribution: false, - min_fee_rate: FeeRate::ZERO, - } - } - - /// Disable output substitution even if the receiver didn't. - /// - /// This forbids receiver switching output or decreasing amount. - /// 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, disable: bool) -> Self { - self.disable_output_substitution = disable; - self - } - - // 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) -> 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() - // Lengths of txid, index and sequence: (32, 4, 4). - + Weight::from_non_witness_data_size(32 + 4 + 4); - 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) - } - - /// 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, - ) -> 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() - } - - /// 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, - ) -> 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(true).map_err(InternalBuildSenderError::InvalidOriginalInput)?; - let endpoint = self.uri.extras.endpoint.clone(); - let disable_output_substitution = - self.uri.extras.disable_output_substitution || self.disable_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, - disable_output_substitution, - fee_contribution, - payee, - min_fee_rate: self.min_fee_rate, - #[cfg(feature = "v2")] - reply_key: HpkeKeyPair::gen_keypair().0, - }) - } -} - -#[derive(Clone, PartialEq, Eq)] -#[cfg_attr(feature = "v2", derive(Serialize, Deserialize))] -pub struct Sender { - /// The original PSBT. - psbt: Psbt, - /// The payjoin directory subdirectory to send the request to. - endpoint: Url, - /// Disallow reciever to substitute original outputs. - disable_output_substitution: bool, - /// (maxadditionalfeecontribution, additionalfeeoutputindex) - fee_contribution: Option<(bitcoin::Amount, usize)>, - min_fee_rate: FeeRate, - /// Script of the person being paid - payee: ScriptBuf, - #[cfg(feature = "v2")] - reply_key: HpkeSecretKey, -} - -impl Sender { - /// Extract serialized V1 Request and Context from a Payjoin Proposal - pub fn extract_v1(&self) -> Result<(Request, V1Context), url::ParseError> { - let url = serialize_url( - self.endpoint.clone(), - self.disable_output_substitution, - self.fee_contribution, - self.min_fee_rate, - "1", // payjoin version - )?; - let body = self.psbt.to_string().as_bytes().to_vec(); - Ok(( - Request::new_v1(url, body), - V1Context { - psbt_context: PsbtContext { - original_psbt: self.psbt.clone(), - disable_output_substitution: self.disable_output_substitution, - fee_contribution: self.fee_contribution, - payee: self.payee.clone(), - min_fee_rate: self.min_fee_rate, - allow_mixed_input_scripts: false, - }, - }, - )) - } - - /// Extract serialized Request and Context from a Payjoin Proposal. - /// - /// This method requires the `rs` pubkey to be extracted from the endpoint - /// and has no fallback to v1. - #[cfg(feature = "v2")] - pub fn extract_v2( - &self, - ohttp_relay: Url, - ) -> Result<(Request, V2PostContext), CreateRequestError> { - use crate::uri::UrlExt; - if let Ok(expiry) = self.endpoint.exp() { - if std::time::SystemTime::now() > expiry { - return Err(InternalCreateRequestError::Expired(expiry).into()); - } - } - let rs = self.extract_rs_pubkey()?; - let url = self.endpoint.clone(); - let body = serialize_v2_body( - &self.psbt, - self.disable_output_substitution, - self.fee_contribution, - self.min_fee_rate, - )?; - let hpke_ctx = HpkeContext::new(rs, &self.reply_key); - let body = encrypt_message_a( - body, - &hpke_ctx.reply_pair.public_key().clone(), - &hpke_ctx.receiver.clone(), - ) - .map_err(InternalCreateRequestError::Hpke)?; - let mut ohttp = - self.endpoint.ohttp().map_err(|_| InternalCreateRequestError::MissingOhttpConfig)?; - let (body, ohttp_ctx) = ohttp_encapsulate(&mut ohttp, "POST", url.as_str(), Some(&body)) - .map_err(InternalCreateRequestError::OhttpEncapsulation)?; - log::debug!("ohttp_relay_url: {:?}", ohttp_relay); - Ok(( - Request::new_v2(ohttp_relay, body), - V2PostContext { - endpoint: self.endpoint.clone(), - psbt_ctx: PsbtContext { - original_psbt: self.psbt.clone(), - disable_output_substitution: self.disable_output_substitution, - fee_contribution: self.fee_contribution, - payee: self.payee.clone(), - min_fee_rate: self.min_fee_rate, - allow_mixed_input_scripts: true, - }, - hpke_ctx, - ohttp_ctx, - }, - )) - } - - #[cfg(feature = "v2")] - fn extract_rs_pubkey( - &self, - ) -> Result { - use crate::uri::UrlExt; - self.endpoint.receiver_pubkey() - } - - pub fn endpoint(&self) -> &Url { &self.endpoint } -} - /// Data required to validate the response. /// -/// This type is used to process the response. Get it from [`Sender`]'s build methods. +/// This type is used to process a BIP78 response. /// Then call [`Self::process_response`] on it to continue BIP78 flow. #[derive(Debug, Clone)] pub struct V1Context { @@ -363,104 +45,6 @@ impl V1Context { } } -#[cfg(feature = "v2")] -pub struct V2PostContext { - /// The payjoin directory subdirectory to send the request to. - endpoint: Url, - psbt_ctx: PsbtContext, - hpke_ctx: HpkeContext, - ohttp_ctx: ohttp::ClientResponse, -} - -#[cfg(feature = "v2")] -impl V2PostContext { - pub fn process_response(self, response: &[u8]) -> Result { - let response_array: &[u8; crate::ohttp::ENCAPSULATED_MESSAGE_BYTES] = - response - .try_into() - .map_err(|_| InternalValidationError::UnexpectedResponseSize(response.len()))?; - let response = ohttp_decapsulate(self.ohttp_ctx, response_array) - .map_err(InternalValidationError::OhttpEncapsulation)?; - match response.status() { - http::StatusCode::OK => { - // return OK with new Typestate - Ok(V2GetContext { - endpoint: self.endpoint, - psbt_ctx: self.psbt_ctx, - hpke_ctx: self.hpke_ctx, - }) - } - _ => Err(InternalValidationError::UnexpectedStatusCode)?, - } - } -} - -#[cfg(feature = "v2")] -#[derive(Debug, Clone)] -pub struct V2GetContext { - /// The payjoin directory subdirectory to send the request to. - endpoint: Url, - psbt_ctx: PsbtContext, - hpke_ctx: HpkeContext, -} - -#[cfg(feature = "v2")] -impl V2GetContext { - pub fn extract_req( - &self, - ohttp_relay: Url, - ) -> Result<(Request, ohttp::ClientResponse), CreateRequestError> { - use crate::uri::UrlExt; - let base_url = self.endpoint.clone(); - - // TODO unify with receiver's fn subdir_path_from_pubkey - let hash = sha256::Hash::hash(&self.hpke_ctx.reply_pair.public_key().to_compressed_bytes()); - let subdir: ShortId = hash.into(); - let url = base_url.join(&subdir.to_string()).map_err(InternalCreateRequestError::Url)?; - let body = encrypt_message_a( - Vec::new(), - &self.hpke_ctx.reply_pair.public_key().clone(), - &self.hpke_ctx.receiver.clone(), - ) - .map_err(InternalCreateRequestError::Hpke)?; - let mut ohttp = - self.endpoint.ohttp().map_err(|_| InternalCreateRequestError::MissingOhttpConfig)?; - let (body, ohttp_ctx) = ohttp_encapsulate(&mut ohttp, "GET", url.as_str(), Some(&body)) - .map_err(InternalCreateRequestError::OhttpEncapsulation)?; - - Ok((Request::new_v2(ohttp_relay, body), ohttp_ctx)) - } - - pub fn process_response( - &self, - response: &[u8], - ohttp_ctx: ohttp::ClientResponse, - ) -> Result, ResponseError> { - let response_array: &[u8; crate::ohttp::ENCAPSULATED_MESSAGE_BYTES] = - response - .try_into() - .map_err(|_| InternalValidationError::UnexpectedResponseSize(response.len()))?; - - let response = ohttp_decapsulate(ohttp_ctx, response_array) - .map_err(InternalValidationError::OhttpEncapsulation)?; - let body = match response.status() { - http::StatusCode::OK => response.body().to_vec(), - http::StatusCode::ACCEPTED => return Ok(None), - _ => return Err(InternalValidationError::UnexpectedStatusCode)?, - }; - let psbt = decrypt_message_b( - &body, - self.hpke_ctx.receiver.clone(), - self.hpke_ctx.reply_pair.secret_key().clone(), - ) - .map_err(InternalValidationError::Hpke)?; - - let proposal = Psbt::deserialize(&psbt).map_err(InternalValidationError::Psbt)?; - let processed_proposal = self.psbt_ctx.clone().process_proposal(proposal)?; - Ok(Some(processed_proposal)) - } -} - /// Data required to validate the response against the original PSBT. #[derive(Debug, Clone)] pub struct PsbtContext { @@ -472,20 +56,6 @@ pub struct PsbtContext { allow_mixed_input_scripts: bool, } -#[cfg(feature = "v2")] -#[derive(Debug, Clone)] -struct HpkeContext { - receiver: HpkePublicKey, - reply_pair: HpkeKeyPair, -} - -#[cfg(feature = "v2")] -impl HpkeContext { - pub fn new(receiver: HpkePublicKey, reply_key: &HpkeSecretKey) -> Self { - Self { receiver, reply_pair: HpkeKeyPair::from_secret_key(reply_key) } - } -} - macro_rules! check_eq { ($proposed:expr, $original:expr, $error:ident) => { match ($proposed, $original) { @@ -859,27 +429,6 @@ fn determine_fee_contribution( }) } -#[cfg(feature = "v2")] -fn serialize_v2_body( - psbt: &Psbt, - disable_output_substitution: bool, - fee_contribution: Option<(bitcoin::Amount, usize)>, - min_feerate: FeeRate, -) -> Result, CreateRequestError> { - // Grug say localhost base be discarded anyway. no big brain needed. - let placeholder_url = serialize_url( - Url::parse("http://localhost").unwrap(), - disable_output_substitution, - fee_contribution, - min_feerate, - "2", // payjoin version - ) - .map_err(InternalCreateRequestError::Url)?; - let query_params = placeholder_url.query().unwrap_or_default(); - let base64 = psbt.to_string(); - Ok(format!("{}\n{}", base64, query_params).into_bytes()) -} - fn serialize_url( endpoint: Url, disable_output_substitution: bool, @@ -906,7 +455,7 @@ fn serialize_url( } #[cfg(test)] -mod test { +pub(crate) mod test { use std::str::FromStr; use bitcoin::psbt::Psbt; @@ -915,7 +464,7 @@ mod test { use crate::psbt::PsbtExt; use crate::send::error::{ResponseError, WellKnownError}; - const ORIGINAL_PSBT: &str = "cHNidP8BAHMCAAAAAY8nutGgJdyYGXWiBEb45Hoe9lWGbkxh/6bNiOJdCDuDAAAAAAD+////AtyVuAUAAAAAF6kUHehJ8GnSdBUOOv6ujXLrWmsJRDCHgIQeAAAAAAAXqRR3QJbbz0hnQ8IvQ0fptGn+votneofTAAAAAAEBIKgb1wUAAAAAF6kU3k4ekGHKWRNbA1rV5tR5kEVDVNCHAQcXFgAUx4pFclNVgo1WWAdN1SYNX8tphTABCGsCRzBEAiB8Q+A6dep+Rz92vhy26lT0AjZn4PRLi8Bf9qoB/CMk0wIgP/Rj2PWZ3gEjUkTlhDRNAQ0gXwTO7t9n+V14pZ6oljUBIQMVmsAaoNWHVMS02LfTSe0e388LNitPa1UQZyOihY+FFgABABYAFEb2Giu6c4KO5YW0pfw3lGp9jMUUAAA="; + pub(crate) const ORIGINAL_PSBT: &str = "cHNidP8BAHMCAAAAAY8nutGgJdyYGXWiBEb45Hoe9lWGbkxh/6bNiOJdCDuDAAAAAAD+////AtyVuAUAAAAAF6kUHehJ8GnSdBUOOv6ujXLrWmsJRDCHgIQeAAAAAAAXqRR3QJbbz0hnQ8IvQ0fptGn+votneofTAAAAAAEBIKgb1wUAAAAAF6kU3k4ekGHKWRNbA1rV5tR5kEVDVNCHAQcXFgAUx4pFclNVgo1WWAdN1SYNX8tphTABCGsCRzBEAiB8Q+A6dep+Rz92vhy26lT0AjZn4PRLi8Bf9qoB/CMk0wIgP/Rj2PWZ3gEjUkTlhDRNAQ0gXwTO7t9n+V14pZ6oljUBIQMVmsAaoNWHVMS02LfTSe0e388LNitPa1UQZyOihY+FFgABABYAFEb2Giu6c4KO5YW0pfw3lGp9jMUUAAA="; const PAYJOIN_PROPOSAL: &str = "cHNidP8BAJwCAAAAAo8nutGgJdyYGXWiBEb45Hoe9lWGbkxh/6bNiOJdCDuDAAAAAAD+////jye60aAl3JgZdaIERvjkeh72VYZuTGH/ps2I4l0IO4MBAAAAAP7///8CJpW4BQAAAAAXqRQd6EnwadJ0FQ46/q6NcutaawlEMIcACT0AAAAAABepFHdAltvPSGdDwi9DR+m0af6+i2d6h9MAAAAAAQEgqBvXBQAAAAAXqRTeTh6QYcpZE1sDWtXm1HmQRUNU0IcBBBYAFMeKRXJTVYKNVlgHTdUmDV/LaYUwIgYDFZrAGqDVh1TEtNi300ntHt/PCzYrT2tVEGcjooWPhRYYSFzWUDEAAIABAACAAAAAgAEAAAAAAAAAAAEBIICEHgAAAAAAF6kUyPLL+cphRyyI5GTUazV0hF2R2NWHAQcXFgAUX4BmVeWSTJIEwtUb5TlPS/ntohABCGsCRzBEAiBnu3tA3yWlT0WBClsXXS9j69Bt+waCs9JcjWtNjtv7VgIge2VYAaBeLPDB6HGFlpqOENXMldsJezF9Gs5amvDQRDQBIQJl1jz1tBt8hNx2owTm+4Du4isx0pmdKNMNIjjaMHFfrQABABYAFEb2Giu6c4KO5YW0pfw3lGp9jMUUIgICygvBWB5prpfx61y1HDAwo37kYP3YRJBvAjtunBAur3wYSFzWUDEAAIABAACAAAAAgAEAAAABAAAAAAA="; fn create_v1_context() -> super::PsbtContext { @@ -970,24 +519,6 @@ mod test { ctx.process_proposal(proposal).unwrap(); } - #[test] - #[cfg(feature = "v2")] - fn req_ctx_ser_de_roundtrip() { - use super::*; - let req_ctx = Sender { - psbt: Psbt::from_str(ORIGINAL_PSBT).unwrap(), - endpoint: Url::parse("http://localhost:1234").unwrap(), - disable_output_substitution: false, - fee_contribution: None, - min_fee_rate: FeeRate::ZERO, - payee: ScriptBuf::from(vec![0x00]), - reply_key: HpkeKeyPair::gen_keypair().0, - }; - let serialized = serde_json::to_string(&req_ctx).unwrap(); - let deserialized = serde_json::from_str(&serialized).unwrap(); - assert!(req_ctx == deserialized); - } - #[test] fn handle_json_errors() { let ctx = create_v1_context(); diff --git a/payjoin/src/send/v1.rs b/payjoin/src/send/v1.rs new file mode 100644 index 00000000..a3194bba --- /dev/null +++ b/payjoin/src/send/v1.rs @@ -0,0 +1,255 @@ +//! Send Payjoin +//! +//! This module contains types and methods used to implement sending via [BIP78 +//! Payjoin](https://github.com/bitcoin/bips/blob/master/bip-0078.mediawiki). +//! +//! Usage is pretty simple: +//! +//! 1. Parse BIP21 as [`payjoin::Uri`](crate::Uri) +//! 2. Construct URI request parameters, a finalized “Original PSBT” paying .amount to .address +//! 3. (optional) Spawn a thread or async task that will broadcast the original PSBT fallback after +//! delay (e.g. 1 minute) unless canceled +//! 4. Construct the [`Sender`] using [`SenderBuilder`] with the PSBT and payjoin uri +//! 5. Send the request and receive a response by following on the extracted V1Context +//! 6. Sign and finalize the Payjoin Proposal PSBT +//! 7. Broadcast the Payjoin Transaction (and cancel the optional fallback broadcast) +//! +//! This crate is runtime-agnostic. Data persistence, chain interactions, and networking may be +//! provided by custom implementations or copy the reference +//! [`payjoin-cli`](https://github.com/payjoin/rust-payjoin/tree/master/payjoin-cli) for bitcoind, +//! [`nolooking`](https://github.com/chaincase-app/nolooking) for LND, or +//! [`bitmask-core`](https://github.com/diba-io/bitmask-core) BDK integration. Bring your own +//! wallet and http client. + +use bitcoin::psbt::Psbt; +use bitcoin::{FeeRate, ScriptBuf, Weight}; +use error::{BuildSenderError, InternalBuildSenderError}; +use url::Url; + +use super::*; +use crate::psbt::PsbtExt; +use crate::request::Request; +use crate::PjUri; + +#[derive(Clone)] +pub struct SenderBuilder<'a> { + pub(crate) psbt: Psbt, + pub(crate) uri: PjUri<'a>, + pub(crate) disable_output_substitution: bool, + 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, +} + +impl<'a> SenderBuilder<'a> { + /// 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 { + psbt, + uri, + // Sender's optional parameters + disable_output_substitution: false, + fee_contribution: None, + clamp_fee_contribution: false, + min_fee_rate: FeeRate::ZERO, + } + } + + /// Disable output substitution even if the receiver didn't. + /// + /// This forbids receiver switching output or decreasing amount. + /// 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, disable: bool) -> Self { + self.disable_output_substitution = disable; + self + } + + // 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) -> 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() + // Lengths of txid, index and sequence: (32, 4, 4). + + Weight::from_non_witness_data_size(32 + 4 + 4); + 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) + } + + /// 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, + ) -> 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() + } + + /// 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, + ) -> 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(true).map_err(InternalBuildSenderError::InvalidOriginalInput)?; + let endpoint = self.uri.extras.endpoint.clone(); + let disable_output_substitution = + self.uri.extras.disable_output_substitution || self.disable_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, + disable_output_substitution, + fee_contribution, + payee, + min_fee_rate: self.min_fee_rate, + }) + } +} + +#[derive(Clone, PartialEq, Eq)] +#[cfg_attr(feature = "v2", derive(serde::Serialize, serde::Deserialize))] +pub struct Sender { + /// The original PSBT. + pub(crate) psbt: Psbt, + /// The payjoin directory subdirectory to send the request to. + pub(crate) endpoint: Url, + /// Disallow reciever to substitute original outputs. + pub(crate) disable_output_substitution: bool, + /// (maxadditionalfeecontribution, additionalfeeoutputindex) + pub(crate) fee_contribution: Option<(bitcoin::Amount, usize)>, + pub(crate) min_fee_rate: FeeRate, + /// Script of the person being paid + pub(crate) payee: ScriptBuf, +} + +impl Sender { + /// Extract serialized V1 Request and Context from a Payjoin Proposal + pub fn extract_v1(&self) -> Result<(Request, V1Context), url::ParseError> { + let url = serialize_url( + self.endpoint.clone(), + self.disable_output_substitution, + self.fee_contribution, + self.min_fee_rate, + "1", // payjoin version + )?; + let body = self.psbt.to_string().as_bytes().to_vec(); + Ok(( + Request::new_v1(url, body), + V1Context { + psbt_context: PsbtContext { + original_psbt: self.psbt.clone(), + disable_output_substitution: self.disable_output_substitution, + fee_contribution: self.fee_contribution, + payee: self.payee.clone(), + min_fee_rate: self.min_fee_rate, + allow_mixed_input_scripts: false, + }, + }, + )) + } + + pub fn endpoint(&self) -> &Url { &self.endpoint } +} diff --git a/payjoin/src/send/v2/error.rs b/payjoin/src/send/v2/error.rs new file mode 100644 index 00000000..a5c39593 --- /dev/null +++ b/payjoin/src/send/v2/error.rs @@ -0,0 +1,62 @@ +use core::fmt; + +use crate::uri::url_ext::ParseReceiverPubkeyParamError; + +/// Error returned when request could not be created. +/// +/// This error can currently only happen due to programmer mistake. +/// `unwrap()`ing it is thus considered OK in Rust but you may achieve nicer message by displaying +/// it. +#[derive(Debug)] +pub struct CreateRequestError(InternalCreateRequestError); + +#[derive(Debug)] +pub(crate) enum InternalCreateRequestError { + Url(url::ParseError), + Hpke(crate::hpke::HpkeError), + OhttpEncapsulation(crate::ohttp::OhttpEncapsulationError), + ParseReceiverPubkey(ParseReceiverPubkeyParamError), + MissingOhttpConfig, + Expired(std::time::SystemTime), +} + +impl fmt::Display for CreateRequestError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + use InternalCreateRequestError::*; + + match &self.0 { + Url(e) => write!(f, "cannot parse url: {:#?}", e), + Hpke(e) => write!(f, "v2 error: {}", e), + OhttpEncapsulation(e) => write!(f, "v2 error: {}", e), + ParseReceiverPubkey(e) => write!(f, "cannot parse receiver public key: {}", e), + MissingOhttpConfig => + write!(f, "no ohttp configuration with which to make a v2 request available"), + Expired(expiry) => write!(f, "session expired at {:?}", expiry), + } + } +} + +impl std::error::Error for CreateRequestError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + use InternalCreateRequestError::*; + + match &self.0 { + Url(error) => Some(error), + Hpke(error) => Some(error), + OhttpEncapsulation(error) => Some(error), + ParseReceiverPubkey(error) => Some(error), + MissingOhttpConfig => None, + Expired(_) => None, + } + } +} + +impl From for CreateRequestError { + fn from(value: InternalCreateRequestError) -> Self { CreateRequestError(value) } +} + +impl From for CreateRequestError { + fn from(value: ParseReceiverPubkeyParamError) -> Self { + CreateRequestError(InternalCreateRequestError::ParseReceiverPubkey(value)) + } +} diff --git a/payjoin/src/send/v2/mod.rs b/payjoin/src/send/v2/mod.rs new file mode 100644 index 00000000..7b2e34d7 --- /dev/null +++ b/payjoin/src/send/v2/mod.rs @@ -0,0 +1,346 @@ +//! Send Payjoin +//! +//! This module contains types and methods used to implement sending via [BIP77 +//! Payjoin](https://github.com/bitcoin/bips/pull/1483). +//! +//! Usage is pretty simple: +//! +//! 1. Parse BIP21 as [`payjoin::Uri`](crate::Uri) +//! 2. Construct URI request parameters, a finalized “Original PSBT” paying .amount to .address +//! 3. (optional) Spawn a thread or async task that will broadcast the original PSBT fallback after +//! delay (e.g. 1 minute) unless canceled +//! 4. Construct the [`Sender`] using [`SenderBuilder`] with the PSBT and payjoin uri +//! 5. Send the request(s) and receive response(s) by following on the extracted Context +//! 6. Sign and finalize the Payjoin Proposal PSBT +//! 7. Broadcast the Payjoin Transaction (and cancel the optional fallback broadcast) +//! +//! This crate is runtime-agnostic. Data persistence, chain interactions, and networking may be +//! provided by custom implementations or copy the reference +//! [`payjoin-cli`](https://github.com/payjoin/rust-payjoin/tree/master/payjoin-cli) for bitcoind, +//! [`nolooking`](https://github.com/chaincase-app/nolooking) for LND, or +//! [`bitmask-core`](https://github.com/diba-io/bitmask-core) BDK integration. Bring your own +//! wallet and http client. + +use bitcoin::hashes::{sha256, Hash}; +pub use error::CreateRequestError; +use error::InternalCreateRequestError; +use serde::{Deserialize, Serialize}; +use url::Url; + +use super::error::BuildSenderError; +use super::*; +use crate::hpke::{decrypt_message_b, encrypt_message_a, HpkeSecretKey}; +use crate::ohttp::{ohttp_decapsulate, ohttp_encapsulate}; +use crate::send::v1; +use crate::uri::{ShortId, UrlExt}; +use crate::{HpkeKeyPair, HpkePublicKey, PjUri, Request}; + +mod error; + +#[derive(Clone)] +pub struct SenderBuilder<'a>(v1::SenderBuilder<'a>); + +impl<'a> SenderBuilder<'a> { + /// 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)) } + + /// Disable output substitution even if the receiver didn't. + /// + /// This forbids receiver switching output or decreasing amount. + /// 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(self, disable: bool) -> Self { + Self(self.0.always_disable_output_substitution(disable)) + } + + // 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) -> Result { + Ok(Sender { + v1: self.0.build_recommended(min_fee_rate)?, + reply_key: HpkeKeyPair::gen_keypair().0, + }) + } + + /// 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( + self, + max_fee_contribution: bitcoin::Amount, + change_index: Option, + min_fee_rate: FeeRate, + clamp_fee_contribution: bool, + ) -> Result { + Ok(Sender { + v1: self.0.build_with_additional_fee( + max_fee_contribution, + change_index, + min_fee_rate, + clamp_fee_contribution, + )?, + reply_key: HpkeKeyPair::gen_keypair().0, + }) + } + + /// 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( + self, + min_fee_rate: FeeRate, + ) -> Result { + Ok(Sender { + v1: self.0.build_non_incentivizing(min_fee_rate)?, + reply_key: HpkeKeyPair::gen_keypair().0, + }) + } +} + +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Sender { + /// The v1 Sender. + v1: v1::Sender, + /// The secret key to decrypt the receiver's reply. + reply_key: HpkeSecretKey, +} + +impl Sender { + /// Extract serialized V1 Request and Context from a Payjoin Proposal + pub fn extract_v1(&self) -> Result<(Request, V1Context), url::ParseError> { + self.v1.extract_v1() + } + + /// Extract serialized Request and Context from a Payjoin Proposal. + /// + /// This method requires the `rs` pubkey to be extracted from the endpoint + /// and has no fallback to v1. + pub fn extract_v2( + &self, + ohttp_relay: Url, + ) -> Result<(Request, V2PostContext), CreateRequestError> { + use crate::hpke::encrypt_message_a; + use crate::ohttp::ohttp_encapsulate; + use crate::send::PsbtContext; + use crate::uri::UrlExt; + if let Ok(expiry) = self.v1.endpoint.exp() { + if std::time::SystemTime::now() > expiry { + return Err(InternalCreateRequestError::Expired(expiry).into()); + } + } + let rs = self.extract_rs_pubkey()?; + let url = self.v1.endpoint.clone(); + let body = serialize_v2_body( + &self.v1.psbt, + self.v1.disable_output_substitution, + self.v1.fee_contribution, + self.v1.min_fee_rate, + )?; + let hpke_ctx = HpkeContext::new(rs, &self.reply_key); + let body = encrypt_message_a( + body, + &hpke_ctx.reply_pair.public_key().clone(), + &hpke_ctx.receiver.clone(), + ) + .map_err(InternalCreateRequestError::Hpke)?; + let mut ohttp = + self.v1.endpoint.ohttp().map_err(|_| InternalCreateRequestError::MissingOhttpConfig)?; + let (body, ohttp_ctx) = ohttp_encapsulate(&mut ohttp, "POST", url.as_str(), Some(&body)) + .map_err(InternalCreateRequestError::OhttpEncapsulation)?; + log::debug!("ohttp_relay_url: {:?}", ohttp_relay); + Ok(( + Request::new_v2(ohttp_relay, body), + V2PostContext { + endpoint: self.v1.endpoint.clone(), + psbt_ctx: PsbtContext { + original_psbt: self.v1.psbt.clone(), + disable_output_substitution: self.v1.disable_output_substitution, + fee_contribution: self.v1.fee_contribution, + payee: self.v1.payee.clone(), + min_fee_rate: self.v1.min_fee_rate, + allow_mixed_input_scripts: true, + }, + hpke_ctx, + ohttp_ctx, + }, + )) + } + + fn extract_rs_pubkey( + &self, + ) -> Result { + self.v1.endpoint.receiver_pubkey() + } + + pub fn endpoint(&self) -> &Url { self.v1.endpoint() } +} + +fn serialize_v2_body( + psbt: &Psbt, + disable_output_substitution: bool, + fee_contribution: Option<(bitcoin::Amount, usize)>, + min_feerate: FeeRate, +) -> Result, CreateRequestError> { + // Grug say localhost base be discarded anyway. no big brain needed. + let placeholder_url = serialize_url( + Url::parse("http://localhost").unwrap(), + disable_output_substitution, + fee_contribution, + min_feerate, + "2", // payjoin version + ) + .map_err(InternalCreateRequestError::Url)?; + let query_params = placeholder_url.query().unwrap_or_default(); + let base64 = psbt.to_string(); + Ok(format!("{}\n{}", base64, query_params).into_bytes()) +} + +pub struct V2PostContext { + /// The payjoin directory subdirectory to send the request to. + endpoint: Url, + psbt_ctx: PsbtContext, + hpke_ctx: HpkeContext, + ohttp_ctx: ohttp::ClientResponse, +} + +impl V2PostContext { + pub fn process_response(self, response: &[u8]) -> Result { + let response_array: &[u8; crate::ohttp::ENCAPSULATED_MESSAGE_BYTES] = + response + .try_into() + .map_err(|_| InternalValidationError::UnexpectedResponseSize(response.len()))?; + let response = ohttp_decapsulate(self.ohttp_ctx, response_array) + .map_err(InternalValidationError::OhttpEncapsulation)?; + match response.status() { + http::StatusCode::OK => { + // return OK with new Typestate + Ok(V2GetContext { + endpoint: self.endpoint, + psbt_ctx: self.psbt_ctx, + hpke_ctx: self.hpke_ctx, + }) + } + _ => Err(InternalValidationError::UnexpectedStatusCode)?, + } + } +} + +#[derive(Debug, Clone)] +pub struct V2GetContext { + /// The payjoin directory subdirectory to send the request to. + endpoint: Url, + psbt_ctx: PsbtContext, + hpke_ctx: HpkeContext, +} + +impl V2GetContext { + pub fn extract_req( + &self, + ohttp_relay: Url, + ) -> Result<(Request, ohttp::ClientResponse), CreateRequestError> { + use crate::uri::UrlExt; + let base_url = self.endpoint.clone(); + + // TODO unify with receiver's fn subdir_path_from_pubkey + let hash = sha256::Hash::hash(&self.hpke_ctx.reply_pair.public_key().to_compressed_bytes()); + let subdir: ShortId = hash.into(); + let url = base_url.join(&subdir.to_string()).map_err(InternalCreateRequestError::Url)?; + let body = encrypt_message_a( + Vec::new(), + &self.hpke_ctx.reply_pair.public_key().clone(), + &self.hpke_ctx.receiver.clone(), + ) + .map_err(InternalCreateRequestError::Hpke)?; + let mut ohttp = + self.endpoint.ohttp().map_err(|_| InternalCreateRequestError::MissingOhttpConfig)?; + let (body, ohttp_ctx) = ohttp_encapsulate(&mut ohttp, "GET", url.as_str(), Some(&body)) + .map_err(InternalCreateRequestError::OhttpEncapsulation)?; + + Ok((Request::new_v2(ohttp_relay, body), ohttp_ctx)) + } + + pub fn process_response( + &self, + response: &[u8], + ohttp_ctx: ohttp::ClientResponse, + ) -> Result, ResponseError> { + let response_array: &[u8; crate::ohttp::ENCAPSULATED_MESSAGE_BYTES] = + response + .try_into() + .map_err(|_| InternalValidationError::UnexpectedResponseSize(response.len()))?; + + let response = ohttp_decapsulate(ohttp_ctx, response_array) + .map_err(InternalValidationError::OhttpEncapsulation)?; + let body = match response.status() { + http::StatusCode::OK => response.body().to_vec(), + http::StatusCode::ACCEPTED => return Ok(None), + _ => return Err(InternalValidationError::UnexpectedStatusCode)?, + }; + let psbt = decrypt_message_b( + &body, + self.hpke_ctx.receiver.clone(), + self.hpke_ctx.reply_pair.secret_key().clone(), + ) + .map_err(InternalValidationError::Hpke)?; + + let proposal = Psbt::deserialize(&psbt).map_err(InternalValidationError::Psbt)?; + let processed_proposal = self.psbt_ctx.clone().process_proposal(proposal)?; + Ok(Some(processed_proposal)) + } +} + +#[cfg(feature = "v2")] +#[derive(Debug, Clone)] +struct HpkeContext { + receiver: HpkePublicKey, + reply_pair: HpkeKeyPair, +} + +#[cfg(feature = "v2")] +impl HpkeContext { + pub fn new(receiver: HpkePublicKey, reply_key: &HpkeSecretKey) -> Self { + Self { receiver, reply_pair: HpkeKeyPair::from_secret_key(reply_key) } + } +} + +mod test { + #[test] + #[cfg(feature = "v2")] + fn req_ctx_ser_de_roundtrip() { + use super::*; + use crate::send::test::ORIGINAL_PSBT; + let req_ctx = Sender { + v1: v1::Sender { + psbt: Psbt::from_str(ORIGINAL_PSBT).unwrap(), + endpoint: Url::parse("http://localhost:1234").unwrap(), + disable_output_substitution: false, + fee_contribution: None, + min_fee_rate: FeeRate::ZERO, + payee: ScriptBuf::from(vec![0x00]), + }, + reply_key: HpkeKeyPair::gen_keypair().0, + }; + let serialized = serde_json::to_string(&req_ctx).unwrap(); + let deserialized = serde_json::from_str(&serialized).unwrap(); + assert!(req_ctx == deserialized); + } +} diff --git a/payjoin/tests/integration.rs b/payjoin/tests/integration.rs index 11687f2f..39f8a704 100644 --- a/payjoin/tests/integration.rs +++ b/payjoin/tests/integration.rs @@ -13,7 +13,6 @@ mod integration { use log::{log_enabled, Level}; use once_cell::sync::{Lazy, OnceCell}; use payjoin::receive::InputPair; - use payjoin::send::SenderBuilder; use payjoin::{PjUri, PjUriBuilder, Request, Uri}; use tracing_subscriber::{EnvFilter, FmtSubscriber}; use url::Url; @@ -27,6 +26,7 @@ mod integration { #[cfg(not(feature = "v2"))] mod v1 { use log::debug; + use payjoin::send::v1::SenderBuilder; use payjoin::UriExt; use super::*; @@ -179,6 +179,7 @@ mod integration { use bitcoin::Address; use http::StatusCode; use payjoin::receive::v2::{PayjoinProposal, Receiver, UncheckedProposal}; + use payjoin::send::v2::SenderBuilder; use payjoin::{HpkeKeyPair, OhttpKeys, PjUri, UriExt}; use reqwest::{Client, ClientBuilder, Error, Response}; use testcontainers_modules::redis::Redis; @@ -1009,6 +1010,7 @@ mod integration { #[cfg(not(feature = "v2"))] mod batching { + use payjoin::send::v1::SenderBuilder; use payjoin::UriExt; use super::*;