diff --git a/payjoin/src/receive/error.rs b/payjoin/src/receive/error.rs index d6271c7f..7aa4f72d 100644 --- a/payjoin/src/receive/error.rs +++ b/payjoin/src/receive/error.rs @@ -9,6 +9,16 @@ pub enum Error { Server(Box), } +impl Error { + pub fn to_json(&self) -> String { + match self { + Self::BadRequest(e) => e.to_string(), + Self::Server(e) => + format!("{{ \"errorCode\": \"server-error\", \"message\": \"{}\" }}", e), + } + } +} + impl fmt::Display for Error { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { match &self { diff --git a/payjoin/src/receive/v2/mod.rs b/payjoin/src/receive/v2/mod.rs index 33699003..76fb5c53 100644 --- a/payjoin/src/receive/v2/mod.rs +++ b/payjoin/src/receive/v2/mod.rs @@ -53,55 +53,74 @@ fn subdir_path_from_pubkey(pubkey: &HpkePublicKey) -> ShortId { sha256::Hash::hash(&pubkey.to_compressed_bytes()).into() } -// State types #[derive(Debug, Clone)] -pub struct UncheckedProposal { - v1: v1::UncheckedProposal, -} +pub struct InitialState; -#[derive(Debug, Clone)] -pub struct MaybeInputsOwned { - v1: v1::MaybeInputsOwned, -} +pub trait State {} -#[derive(Debug, Clone)] -pub struct MaybeInputsSeen { - v1: v1::MaybeInputsSeen, -} - -#[derive(Debug, Clone)] -pub struct OutputsUnknown { - inner: v1::OutputsUnknown, -} +impl State for InitialState {} +impl State for v1::UncheckedProposal {} +impl State for v1::MaybeInputsOwned {} +impl State for v1::MaybeInputsSeen {} +impl State for v1::OutputsUnknown {} +impl State for v1::WantsOutputs {} +impl State for v1::WantsInputs {} +impl State for v1::ProvisionalProposal {} +impl State for v1::PayjoinProposal {} +// Main Receiver type that holds both context and state #[derive(Debug, Clone)] -pub struct WantsOutputs { - v1: v1::WantsOutputs, +pub struct Receiver { + context: SessionContext, + state: S, } -#[derive(Debug, Clone)] -pub struct WantsInputs { - v1: v1::WantsInputs, -} +impl Receiver { + pub fn extract_err_req( + &mut self, + err: Error, + ) -> Result<(Request, ohttp::ClientResponse), InternalSessionError> { + let (body, ohttp_ctx) = ohttp_encapsulate( + &mut self.context.ohttp_keys, + "POST", + self.subdir().as_str(), + Some(err.to_json().as_bytes()), + ) + .map_err(InternalSessionError::OhttpEncapsulation)?; + let url = self.subdir(); + let req = Request::new_v2(url, body); + Ok((req, ohttp_ctx)) + } -#[derive(Debug, Clone)] -pub struct ProvisionalProposal { - v1: v1::ProvisionalProposal, -} + fn state(&self) -> &S { &self.state } -#[derive(Debug, Clone)] -pub struct PayjoinProposal { - v1: v1::PayjoinProposal, -} + /// Build a V2 Payjoin URI from the receiver's context + pub fn pj_uri<'a>(&self) -> crate::PjUri<'a> { + use crate::uri::{PayjoinExtras, UrlExt}; + let mut pj = self.subdir().clone(); + pj.set_receiver_pubkey(self.context.s.public_key().clone()); + pj.set_ohttp(self.context.ohttp_keys.clone()); + pj.set_exp(self.context.expiry); + let extras = PayjoinExtras { endpoint: pj, disable_output_substitution: false }; + bitcoin_uri::Uri::with_extras(self.context.address.clone(), extras) + } -#[derive(Debug, Clone)] -pub struct InitialState; + /// The subdirectory for this Payjoin receiver session. + /// It consists of a directory URL and the session ShortID in the path. + pub fn subdir(&self) -> Url { + let mut url = self.context.directory.clone(); + { + let mut path_segments = + url.path_segments_mut().expect("Payjoin Directory URL cannot be a base"); + path_segments.push(&self.id().to_string()); + } + url + } -// Main Receiver type that holds both context and state -#[derive(Debug, Clone)] -pub struct Receiver { - context: SessionContext, - state: S, + /// The per-session identifier + pub fn id(&self) -> ShortId { + sha256::Hash::hash(&self.context.s.public_key().to_compressed_bytes()).into() + } } // Only implement serialization for the initial state @@ -181,7 +200,7 @@ impl Receiver { &mut self, body: &[u8], context: ohttp::ClientResponse, - ) -> Result>, Error> { + ) -> Result>, Error> { let response_array: &[u8; crate::ohttp::ENCAPSULATED_MESSAGE_BYTES] = body.try_into().map_err(|_| { Error::Server(Box::new(SessionError::from( @@ -215,24 +234,21 @@ impl Receiver { fn extract_proposal_from_v1( &mut self, response: String, - ) -> Result, Error> { + ) -> Result, Error> { Ok(Receiver { context: self.context.clone(), - state: UncheckedProposal { v1: self.unchecked_from_payload(response)? }, + state: self.unchecked_from_payload(response)?, }) } fn extract_proposal_from_v2( &mut self, response: Vec, - ) -> Result, Error> { + ) -> Result, Error> { let (payload_bytes, e) = decrypt_message_a(&response, self.context.s.secret_key().clone())?; self.context.e = Some(e); let payload = String::from_utf8(payload_bytes).map_err(InternalRequestError::Utf8)?; - Ok(Receiver { - context: self.context.clone(), - state: UncheckedProposal { v1: self.unchecked_from_payload(payload)? }, - }) + Ok(Receiver { context: self.context.clone(), state: self.unchecked_from_payload(payload)? }) } fn unchecked_from_payload( @@ -266,41 +282,13 @@ impl Receiver { log::debug!("Received request with params: {:?}", params); Ok(v1::UncheckedProposal { psbt, params }) } - - /// Build a V2 Payjoin URI from the receiver's context - pub fn pj_uri<'a>(&self) -> crate::PjUri<'a> { - use crate::uri::{PayjoinExtras, UrlExt}; - let mut pj = self.subdir().clone(); - pj.set_receiver_pubkey(self.context.s.public_key().clone()); - pj.set_ohttp(self.context.ohttp_keys.clone()); - pj.set_exp(self.context.expiry); - let extras = PayjoinExtras { endpoint: pj, disable_output_substitution: false }; - bitcoin_uri::Uri::with_extras(self.context.address.clone(), extras) - } - - /// The subdirectory for this Payjoin receiver session. - /// It consists of a directory URL and the session ShortID in the path. - pub fn subdir(&self) -> Url { - let mut url = self.context.directory.clone(); - { - let mut path_segments = - url.path_segments_mut().expect("Payjoin Directory URL cannot be a base"); - path_segments.push(&self.id().to_string()); - } - url - } - - /// The per-session identifier - pub fn id(&self) -> ShortId { - sha256::Hash::hash(&self.context.s.public_key().to_compressed_bytes()).into() - } } // Implement state transitions for UncheckedState -impl Receiver { +impl Receiver { /// The Sender's Original PSBT pub fn extract_tx_to_schedule_broadcast(&self) -> bitcoin::Transaction { - self.state.v1.extract_tx_to_schedule_broadcast() + self.state.extract_tx_to_schedule_broadcast() } /// Call after checking that the Original PSBT can be broadcast. @@ -319,9 +307,9 @@ impl Receiver { self, min_fee_rate: Option, can_broadcast: impl Fn(&bitcoin::Transaction) -> Result, - ) -> Result, Error> { - let inner = self.state.v1.check_broadcast_suitability(min_fee_rate, can_broadcast)?; - Ok(Receiver { context: self.context, state: MaybeInputsOwned { v1: inner } }) + ) -> Result, Error> { + let state = self.state.check_broadcast_suitability(min_fee_rate, can_broadcast)?; + Ok(Receiver { context: self.context, state }) } /// Call this method if the only way to initiate a Payjoin with this receiver @@ -329,14 +317,14 @@ impl Receiver { /// /// So-called "non-interactive" receivers, like payment processors, that allow arbitrary requests are otherwise vulnerable to probing attacks. /// Those receivers call `extract_tx_to_check_broadcast()` and `attest_tested_and_scheduled_broadcast()` after making those checks downstream. - pub fn assume_interactive_receiver(self) -> Receiver { - let inner = self.state.v1.assume_interactive_receiver(); - Receiver { context: self.context, state: MaybeInputsOwned { v1: inner } } + pub fn assume_interactive_receiver(self) -> Receiver { + let state = self.state.assume_interactive_receiver(); + Receiver { context: self.context, state } } } // Implement state transitions for MaybeInputsOwnedState -impl Receiver { +impl Receiver { /// Check that the Original PSBT has no receiver-owned inputs. /// Return original-psbt-rejected error or otherwise refuse to sign undesirable inputs. /// @@ -344,51 +332,51 @@ impl Receiver { pub fn check_inputs_not_owned( self, is_owned: impl Fn(&Script) -> Result, - ) -> Result, Error> { - let inner = self.state.v1.check_inputs_not_owned(is_owned)?; - Ok(Receiver { context: self.context, state: MaybeInputsSeen { v1: inner } }) + ) -> Result, Error> { + let state = self.state.check_inputs_not_owned(is_owned)?; + Ok(Receiver { context: self.context, state }) } } // Implement state transitions for MaybeInputsSeenState -impl Receiver { +impl Receiver { /// Make sure that the original transaction inputs have never been seen before. /// This prevents probing attacks. This prevents reentrant Payjoin, where a sender /// proposes a Payjoin PSBT as a new Original PSBT for a new Payjoin. pub fn check_no_inputs_seen_before( self, is_known: impl Fn(&OutPoint) -> Result, - ) -> Result, Error> { - let inner = self.state.v1.check_no_inputs_seen_before(is_known)?; - Ok(Receiver { context: self.context, state: OutputsUnknown { inner } }) + ) -> Result, Error> { + let state = self.state.check_no_inputs_seen_before(is_known)?; + Ok(Receiver { context: self.context, state }) } } // Implement state transitions for OutputsUnknownState -impl Receiver { +impl Receiver { /// Find which outputs belong to the receiver pub fn identify_receiver_outputs( self, is_receiver_output: impl Fn(&Script) -> Result, - ) -> Result, Error> { - let inner = self.state.inner.identify_receiver_outputs(is_receiver_output)?; - Ok(Receiver { context: self.context, state: WantsOutputs { v1: inner } }) + ) -> Result, Error> { + let state = self.state.identify_receiver_outputs(is_receiver_output)?; + Ok(Receiver { context: self.context, state }) } } // Implement state transitions for WantsOutputsState -impl Receiver { +impl Receiver { pub fn is_output_substitution_disabled(&self) -> bool { - self.state.v1.is_output_substitution_disabled() + self.state.is_output_substitution_disabled() } /// Substitute the receiver output script with the provided script. pub fn substitute_receiver_script( self, output_script: &Script, - ) -> Result, OutputSubstitutionError> { - let inner = self.state.v1.substitute_receiver_script(output_script)?; - Ok(Receiver { context: self.context, state: WantsOutputs { v1: inner } }) + ) -> Result, OutputSubstitutionError> { + let state = self.state.substitute_receiver_script(output_script)?; + Ok(Receiver { context: self.context, state }) } /// Replace **all** receiver outputs with one or more provided outputs. @@ -400,21 +388,21 @@ impl Receiver { self, replacement_outputs: Vec, drain_script: &Script, - ) -> Result, OutputSubstitutionError> { - let inner = self.state.v1.replace_receiver_outputs(replacement_outputs, drain_script)?; - Ok(Receiver { context: self.context, state: WantsOutputs { v1: inner } }) + ) -> Result, OutputSubstitutionError> { + let state = self.state.replace_receiver_outputs(replacement_outputs, drain_script)?; + Ok(Receiver { context: self.context, state }) } /// Proceed to the input contribution step. /// Outputs cannot be modified after this function is called. - pub fn commit_outputs(self) -> Receiver { - let inner = self.state.v1.commit_outputs(); - Receiver { context: self.context, state: WantsInputs { v1: inner } } + pub fn commit_outputs(self) -> Receiver { + let state = self.state.commit_outputs(); + Receiver { context: self.context, state } } } // Implement state transitions for WantsInputsState -impl Receiver { +impl Receiver { /// Select receiver input such that the payjoin avoids surveillance. /// Return the input chosen that has been applied to the Proposal. /// @@ -430,7 +418,7 @@ impl Receiver { &self, candidate_inputs: impl IntoIterator, ) -> Result { - self.state.v1.try_preserving_privacy(candidate_inputs) + self.state.try_preserving_privacy(candidate_inputs) } /// Add the provided list of inputs to the transaction. @@ -438,47 +426,47 @@ impl Receiver { pub fn contribute_inputs( self, inputs: impl IntoIterator, - ) -> Result, InputContributionError> { - let inner = self.state.v1.contribute_inputs(inputs)?; - Ok(Receiver { context: self.context, state: WantsInputs { v1: inner } }) + ) -> Result, InputContributionError> { + let state = self.state.contribute_inputs(inputs)?; + Ok(Receiver { context: self.context, state }) } /// Proceed to the proposal finalization step. /// Inputs cannot be modified after this function is called. - pub fn commit_inputs(self) -> Receiver { - let inner = self.state.v1.commit_inputs(); - Receiver { context: self.context, state: ProvisionalProposal { v1: inner } } + pub fn commit_inputs(self) -> Receiver { + let state = self.state.commit_inputs(); + Receiver { context: self.context, state } } } // Implement state transitions for ProvisionalProposalState -impl Receiver { +impl Receiver { pub fn finalize_proposal( self, wallet_process_psbt: impl Fn(&Psbt) -> Result, min_feerate_sat_per_vb: Option, max_feerate_sat_per_vb: FeeRate, - ) -> Result, Error> { - let inner = self.state.v1.finalize_proposal( + ) -> Result, Error> { + let state = self.state.finalize_proposal( wallet_process_psbt, min_feerate_sat_per_vb, max_feerate_sat_per_vb, )?; - Ok(Receiver { context: self.context, state: PayjoinProposal { v1: inner } }) + Ok(Receiver { context: self.context, state }) } } // Implement state transitions for PayjoinProposalState -impl Receiver { +impl Receiver { pub fn utxos_to_be_locked(&self) -> impl '_ + Iterator { - self.state.v1.utxos_to_be_locked() + self.state.utxos_to_be_locked() } pub fn is_output_substitution_disabled(&self) -> bool { - self.state.v1.is_output_substitution_disabled() + self.state.is_output_substitution_disabled() } - pub fn psbt(&self) -> &Psbt { self.state.v1.psbt() } + pub fn psbt(&self) -> &Psbt { self.state.psbt() } #[cfg(feature = "v2")] pub fn extract_v2_req(&mut self) -> Result<(Request, ohttp::ClientResponse), Error> { @@ -488,7 +476,7 @@ impl Receiver { if let Some(e) = &self.context.e { // Prepare v2 payload - let payjoin_bytes = self.state.v1.psbt().serialize(); + let payjoin_bytes = self.state.psbt().serialize(); let sender_subdir = subdir_path_from_pubkey(e); target_resource = self .context @@ -499,7 +487,7 @@ impl Receiver { method = "POST"; } else { // Prepare v2 wrapped and backwards-compatible v1 payload - body = self.state.v1.psbt().to_string().as_bytes().to_vec(); + body = self.state.psbt().to_string().as_bytes().to_vec(); let receiver_subdir = subdir_path_from_pubkey(self.context.s.public_key()); target_resource = self .context