diff --git a/payjoin-cli/src/app/v2/mod.rs b/payjoin-cli/src/app/v2/mod.rs index 8a1ff785e..f6ca9c78e 100644 --- a/payjoin-cli/src/app/v2/mod.rs +++ b/payjoin-cli/src/app/v2/mod.rs @@ -329,8 +329,15 @@ impl AppTrait for App { )); } Err(e) => { - tracing::error!("An error {:?} occurred while replaying receiver session", e); - println!("Session {session_id} receiver failed to replay - {e}"); + if e.is_expired() { + println!("Session {session_id} receiver expired."); + } else { + tracing::error!( + "An error {:?} occurred while replaying receiver session", + e + ); + println!("Session {session_id} receiver failed to replay - {e}"); + } Self::close_failed_session(&recv_persister, &session_id, "receiver"); } } @@ -350,8 +357,12 @@ impl AppTrait for App { )); } Err(e) => { - tracing::error!("An error {:?} occurred while replaying Sender session", e); - println!("Session {session_id} sender failed to replay - {e}"); + if e.is_expired() { + println!("Session {session_id} sender expired."); + } else { + tracing::error!("An error {:?} occurred while replaying Sender session", e); + println!("Session {session_id} sender failed to replay - {e}"); + } Self::close_failed_session(&sender_persister, &session_id, "sender"); } } diff --git a/payjoin-ffi/src/receive/error.rs b/payjoin-ffi/src/receive/error.rs index 60f25fd5f..542d39aa4 100644 --- a/payjoin-ffi/src/receive/error.rs +++ b/payjoin-ffi/src/receive/error.rs @@ -38,6 +38,23 @@ impl From for ReceiverError { } } +/// Error returned when a receiver request could not be created. +/// +/// Returned by `create_poll_request` and `create_post_request`. uniffi attaches +/// methods to Object types, so the expiry predicate is a method here rather than +/// a free function over the top-level `ReceiverError`. +#[derive(Debug, thiserror::Error, uniffi::Object)] +#[uniffi::export(Debug, Display)] +#[error(transparent)] +pub struct ReceiverCreateRequestError(#[from] receive::v2::CreateRequestError); + +#[uniffi::export] +impl ReceiverCreateRequestError { + /// Returns `true` if the request could not be created because the session + /// has expired. + pub fn is_expired(&self) -> bool { self.0.is_expired() } +} + /// Error that may occur during state machine transitions #[derive(Debug, thiserror::Error, uniffi::Error)] #[error(transparent)] @@ -174,6 +191,12 @@ impl From for JsonReply { #[error(transparent)] pub struct SessionError(#[from] receive::v2::SessionError); +#[uniffi::export] +impl SessionError { + /// Returns `true` if the session has expired. + pub fn is_expired(&self) -> bool { self.0.is_expired() } +} + /// Protocol error raised during output substitution. #[derive(Debug, thiserror::Error, uniffi::Object)] #[uniffi::export(Debug, Display)] @@ -203,7 +226,7 @@ impl From for OutputSubstitutionError { #[derive(Debug, thiserror::Error, uniffi::Object)] #[uniffi::export(Debug, Display)] #[error(transparent)] -pub struct SelectionError(#[from] receive::SelectionError); +pub struct CoinSelectionError(#[from] receive::CoinSelectionError); /// Error that may occur when input contribution fails. #[derive(Debug, thiserror::Error, uniffi::Object)] @@ -248,3 +271,54 @@ impl From for InputPairError { pub struct ReceiverReplayError( #[from] payjoin::error::ReplayError, ); + +#[uniffi::export] +impl ReceiverReplayError { + /// Returns `true` if the event log could not be replayed because the + /// session has expired. + pub fn is_expired(&self) -> bool { self.0.is_expired() } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn session_and_replay_errors_expose_is_expired() { + // uniffi Objects expose the core predicate to bindings. + let _: fn(&SessionError) -> bool = SessionError::is_expired; + let _: fn(&ReceiverReplayError) -> bool = ReceiverReplayError::is_expired; + } + + #[cfg(feature = "_test-utils")] + #[test] + fn receiver_create_request_error_is_expired() { + use std::str::FromStr; + use std::time::Duration; + + use payjoin::bitcoin::Address; + use payjoin::persist::InMemoryPersister; + use payjoin::receive::v2::{ReceiverBuilder, SessionEvent}; + use payjoin::OhttpKeys; + use payjoin_test_utils::{EXAMPLE_URL, KEM, KEY_ID, SYMMETRIC}; + + // Build a receiver whose session is already expired, then surface the + // expiry error through the dedicated create-request error. + let address = Address::from_str("tb1q6d3a2w975yny0asuvd9a67ner4nks58ff0q8g4") + .expect("valid address") + .assume_checked(); + let ohttp_keys = OhttpKeys( + ohttp::KeyConfig::new(KEY_ID, KEM, Vec::from(SYMMETRIC)).expect("valid keys"), + ); + let persister = InMemoryPersister::::default(); + let receiver = ReceiverBuilder::new(address, EXAMPLE_URL, ohttp_keys) + .expect("valid builder") + .with_expiration(Duration::from_secs(0)) + .build() + .save(&persister) + .expect("in-memory persister is infallible"); + let expired = + receiver.create_poll_request(EXAMPLE_URL).map(|_| ()).expect_err("session is expired"); + assert!(ReceiverCreateRequestError::from(expired).is_expired()); + } +} diff --git a/payjoin-ffi/src/receive/mod.rs b/payjoin-ffi/src/receive/mod.rs index a2cabd805..e560c1806 100644 --- a/payjoin-ffi/src/receive/mod.rs +++ b/payjoin-ffi/src/receive/mod.rs @@ -2,9 +2,9 @@ use std::str::FromStr; use std::sync::{Arc, RwLock}; pub use error::{ - AddressParseError, InputContributionError, InputPairError, JsonReply, OutputSubstitutionError, - ProtocolError, PsbtInputError, ReceiverBuilderError, ReceiverError, SelectionError, - SessionError, + AddressParseError, CoinSelectionError, InputContributionError, InputPairError, JsonReply, + OutputSubstitutionError, ProtocolError, PsbtInputError, ReceiverBuilderError, + ReceiverCreateRequestError, ReceiverError, SessionError, }; use payjoin::bitcoin::consensus::Decodable; use payjoin::bitcoin::psbt::Psbt; @@ -689,7 +689,7 @@ impl Initialized { pub fn create_poll_request( &self, ohttp_relay: String, - ) -> Result { + ) -> Result { self.0 .create_poll_request(ohttp_relay) .map(|(req, ctx)| RequestResponse { @@ -1069,7 +1069,7 @@ impl WantsInputs { pub fn try_preserving_privacy( &self, candidate_inputs: Vec>, - ) -> Result, SelectionError> { + ) -> Result, CoinSelectionError> { let candidate_inputs: Vec = candidate_inputs.into_iter().map(|pair| Arc::unwrap_or_clone(pair).into()).collect(); match self.0.clone().try_preserving_privacy(candidate_inputs) { @@ -1324,7 +1324,7 @@ impl PayjoinProposal { pub fn create_post_request( &self, ohttp_relay: String, - ) -> Result { + ) -> Result { self.0.clone().create_post_request(ohttp_relay).map_err(Into::into).map(|(req, ctx)| { RequestResponse { request: req.into(), client_response: Arc::new(ctx.into()) } }) diff --git a/payjoin-ffi/src/send/error.rs b/payjoin-ffi/src/send/error.rs index 20fa1171a..96fdec825 100644 --- a/payjoin-ffi/src/send/error.rs +++ b/payjoin-ffi/src/send/error.rs @@ -60,6 +60,13 @@ impl From for SenderInputError { #[error(transparent)] pub struct CreateRequestError(#[from] send::v2::CreateRequestError); +#[uniffi::export] +impl CreateRequestError { + /// Returns `true` if the request could not be created because the session + /// has expired. + pub fn is_expired(&self) -> bool { self.0.is_expired() } +} + /// Error returned for v2-specific payload decapsulation errors. #[derive(Debug, thiserror::Error, uniffi::Object)] #[uniffi::export(Debug, Display)] @@ -104,6 +111,40 @@ impl From for ResponseError { send::ResponseError::Validation(e) => ResponseError::Validation(Arc::new(e.into())), send::ResponseError::Unrecognized { error_code, message } => ResponseError::Unrecognized { error_code, msg: message }, + // `send::ResponseError` is non_exhaustive; surface any future + // variant as an unrecognized error rather than failing to build. + other => + ResponseError::Unrecognized { error_code: String::new(), msg: other.to_string() }, + } + } +} + +/// BIP-78 well-known error code, surfaced to bindings so senders can branch +/// on the code instead of parsing the Display string. +#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)] +pub enum ErrorCode { + /// The payjoin endpoint is not available for now. + Unavailable, + /// The receiver added some inputs but could not bump the fee. + NotEnoughMoney, + /// This version of payjoin is not supported. + VersionUnsupported, + /// The receiver rejected the original PSBT. + OriginalPsbtRejected, + /// A well-known code newer than this binding understands. + Unrecognized, +} + +impl From for ErrorCode { + fn from(value: send::ErrorCode) -> Self { + match value { + send::ErrorCode::Unavailable => ErrorCode::Unavailable, + send::ErrorCode::NotEnoughMoney => ErrorCode::NotEnoughMoney, + send::ErrorCode::VersionUnsupported => ErrorCode::VersionUnsupported, + send::ErrorCode::OriginalPsbtRejected => ErrorCode::OriginalPsbtRejected, + // `send::ErrorCode` is non_exhaustive; map codes this binding does + // not yet know to Unrecognized. + _ => ErrorCode::Unrecognized, } } } @@ -114,6 +155,13 @@ impl From for ResponseError { #[error(transparent)] pub struct WellKnownError(#[from] send::WellKnownError); +#[uniffi::export] +impl WellKnownError { + /// Return the BIP-78 well-known error code, letting senders branch on it + /// instead of parsing the Display string. + pub fn code(&self) -> ErrorCode { self.0.code().into() } +} + /// Error that may occur when the sender session event log is replayed #[derive(Debug, thiserror::Error, uniffi::Object)] #[uniffi::export(Debug, Display)] @@ -122,6 +170,13 @@ pub struct SenderReplayError( #[from] payjoin::error::ReplayError, ); +#[uniffi::export] +impl SenderReplayError { + /// Returns `true` if the event log could not be replayed because the + /// session has expired. + pub fn is_expired(&self) -> bool { self.0.is_expired() } +} + /// Error that may occur during state machine transitions #[derive(Debug, thiserror::Error, uniffi::Error)] #[error(transparent)] @@ -201,3 +256,32 @@ where SenderPersistedError::Unexpected } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn create_request_error_exposes_is_expired() { + // A non-expiry CreateRequestError delegates to the core predicate. + let err = CreateRequestError::from(send::v2::CreateRequestError::from( + payjoin::IntoUrlError::BadScheme, + )); + assert!(!err.is_expired()); + + // The accessor is also exposed on the sender replay error. + let _: fn(&SenderReplayError) -> bool = SenderReplayError::is_expired; + } + + #[test] + fn well_known_error_exposes_code() { + use payjoin::send::ErrorCode as Core; + assert_eq!(ErrorCode::from(Core::Unavailable), ErrorCode::Unavailable); + assert_eq!(ErrorCode::from(Core::NotEnoughMoney), ErrorCode::NotEnoughMoney); + assert_eq!(ErrorCode::from(Core::VersionUnsupported), ErrorCode::VersionUnsupported); + assert_eq!(ErrorCode::from(Core::OriginalPsbtRejected), ErrorCode::OriginalPsbtRejected); + + // The accessor is exposed on the binding's WellKnownError. + let _: fn(&WellKnownError) -> ErrorCode = WellKnownError::code; + } +} diff --git a/payjoin/src/core/error.rs b/payjoin/src/core/error.rs index 8c74f54e5..e8f546a05 100644 --- a/payjoin/src/core/error.rs +++ b/payjoin/src/core/error.rs @@ -69,6 +69,13 @@ impl From) -> Self { ReplayError(e) } } +#[cfg(feature = "v2")] +impl ReplayError { + /// Returns `true` if the event log could not be replayed because the + /// session has expired. + pub fn is_expired(&self) -> bool { matches!(self.0, InternalReplayError::Expired(_)) } +} + #[cfg(feature = "v2")] #[derive(Debug)] pub(crate) enum InternalReplayError { @@ -81,3 +88,18 @@ pub(crate) enum InternalReplayError { /// Application storage error PersistenceFailure(ImplementationError), } + +#[cfg(all(test, feature = "v2"))] +mod tests { + use super::*; + use crate::time::Time; + + #[test] + fn replay_error_is_expired() { + let expired: ReplayError<(), ()> = ReplayError(InternalReplayError::Expired(Time::now())); + assert!(expired.is_expired()); + + let other: ReplayError<(), ()> = ReplayError(InternalReplayError::NoEvents); + assert!(!other.is_expired()); + } +} diff --git a/payjoin/src/core/error_codes.rs b/payjoin/src/core/error_codes.rs index 2ef372ea1..b60c5375e 100644 --- a/payjoin/src/core/error_codes.rs +++ b/payjoin/src/core/error_codes.rs @@ -2,6 +2,7 @@ //! See: #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[non_exhaustive] pub enum ErrorCode { /// The payjoin endpoint is not available for now. Unavailable, diff --git a/payjoin/src/core/receive/common/mod.rs b/payjoin/src/core/receive/common/mod.rs index 9a93fb8f0..f1a066e3b 100644 --- a/payjoin/src/core/receive/common/mod.rs +++ b/payjoin/src/core/receive/common/mod.rs @@ -12,11 +12,11 @@ use bitcoin::{Amount, FeeRate, Script, TxIn, TxOut, Weight}; use serde::{Deserialize, Serialize}; use super::error::{ - InputContributionError, InternalInputContributionError, InternalOutputSubstitutionError, - InternalSelectionError, + InputContributionError, InternalCoinSelectionError, InternalInputContributionError, + InternalOutputSubstitutionError, }; use super::optional_parameters::Params; -use super::{InputPair, OutputSubstitutionError, SelectionError}; +use super::{CoinSelectionError, InputPair, OutputSubstitutionError}; use crate::output_substitution::OutputSubstitution; use crate::psbt::PsbtExt; use crate::receive::{InternalPayloadError, OriginalPayload, PsbtContext}; @@ -207,7 +207,7 @@ impl WantsInputs { pub fn try_preserving_privacy( &self, candidate_inputs: impl IntoIterator, - ) -> Result { + ) -> Result { let mut candidate_inputs = candidate_inputs.into_iter().peekable(); self.avoid_uih(&mut candidate_inputs) @@ -227,9 +227,9 @@ impl WantsInputs { pub(super) fn avoid_uih( &self, candidate_inputs: impl IntoIterator, - ) -> Result { + ) -> Result { if self.payjoin_psbt.outputs.len() != 2 { - return Err(InternalSelectionError::UnsupportedOutputLength.into()); + return Err(InternalCoinSelectionError::UnsupportedOutputLength.into()); } let min_out_sats = self @@ -263,15 +263,15 @@ impl WantsInputs { } // No suitable privacy preserving selection found - Err(InternalSelectionError::NotFound.into()) + Err(InternalCoinSelectionError::NotFound.into()) } /// Returns the first candidate input in the provided list or errors if the list is empty. fn select_first_candidate( &self, candidate_inputs: impl IntoIterator, - ) -> Result { - candidate_inputs.into_iter().next().ok_or(InternalSelectionError::Empty.into()) + ) -> Result { + candidate_inputs.into_iter().next().ok_or(InternalCoinSelectionError::Empty.into()) } /// Contributes the provided list of inputs to the transaction at random indices. If the total input @@ -522,7 +522,7 @@ mod tests { let result = wants_inputs.try_preserving_privacy(empty_candidate_inputs); assert_eq!( result.unwrap_err(), - SelectionError::from(InternalSelectionError::Empty), + CoinSelectionError::from(InternalCoinSelectionError::Empty), "try_preserving_privacy should fail with empty candidate inputs" ); } @@ -604,7 +604,7 @@ mod tests { let avoid_uih = payjoin.avoid_uih(input_iter); assert_eq!( avoid_uih.unwrap_err(), - SelectionError::from(InternalSelectionError::UnsupportedOutputLength), + CoinSelectionError::from(InternalCoinSelectionError::UnsupportedOutputLength), "Payjoin below minimum allowed outputs for avoid uih and should error" ); } diff --git a/payjoin/src/core/receive/error.rs b/payjoin/src/core/receive/error.rs index d7bd23e39..0a3649478 100644 --- a/payjoin/src/core/receive/error.rs +++ b/payjoin/src/core/receive/error.rs @@ -353,10 +353,10 @@ impl std::error::Error for OutputSubstitutionError { /// This is currently opaque type because we aren't sure which variants will stay. /// You can only display it. #[derive(Debug, PartialEq, Eq)] -pub struct SelectionError(InternalSelectionError); +pub struct CoinSelectionError(InternalCoinSelectionError); #[derive(Debug, PartialEq, Eq)] -pub(crate) enum InternalSelectionError { +pub(crate) enum InternalCoinSelectionError { /// No candidates available for selection Empty, /// Current privacy selection implementation only supports 2-output transactions @@ -365,23 +365,23 @@ pub(crate) enum InternalSelectionError { NotFound, } -impl fmt::Display for SelectionError { +impl fmt::Display for CoinSelectionError { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { match &self.0 { - InternalSelectionError::Empty => write!(f, "No candidates available for selection"), - InternalSelectionError::UnsupportedOutputLength => write!( + InternalCoinSelectionError::Empty => write!(f, "No candidates available for selection"), + InternalCoinSelectionError::UnsupportedOutputLength => write!( f, "Current privacy selection implementation only supports 2-output transactions" ), - InternalSelectionError::NotFound => + InternalCoinSelectionError::NotFound => write!(f, "No selection candidates improve privacy"), } } } -impl error::Error for SelectionError { +impl error::Error for CoinSelectionError { fn source(&self) -> Option<&(dyn error::Error + 'static)> { - use InternalSelectionError::*; + use InternalCoinSelectionError::*; match &self.0 { Empty => None, @@ -390,8 +390,8 @@ impl error::Error for SelectionError { } } } -impl From for SelectionError { - fn from(value: InternalSelectionError) -> Self { SelectionError(value) } +impl From for CoinSelectionError { + fn from(value: InternalCoinSelectionError) -> Self { CoinSelectionError(value) } } /// Error that may occur when input contribution fails. diff --git a/payjoin/src/core/receive/mod.rs b/payjoin/src/core/receive/mod.rs index bac207c53..259e91422 100644 --- a/payjoin/src/core/receive/mod.rs +++ b/payjoin/src/core/receive/mod.rs @@ -18,8 +18,8 @@ use bitcoin::{ }; pub(crate) use error::InternalPayloadError; pub use error::{ - Error, InputContributionError, JsonReply, OutputSubstitutionError, PayloadError, ProtocolError, - SelectionError, + CoinSelectionError, Error, InputContributionError, JsonReply, OutputSubstitutionError, + PayloadError, ProtocolError, }; use optional_parameters::Params; use serde::{Deserialize, Serialize}; diff --git a/payjoin/src/core/receive/v2/error.rs b/payjoin/src/core/receive/v2/error.rs index ec9062c51..e68ee97e2 100644 --- a/payjoin/src/core/receive/v2/error.rs +++ b/payjoin/src/core/receive/v2/error.rs @@ -3,8 +3,6 @@ use std::error; use crate::hpke::HpkeError; use crate::ohttp::{DirectoryResponseError, OhttpEncapsulationError}; -use crate::receive::error::Error; -use crate::receive::ProtocolError; use crate::time::Time; /// Error that may occur during a v2 session typestate change @@ -18,8 +16,13 @@ impl From for SessionError { fn from(value: InternalSessionError) -> Self { SessionError(value) } } -impl From for Error { - fn from(e: InternalSessionError) -> Self { Error::Protocol(ProtocolError::V2(e.into())) } +impl SessionError { + /// Returns `true` if the session has expired. + pub fn is_expired(&self) -> bool { matches!(self.0, InternalSessionError::Expired(_)) } +} + +impl From for SessionError { + fn from(e: crate::into_url::Error) -> Self { SessionError(InternalSessionError::ParseUrl(e)) } } #[derive(Debug)] @@ -36,16 +39,6 @@ pub(crate) enum InternalSessionError { DirectoryResponse(DirectoryResponseError), } -impl From for Error { - fn from(e: OhttpEncapsulationError) -> Self { - InternalSessionError::OhttpEncapsulation(e).into() - } -} - -impl From for Error { - fn from(e: HpkeError) -> Self { InternalSessionError::Hpke(e).into() } -} - impl fmt::Display for SessionError { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { use InternalSessionError::*; @@ -73,3 +66,100 @@ impl error::Error for SessionError { } } } + +/// Error returned when a receiver request could not be created. +/// +/// Mirrors [`crate::send::v2::CreateRequestError`]: a narrow, opaque error for +/// the `create_poll_request` and `create_post_request` constructors. It carries +/// only their real failure modes, so callers can branch on expiry without +/// matching the broad [`crate::receive::Error`] or its Display string. +#[derive(Debug)] +pub struct CreateRequestError(InternalCreateRequestError); + +#[derive(Debug)] +pub(crate) enum InternalCreateRequestError { + /// Url parsing failed + Url(crate::into_url::Error), + /// Hybrid Public Key Encryption failed + Hpke(HpkeError), + /// OHTTP Encapsulation failed + OhttpEncapsulation(OhttpEncapsulationError), + /// The session has expired + Expired(Time), +} + +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}"), + Expired(_expiration) => write!(f, "session expired"), + } + } +} + +impl error::Error for CreateRequestError { + fn source(&self) -> Option<&(dyn error::Error + 'static)> { + use InternalCreateRequestError::*; + + match &self.0 { + Url(e) => Some(e), + Hpke(e) => Some(e), + OhttpEncapsulation(e) => Some(e), + Expired(_) => None, + } + } +} + +impl CreateRequestError { + /// Returns `true` if the request could not be created because the session + /// has expired. + pub fn is_expired(&self) -> bool { matches!(self.0, InternalCreateRequestError::Expired(_)) } +} + +impl From for CreateRequestError { + fn from(value: InternalCreateRequestError) -> Self { CreateRequestError(value) } +} + +impl From for CreateRequestError { + fn from(e: crate::into_url::Error) -> Self { + CreateRequestError(InternalCreateRequestError::Url(e)) + } +} + +impl From for CreateRequestError { + fn from(e: HpkeError) -> Self { CreateRequestError(InternalCreateRequestError::Hpke(e)) } +} + +impl From for CreateRequestError { + fn from(e: OhttpEncapsulationError) -> Self { + CreateRequestError(InternalCreateRequestError::OhttpEncapsulation(e)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn session_error_is_expired() { + let expired = SessionError(InternalSessionError::Expired(Time::now())); + assert!(expired.is_expired()); + + let other = SessionError(InternalSessionError::ParseUrl(crate::into_url::Error::BadScheme)); + assert!(!other.is_expired()); + } + + #[test] + fn create_request_error_is_expired() { + let expired = CreateRequestError(InternalCreateRequestError::Expired(Time::now())); + assert!(expired.is_expired()); + + let other = + CreateRequestError(InternalCreateRequestError::Url(crate::into_url::Error::BadScheme)); + assert!(!other.is_expired()); + } +} diff --git a/payjoin/src/core/receive/v2/mod.rs b/payjoin/src/core/receive/v2/mod.rs index 978802369..79e3ef47d 100644 --- a/payjoin/src/core/receive/v2/mod.rs +++ b/payjoin/src/core/receive/v2/mod.rs @@ -31,8 +31,8 @@ use std::time::Duration; use bitcoin::hashes::{sha256, Hash}; use bitcoin::psbt::Psbt; use bitcoin::{Address, Amount, FeeRate, OutPoint, Script, TxOut, Txid}; -pub(crate) use error::InternalSessionError; -pub use error::SessionError; +pub use error::{CreateRequestError, SessionError}; +pub(crate) use error::{InternalCreateRequestError, InternalSessionError}; use serde::de::Deserializer; use serde::{Deserialize, Serialize}; pub use session::{ @@ -45,7 +45,8 @@ use web_time::Duration; use self::sealed::FallbackTx; use super::error::{Error, InputContributionError}; use super::{ - common, InternalPayloadError, JsonReply, OutputSubstitutionError, ProtocolError, SelectionError, + common, CoinSelectionError, InternalPayloadError, JsonReply, OutputSubstitutionError, + ProtocolError, }; use crate::core::Url; use crate::error::{InternalReplayError, ReplayError}; @@ -85,17 +86,14 @@ pub struct SessionContext { } impl SessionContext { - fn full_relay_url(&self, ohttp_relay: impl IntoUrl) -> Result { - let relay_base = ohttp_relay.into_url().map_err(InternalSessionError::ParseUrl)?; + fn full_relay_url(&self, ohttp_relay: impl IntoUrl) -> Result { + let relay_base = ohttp_relay.into_url()?; // Only reveal scheme and authority to the relay - let directory_base = - self.directory.join("/").map_err(|e| InternalSessionError::ParseUrl(e.into()))?; + let directory_base = self.directory.join("/")?; // Append that information as a path to the relay URL - relay_base - .join(&format!("/{directory_base}")) - .map_err(|e| InternalSessionError::ParseUrl(e.into())) + Ok(relay_base.join(&format!("/{directory_base}"))?) } /// The mailbox ID where the receiver expects the sender's Original PSBT. @@ -550,12 +548,11 @@ impl Receiver { pub fn create_poll_request( &self, ohttp_relay: impl IntoUrl, - ) -> Result<(Request, ohttp::ClientResponse), Error> { + ) -> Result<(Request, ohttp::ClientResponse), CreateRequestError> { if self.session_context.expiration.elapsed() { - return Err(InternalSessionError::Expired(self.session_context.expiration).into()); + return Err(InternalCreateRequestError::Expired(self.session_context.expiration).into()); } - let (body, ohttp_ctx) = - self.fallback_req_body().map_err(InternalSessionError::OhttpEncapsulation)?; + let (body, ohttp_ctx) = self.fallback_req_body()?; let req = Request::new_v2(&self.session_context.full_relay_url(ohttp_relay)?, &body); Ok((req, ohttp_ctx)) } @@ -1097,7 +1094,7 @@ impl Receiver { pub fn try_preserving_privacy( &self, candidate_inputs: impl IntoIterator, - ) -> Result { + ) -> Result { self.inner.try_preserving_privacy(candidate_inputs) } @@ -1288,7 +1285,11 @@ impl Receiver { pub fn create_post_request( &self, ohttp_relay: impl IntoUrl, - ) -> Result<(Request, ohttp::ClientResponse), Error> { + ) -> Result<(Request, ohttp::ClientResponse), CreateRequestError> { + if self.session_context.expiration.elapsed() { + return Err(InternalCreateRequestError::Expired(self.session_context.expiration).into()); + } + let target_resource: Url; let body: Vec; let method: &str; @@ -2134,6 +2135,26 @@ pub mod test { Ok(()) } + #[test] + fn test_create_post_request_expiration() -> Result<(), BoxError> { + let now = crate::time::Time::now(); + let context = SessionContext { expiration: now, ..SHARED_CONTEXT.clone() }; + let psbt_context = PsbtContext { + original_psbt: PARSED_ORIGINAL_PSBT.clone(), + payjoin_psbt: PARSED_PAYJOIN_PROPOSAL.clone(), + }; + let receiver = + Receiver { state: PayjoinProposal { psbt_context }, session_context: context }; + + let expiration = receiver.create_post_request(EXAMPLE_URL); + + match expiration { + Err(error) => assert!(error.is_expired()), + Ok(_) => panic!("Expected session expiration error, got success"), + } + Ok(()) + } + #[test] fn default_max_fee_rate() { let persister = InMemoryPersister::default(); diff --git a/payjoin/src/core/send/error.rs b/payjoin/src/core/send/error.rs index 8c8dc4bbe..6cae042b3 100644 --- a/payjoin/src/core/send/error.rs +++ b/payjoin/src/core/send/error.rs @@ -251,6 +251,7 @@ impl std::error::Error for InternalProposalError { } /// Represent an error returned by Payjoin receiver. +#[non_exhaustive] pub enum ResponseError { /// `WellKnown` Errors are defined in the [`BIP78::ReceiverWellKnownError`] spec. /// @@ -390,6 +391,9 @@ impl From for ResponseError { } impl WellKnownError { + /// Return the well-known BIP-78 error code. + pub fn code(&self) -> ErrorCode { self.code } + /// Create a new well-known error with the given code and message. pub(crate) fn new(code: ErrorCode, message: String) -> Self { Self { code, message, supported_versions: None } @@ -411,7 +415,7 @@ mod tests { let known_str_error = r#"{"errorCode":"version-unsupported", "message":"custom message here", "supported": [1, 2]}"#; match ResponseError::parse(known_str_error) { ResponseError::WellKnown(e) => { - assert_eq!(e.code, ErrorCode::VersionUnsupported); + assert_eq!(e.code(), ErrorCode::VersionUnsupported); assert_eq!(e.message, "custom message here"); assert_eq!( e.to_string(), @@ -420,6 +424,12 @@ mod tests { } _ => panic!("Expected WellKnown error"), }; + let not_enough_money_error = + r#"{"errorCode":"not-enough-money", "message":"not enough money"}"#; + match ResponseError::parse(not_enough_money_error) { + ResponseError::WellKnown(e) => assert_eq!(e.code(), ErrorCode::NotEnoughMoney), + _ => panic!("Expected WellKnown error"), + }; let unrecognized_error = r#"{"errorCode":"random", "message":"random"}"#; assert!(matches!( ResponseError::parse(unrecognized_error), diff --git a/payjoin/src/core/send/mod.rs b/payjoin/src/core/send/mod.rs index 5aa3521d8..bd1a5bee7 100644 --- a/payjoin/src/core/send/mod.rs +++ b/payjoin/src/core/send/mod.rs @@ -21,6 +21,7 @@ use bitcoin::{Amount, FeeRate, Script, ScriptBuf, TxOut, Weight}; pub use error::{BuildSenderError, ResponseError, ValidationError, WellKnownError}; pub(crate) use error::{InternalBuildSenderError, InternalProposalError, InternalValidationError}; +pub use crate::core::error_codes::ErrorCode; use crate::core::Url; use crate::output_substitution::OutputSubstitution; use crate::psbt::{AddressTypeError, PsbtExt, NON_WITNESS_INPUT_WEIGHT}; diff --git a/payjoin/src/core/send/v2/error.rs b/payjoin/src/core/send/v2/error.rs index 4ef7bee06..b6977c252 100644 --- a/payjoin/src/core/send/v2/error.rs +++ b/payjoin/src/core/send/v2/error.rs @@ -49,6 +49,12 @@ impl From for CreateRequestError { fn from(value: InternalCreateRequestError) -> Self { CreateRequestError(value) } } +impl CreateRequestError { + /// Returns `true` if the request could not be created because the session + /// has expired. + pub fn is_expired(&self) -> bool { matches!(self.0, InternalCreateRequestError::Expired(_)) } +} + impl From for CreateRequestError { fn from(value: crate::into_url::Error) -> Self { CreateRequestError(InternalCreateRequestError::Url(value)) @@ -98,3 +104,18 @@ impl From for super::ResponseError { super::InternalValidationError::V2Decapsulation(value.into()).into() } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn create_request_error_is_expired() { + let expired = CreateRequestError(InternalCreateRequestError::Expired(Time::now())); + assert!(expired.is_expired()); + + let other = + CreateRequestError(InternalCreateRequestError::Url(crate::into_url::Error::BadScheme)); + assert!(!other.is_expired()); + } +} diff --git a/payjoin/src/core/send/v2/mod.rs b/payjoin/src/core/send/v2/mod.rs index e7aaa4fde..db6c0a801 100644 --- a/payjoin/src/core/send/v2/mod.rs +++ b/payjoin/src/core/send/v2/mod.rs @@ -482,6 +482,13 @@ impl Sender { &self, ohttp_relay: impl IntoUrl, ) -> Result<(Request, ohttp::ClientResponse), CreateRequestError> { + if self.session_context.pj_param.expiration().elapsed() { + return Err(InternalCreateRequestError::Expired( + self.session_context.pj_param.expiration(), + ) + .into()); + } + // TODO unify with receiver's fn short_id_from_pubkey let hash = sha256::Hash::hash( &HpkeKeyPair::from_secret_key(&self.session_context.reply_key) @@ -698,6 +705,23 @@ mod test { Ok(()) } + #[test] + fn test_poll_request_fails_when_expired() -> Result<(), BoxError> { + let expiration = Time::try_from(SystemTime::now() - Duration::from_secs(1)) + .expect("time in the past should be representable"); + let sender = create_sender_context(expiration)?; + let sender = + Sender { state: PollingForProposal, session_context: sender.session_context.clone() }; + + let result = sender.create_poll_request(EXAMPLE_URL); + + match result { + Ok(_) => panic!("Expected session expiration error, got success"), + Err(error) => assert!(error.is_expired()), + } + Ok(()) + } + #[test] fn test_v2_sender_builder() { let address = Address::from_str("2N47mmrWXsNBvQR6k78hWJoTji57zXwNcU7")