diff --git a/Cargo-minimal.lock b/Cargo-minimal.lock index 31a7c7d9e..1ea980d71 100644 --- a/Cargo-minimal.lock +++ b/Cargo-minimal.lock @@ -4778,7 +4778,7 @@ dependencies = [ [[package]] name = "uniffi-dart" version = "0.1.0+v0.30.0" -source = "git+https://github.com/Uniffi-Dart/uniffi-dart.git?tag=v0.1.0%2Bv0.30.0#e3ed67f780257a5a7fae23231e13d84f931208e0" +source = "git+https://github.com/Uniffi-Dart/uniffi-dart.git?rev=26739b93ca0d3e95dee8c8362d5d971cc931c6f3#26739b93ca0d3e95dee8c8362d5d971cc931c6f3" dependencies = [ "anyhow", "camino", @@ -4849,7 +4849,7 @@ dependencies = [ [[package]] name = "uniffi_dart_macro" version = "0.1.0+v0.30.0" -source = "git+https://github.com/Uniffi-Dart/uniffi-dart.git?tag=v0.1.0%2Bv0.30.0#e3ed67f780257a5a7fae23231e13d84f931208e0" +source = "git+https://github.com/Uniffi-Dart/uniffi-dart.git?rev=26739b93ca0d3e95dee8c8362d5d971cc931c6f3#26739b93ca0d3e95dee8c8362d5d971cc931c6f3" dependencies = [ "futures", "proc-macro2", diff --git a/Cargo-recent.lock b/Cargo-recent.lock index 7c856b8d1..ace81469d 100644 --- a/Cargo-recent.lock +++ b/Cargo-recent.lock @@ -4697,7 +4697,7 @@ dependencies = [ [[package]] name = "uniffi-dart" version = "0.1.0+v0.30.0" -source = "git+https://github.com/Uniffi-Dart/uniffi-dart.git?tag=v0.1.0%2Bv0.30.0#e3ed67f780257a5a7fae23231e13d84f931208e0" +source = "git+https://github.com/Uniffi-Dart/uniffi-dart.git?rev=26739b93ca0d3e95dee8c8362d5d971cc931c6f3#26739b93ca0d3e95dee8c8362d5d971cc931c6f3" dependencies = [ "anyhow", "camino", @@ -4768,7 +4768,7 @@ dependencies = [ [[package]] name = "uniffi_dart_macro" version = "0.1.0+v0.30.0" -source = "git+https://github.com/Uniffi-Dart/uniffi-dart.git?tag=v0.1.0%2Bv0.30.0#e3ed67f780257a5a7fae23231e13d84f931208e0" +source = "git+https://github.com/Uniffi-Dart/uniffi-dart.git?rev=26739b93ca0d3e95dee8c8362d5d971cc931c6f3#26739b93ca0d3e95dee8c8362d5d971cc931c6f3" dependencies = [ "futures", "proc-macro2", diff --git a/payjoin-ffi/Cargo.toml b/payjoin-ffi/Cargo.toml index 42d4c415c..d866a6034 100644 --- a/payjoin-ffi/Cargo.toml +++ b/payjoin-ffi/Cargo.toml @@ -36,7 +36,7 @@ thiserror = "2.0.14" tokio = { version = "1.47.1", features = ["full"], optional = true } uniffi = { version = "0.30.0", features = ["cli"] } uniffi-bindgen-cs = { git = "https://github.com/chavic/uniffi-bindgen-cs.git", rev = "878a3d269eacce64beadcd336ade0b7c8da09824", optional = true } -uniffi-dart = { git = "https://github.com/Uniffi-Dart/uniffi-dart.git", tag = "v0.1.0+v0.30.0", optional = true } +uniffi-dart = { git = "https://github.com/Uniffi-Dart/uniffi-dart.git", rev = "26739b93ca0d3e95dee8c8362d5d971cc931c6f3", optional = true } url = "2.5.4" # getrandom is ignored here because it's required by the wasm_js feature diff --git a/payjoin-ffi/src/error.rs b/payjoin-ffi/src/error.rs index fca35a1fe..4d51b89ff 100644 --- a/payjoin-ffi/src/error.rs +++ b/payjoin-ffi/src/error.rs @@ -17,6 +17,20 @@ impl From for payjoin::ImplementationError { fn from(value: ImplementationError) -> Self { value.0 } } +#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)] +pub enum ReplayErrorKind { + NoEvents, + InvalidEvent, + Expired, + PersistenceFailure, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)] +pub enum ReplayInvalidEventKind { + InitialEvent, + SessionTransition, +} + #[derive(Debug, thiserror::Error, uniffi::Object)] #[error("Error de/serializing JSON object: {0}")] pub struct SerdeJsonError(#[from] serde_json::Error); diff --git a/payjoin-ffi/src/receive/error.rs b/payjoin-ffi/src/receive/error.rs index 2ceb53b97..8a838cfd6 100644 --- a/payjoin-ffi/src/receive/error.rs +++ b/payjoin-ffi/src/receive/error.rs @@ -1,8 +1,14 @@ use std::sync::Arc; +use payjoin::error::ReplayErrorVariant as CoreReplayErrorVariant; +use payjoin::persist::PersistedErrorVariant; use payjoin::receive; -use crate::error::{FfiValidationError, ImplementationError}; +use crate::error::{ + FfiValidationError, ImplementationError, ReplayErrorKind as FfiReplayErrorKind, + ReplayInvalidEventKind as FfiReplayInvalidEventKind, +}; +use crate::receive::HasReplyableError; use crate::uri::error::IntoUrlError; /// The top-level error type for the payjoin receiver @@ -40,20 +46,36 @@ impl From for ReceiverError { /// Error that may occur during state machine transitions #[derive(Debug, thiserror::Error, uniffi::Error)] -#[error(transparent)] pub enum ReceiverPersistedError { - /// rust-payjoin receiver error - #[error(transparent)] - Receiver(ReceiverError), - /// Storage error that could occur at application storage layer + /// Storage failure while persisting session state. #[error(transparent)] Storage(Arc), + /// Retry the same receiver transition from the current session state. + #[error("Transient receiver error: {error}")] + Transient { error: ReceiverError }, + /// The receiver session terminated and should not be resumed. + #[error("Fatal receiver error: {error}")] + Fatal { error: ReceiverError }, + /// The receiver transitioned into a replyable error state. + /// + /// Continue the protocol with the returned `state` to reply to the sender. + #[error("Fatal receiver error with state: {error}")] + FatalWithState { error: ReceiverError, state: Arc }, + /// Unexpected error shape that should not occur for receiver transitions. + #[error("An unexpected error occurred")] + Unexpected, } impl From for ReceiverPersistedError { fn from(value: ImplementationError) -> Self { ReceiverPersistedError::Storage(Arc::new(value)) } } +impl From for ReceiverPersistedError { + fn from(value: crate::error::ForeignError) -> Self { + ReceiverPersistedError::from(ImplementationError::new(value)) + } +} + macro_rules! impl_persisted_error_from { ( $api_error_ty:ty, @@ -64,16 +86,16 @@ macro_rules! impl_persisted_error_from { S: std::error::Error + Send + Sync + 'static, { fn from(err: payjoin::persist::PersistedError<$api_error_ty, S>) -> Self { - if err.storage_error_ref().is_some() { - if let Some(storage_err) = err.storage_error() { - return ReceiverPersistedError::from(ImplementationError::new(storage_err)); - } - return ReceiverPersistedError::Receiver(ReceiverError::Unexpected); - } - if let Some(api_err) = err.api_error() { - return ReceiverPersistedError::Receiver($receiver_arm(api_err)); + match err.into_variant() { + PersistedErrorVariant::Storage(storage_err) => + ReceiverPersistedError::from(ImplementationError::new(storage_err)), + PersistedErrorVariant::Transient(api_err) => + ReceiverPersistedError::Transient { error: $receiver_arm(api_err) }, + PersistedErrorVariant::Fatal(api_err) => + ReceiverPersistedError::Fatal { error: $receiver_arm(api_err) }, + PersistedErrorVariant::FatalWithState(_, _) => + ReceiverPersistedError::Unexpected, } - ReceiverPersistedError::Receiver(ReceiverError::Unexpected) } } }; @@ -89,6 +111,47 @@ impl_persisted_error_from!(payjoin::IntoUrlError, |api_err: payjoin::IntoUrlErro ReceiverError::IntoUrl(Arc::new(api_err.into())) }); +impl_persisted_error_from!( + payjoin::ImplementationError, + |api_err: payjoin::ImplementationError| { + ReceiverError::Implementation(Arc::new(api_err.into())) + } +); + +impl + From< + payjoin::persist::PersistedError< + receive::Error, + S, + payjoin::receive::v2::Receiver, + >, + > for ReceiverPersistedError +where + S: std::error::Error + Send + Sync + 'static, +{ + fn from( + err: payjoin::persist::PersistedError< + receive::Error, + S, + payjoin::receive::v2::Receiver, + >, + ) -> Self { + match err.into_variant() { + PersistedErrorVariant::Storage(storage_err) => + ReceiverPersistedError::from(ImplementationError::new(storage_err)), + PersistedErrorVariant::Transient(api_err) => + ReceiverPersistedError::Transient { error: api_err.into() }, + PersistedErrorVariant::Fatal(api_err) => + ReceiverPersistedError::Fatal { error: api_err.into() }, + PersistedErrorVariant::FatalWithState(api_err, state) => + ReceiverPersistedError::FatalWithState { + error: api_err.into(), + state: Arc::new(state.into()), + }, + } + } +} + /// Error that may occur when building a receiver session. #[derive(Debug, thiserror::Error, uniffi::Error)] #[non_exhaustive] @@ -231,9 +294,233 @@ impl From for InputPairError { fn from(value: FfiValidationError) -> Self { InputPairError::FfiValidation(value) } } -/// Error that may occur when a receiver event log is replayed +/// Error that may occur when a receiver event log is replayed. #[derive(Debug, thiserror::Error, uniffi::Object)] -#[error(transparent)] -pub struct ReceiverReplayError( - #[from] payjoin::error::ReplayError, -); +#[error("{message}")] +pub struct ReceiverReplayError { + kind: FfiReplayErrorKind, + message: String, + invalid_event_kind: Option, + expired_at_unix_seconds: Option, + persistence_failure: Option>, +} + +impl From> + for ReceiverReplayError +{ + fn from( + value: payjoin::error::ReplayError, + ) -> Self { + let message = value.to_string(); + match value.into_variant() { + CoreReplayErrorVariant::NoEvents => Self { + kind: FfiReplayErrorKind::NoEvents, + message, + invalid_event_kind: None, + expired_at_unix_seconds: None, + persistence_failure: None, + }, + CoreReplayErrorVariant::InvalidFirstEvent => Self { + kind: FfiReplayErrorKind::InvalidEvent, + message, + invalid_event_kind: Some(FfiReplayInvalidEventKind::InitialEvent), + expired_at_unix_seconds: None, + persistence_failure: None, + }, + CoreReplayErrorVariant::InvalidEventForState => Self { + kind: FfiReplayErrorKind::InvalidEvent, + message, + invalid_event_kind: Some(FfiReplayInvalidEventKind::SessionTransition), + expired_at_unix_seconds: None, + persistence_failure: None, + }, + CoreReplayErrorVariant::Expired { expired_at_unix_seconds } => Self { + kind: FfiReplayErrorKind::Expired, + message, + invalid_event_kind: None, + expired_at_unix_seconds: Some(expired_at_unix_seconds), + persistence_failure: None, + }, + CoreReplayErrorVariant::PersistenceFailure(error) => Self { + kind: FfiReplayErrorKind::PersistenceFailure, + message, + invalid_event_kind: None, + expired_at_unix_seconds: None, + persistence_failure: Some(Arc::new(error.into())), + }, + } + } +} + +#[uniffi::export] +impl ReceiverReplayError { + pub fn kind(&self) -> FfiReplayErrorKind { self.kind } + + pub fn message(&self) -> String { self.message.clone() } + + pub fn invalid_event_kind(&self) -> Option { + self.invalid_event_kind + } + + pub fn expired_at_unix_seconds(&self) -> Option { self.expired_at_unix_seconds } + + pub fn persistence_failure(&self) -> Option> { + self.persistence_failure.clone() + } +} + +#[cfg(all(test, feature = "_test-utils"))] +mod tests { + use std::sync::{Arc, Mutex}; + use std::time::Duration; + + use payjoin_test_utils::TestServices; + use tokio::time::sleep; + + use super::ReceiverPersistedError; + use crate::error::{ForeignError, ReplayErrorKind}; + use crate::receive::{ + InitializedTransitionOutcome, JsonReceiverSessionPersister, ReceiverBuilder, + }; + use crate::send::{JsonSenderSessionPersister, SenderBuilder}; + + #[derive(Default)] + struct InMemoryReceiverPersister { + events: Mutex>, + } + + impl JsonReceiverSessionPersister for InMemoryReceiverPersister { + fn save(&self, event: String) -> Result<(), ForeignError> { + self.events.lock().expect("lock").push(event); + Ok(()) + } + + fn load(&self) -> Result, ForeignError> { + Ok(self.events.lock().expect("lock").clone()) + } + + fn close(&self) -> Result<(), ForeignError> { Ok(()) } + } + + #[derive(Default)] + struct InMemorySenderPersister { + events: Mutex>, + } + + impl JsonSenderSessionPersister for InMemorySenderPersister { + fn save(&self, event: String) -> Result<(), ForeignError> { + self.events.lock().expect("lock").push(event); + Ok(()) + } + + fn load(&self) -> Result, ForeignError> { + Ok(self.events.lock().expect("lock").clone()) + } + + fn close(&self) -> Result<(), ForeignError> { Ok(()) } + } + + #[derive(Default)] + struct EmptyReceiverPersister; + + impl JsonReceiverSessionPersister for EmptyReceiverPersister { + fn save(&self, _: String) -> Result<(), ForeignError> { Ok(()) } + + fn load(&self) -> Result, ForeignError> { Ok(Vec::new()) } + + fn close(&self) -> Result<(), ForeignError> { Ok(()) } + } + + struct RejectBroadcast; + + impl crate::receive::CanBroadcast for RejectBroadcast { + fn callback(&self, _: Vec) -> Result { Ok(false) } + } + + async fn post_request(services: &TestServices, request: crate::Request) -> Vec { + let response = services + .http_agent() + .post(request.url) + .header("Content-Type", request.content_type) + .body(request.body) + .send() + .await + .expect("request should succeed"); + response.bytes().await.expect("response bytes").to_vec() + } + + #[tokio::test] + async fn test_receiver_persisted_error_preserves_fatal_with_state() { + let services = TestServices::initialize().await.expect("services initialize"); + services.wait_for_services_ready().await.expect("services ready"); + + let receiver_persister = Arc::new(InMemoryReceiverPersister::default()); + let initialized = ReceiverBuilder::new( + "2N47mmrWXsNBvQR6k78hWJoTji57zXwNcU7".to_string(), + services.directory_url(), + Arc::new(services.fetch_ohttp_keys().await.expect("fetch ohttp keys").into()), + ) + .expect("receiver builder") + .build() + .save(receiver_persister.clone()) + .expect("save initialized receiver"); + + let sender_persister = Arc::new(InMemorySenderPersister::default()); + let with_reply_key = + SenderBuilder::new(crate::test_utils::original_psbt(), Arc::new(initialized.pj_uri())) + .expect("sender builder") + .build_recommended(1000) + .expect("build sender") + .save(sender_persister) + .expect("save sender"); + + let sender_request = + with_reply_key.create_v2_post_request(services.ohttp_relay_url()).unwrap(); + let _ = post_request(&services, sender_request.request).await; + + let mut initialized = initialized; + let unchecked = loop { + let poll = initialized.create_poll_request(services.ohttp_relay_url()).unwrap(); + let response = post_request(&services, poll.request).await; + let outcome = initialized + .process_response(&response, poll.client_response.as_ref()) + .save(receiver_persister.clone()) + .expect("persist initialized transition"); + match outcome { + InitializedTransitionOutcome::Progress { inner } => break inner, + InitializedTransitionOutcome::Stasis { inner } => { + initialized = Arc::unwrap_or_clone(inner); + sleep(Duration::from_millis(20)).await; + } + } + }; + + let error = unchecked + .check_broadcast_suitability(None, Arc::new(RejectBroadcast)) + .expect("validation inputs") + .save(receiver_persister); + let error = match error { + Ok(_) => panic!("non-broadcastable original should produce replyable error state"), + Err(error) => error, + }; + + match error { + ReceiverPersistedError::FatalWithState { state, .. } => { + state.create_error_request(services.ohttp_relay_url()).expect("state preserved"); + } + other => panic!("unexpected receiver persisted error: {other:?}"), + } + } + + #[test] + fn test_receiver_replay_error_exposes_no_events_kind() { + let error = + match crate::receive::replay_receiver_event_log(Arc::new(EmptyReceiverPersister)) { + Ok(_) => panic!("empty event log should fail"), + Err(error) => error, + }; + assert_eq!(error.kind(), ReplayErrorKind::NoEvents); + assert!(error.persistence_failure().is_none()); + assert!(error.expired_at_unix_seconds().is_none()); + } +} diff --git a/payjoin-ffi/src/receive/mod.rs b/payjoin-ffi/src/receive/mod.rs index 3625362ee..4ab9c642c 100644 --- a/payjoin-ffi/src/receive/mod.rs +++ b/payjoin-ffi/src/receive/mod.rs @@ -38,9 +38,7 @@ macro_rules! impl_save_for_transition { let value = inner.take().expect("Already saved or moved"); - let res = value - .save(&adapter) - .map_err(|e| ReceiverPersistedError::from(ImplementationError::new(e)))?; + let res = value.save(&adapter).map_err(ReceiverPersistedError::from)?; Ok(res.into()) } @@ -55,10 +53,7 @@ macro_rules! impl_save_for_transition { inner.take().expect("Already saved or moved") }; - let res = value - .save_async(&adapter) - .await - .map_err(|e| ReceiverPersistedError::from(ImplementationError::new(e)))?; + let res = value.save_async(&adapter).await.map_err(ReceiverPersistedError::from)?; Ok(res.into()) } } @@ -1186,7 +1181,7 @@ impl PayjoinProposal { } } -#[derive(Clone, uniffi::Object)] +#[derive(Debug, Clone, uniffi::Object)] pub struct HasReplyableError( pub payjoin::receive::v2::Receiver, ); diff --git a/payjoin-ffi/src/send/error.rs b/payjoin-ffi/src/send/error.rs index ed5438cde..68d54c85c 100644 --- a/payjoin-ffi/src/send/error.rs +++ b/payjoin-ffi/src/send/error.rs @@ -1,9 +1,14 @@ use std::sync::Arc; use payjoin::bitcoin::psbt::PsbtParseError as CorePsbtParseError; +use payjoin::error::ReplayErrorVariant as CoreReplayErrorVariant; +use payjoin::persist::PersistedErrorVariant; use payjoin::send; -use crate::error::{FfiValidationError, ImplementationError}; +use crate::error::{ + FfiValidationError, ImplementationError, ReplayErrorKind as FfiReplayErrorKind, + ReplayInvalidEventKind as FfiReplayInvalidEventKind, +}; /// Error building a Sender from a SenderBuilder. /// @@ -109,30 +114,106 @@ impl From for ResponseError { #[error(transparent)] pub struct WellKnownError(#[from] send::WellKnownError); -/// Error that may occur when the sender session event log is replayed +/// Error that may occur when the sender session event log is replayed. #[derive(Debug, thiserror::Error, uniffi::Object)] -#[error(transparent)] -pub struct SenderReplayError( - #[from] payjoin::error::ReplayError, -); +#[error("{message}")] +pub struct SenderReplayError { + kind: FfiReplayErrorKind, + message: String, + invalid_event_kind: Option, + expired_at_unix_seconds: Option, + persistence_failure: Option>, +} + +impl From> + for SenderReplayError +{ + fn from( + value: payjoin::error::ReplayError, + ) -> Self { + let message = value.to_string(); + match value.into_variant() { + CoreReplayErrorVariant::NoEvents => Self { + kind: FfiReplayErrorKind::NoEvents, + message, + invalid_event_kind: None, + expired_at_unix_seconds: None, + persistence_failure: None, + }, + CoreReplayErrorVariant::InvalidFirstEvent => Self { + kind: FfiReplayErrorKind::InvalidEvent, + message, + invalid_event_kind: Some(FfiReplayInvalidEventKind::InitialEvent), + expired_at_unix_seconds: None, + persistence_failure: None, + }, + CoreReplayErrorVariant::InvalidEventForState => Self { + kind: FfiReplayErrorKind::InvalidEvent, + message, + invalid_event_kind: Some(FfiReplayInvalidEventKind::SessionTransition), + expired_at_unix_seconds: None, + persistence_failure: None, + }, + CoreReplayErrorVariant::Expired { expired_at_unix_seconds } => Self { + kind: FfiReplayErrorKind::Expired, + message, + invalid_event_kind: None, + expired_at_unix_seconds: Some(expired_at_unix_seconds), + persistence_failure: None, + }, + CoreReplayErrorVariant::PersistenceFailure(error) => Self { + kind: FfiReplayErrorKind::PersistenceFailure, + message, + invalid_event_kind: None, + expired_at_unix_seconds: None, + persistence_failure: Some(Arc::new(error.into())), + }, + } + } +} + +#[uniffi::export] +impl SenderReplayError { + pub fn kind(&self) -> FfiReplayErrorKind { self.kind } + + pub fn message(&self) -> String { self.message.clone() } + + pub fn invalid_event_kind(&self) -> Option { + self.invalid_event_kind + } + + pub fn expired_at_unix_seconds(&self) -> Option { self.expired_at_unix_seconds } + + pub fn persistence_failure(&self) -> Option> { + self.persistence_failure.clone() + } +} /// Error that may occur during state machine transitions #[derive(Debug, thiserror::Error, uniffi::Error)] -#[error(transparent)] pub enum SenderPersistedError { - /// rust-payjoin sender Encapsulation error - #[error(transparent)] - EncapsulationError(Arc), - /// rust-payjoin sender response error - #[error(transparent)] - ResponseError(ResponseError), - /// Sender Build error - #[error(transparent)] - BuildSenderError(Arc), - /// Storage error that could occur at application storage layer + /// Storage failure while persisting session state. #[error(transparent)] Storage(Arc), - /// Unexpected error + /// Retry the same sender transition after handling the encapsulation failure. + #[error("Transient sender encapsulation error: {0}")] + TransientEncapsulationError(Arc), + /// The sender session terminated because request encapsulation failed. + #[error("Fatal sender encapsulation error: {0}")] + FatalEncapsulationError(Arc), + /// Retry the same sender transition after handling the response failure. + #[error("Transient sender response error: {0}")] + TransientResponseError(ResponseError), + /// The sender session terminated because response handling failed. + #[error("Fatal sender response error: {0}")] + FatalResponseError(ResponseError), + /// Retry the same sender transition after handling the build failure. + #[error("Transient sender build error: {0}")] + TransientBuildSenderError(Arc), + /// The sender session terminated because sender construction failed. + #[error("Fatal sender build error: {0}")] + FatalBuildSenderError(Arc), + /// Unexpected error shape that should not occur for sender transitions. #[error("An unexpected error occurred")] Unexpected, } @@ -147,16 +228,15 @@ where S: std::error::Error + Send + Sync + 'static, { fn from(err: payjoin::persist::PersistedError) -> Self { - if err.storage_error_ref().is_some() { - if let Some(storage_err) = err.storage_error() { - return SenderPersistedError::from(ImplementationError::new(storage_err)); - } - return SenderPersistedError::Unexpected; - } - if let Some(api_err) = err.api_error() { - return SenderPersistedError::EncapsulationError(Arc::new(api_err.into())); + match err.into_variant() { + PersistedErrorVariant::Storage(storage_err) => + SenderPersistedError::from(ImplementationError::new(storage_err)), + PersistedErrorVariant::Transient(api_err) => + SenderPersistedError::TransientEncapsulationError(Arc::new(api_err.into())), + PersistedErrorVariant::Fatal(api_err) => + SenderPersistedError::FatalEncapsulationError(Arc::new(api_err.into())), + PersistedErrorVariant::FatalWithState(_, _) => SenderPersistedError::Unexpected, } - SenderPersistedError::Unexpected } } @@ -165,16 +245,15 @@ where S: std::error::Error + Send + Sync + 'static, { fn from(err: payjoin::persist::PersistedError) -> Self { - if err.storage_error_ref().is_some() { - if let Some(storage_err) = err.storage_error() { - return SenderPersistedError::from(ImplementationError::new(storage_err)); - } - return SenderPersistedError::Unexpected; - } - if let Some(api_err) = err.api_error() { - return SenderPersistedError::ResponseError(api_err.into()); + match err.into_variant() { + PersistedErrorVariant::Storage(storage_err) => + SenderPersistedError::from(ImplementationError::new(storage_err)), + PersistedErrorVariant::Transient(api_err) => + SenderPersistedError::TransientResponseError(api_err.into()), + PersistedErrorVariant::Fatal(api_err) => + SenderPersistedError::FatalResponseError(api_err.into()), + PersistedErrorVariant::FatalWithState(_, _) => SenderPersistedError::Unexpected, } - SenderPersistedError::Unexpected } } @@ -183,15 +262,138 @@ where S: std::error::Error + Send + Sync + 'static, { fn from(err: payjoin::persist::PersistedError) -> Self { - if err.storage_error_ref().is_some() { - if let Some(storage_err) = err.storage_error() { - return SenderPersistedError::from(ImplementationError::new(storage_err)); - } - return SenderPersistedError::Unexpected; + match err.into_variant() { + PersistedErrorVariant::Storage(storage_err) => + SenderPersistedError::from(ImplementationError::new(storage_err)), + PersistedErrorVariant::Transient(api_err) => + SenderPersistedError::TransientBuildSenderError(Arc::new(api_err.into())), + PersistedErrorVariant::Fatal(api_err) => + SenderPersistedError::FatalBuildSenderError(Arc::new(api_err.into())), + PersistedErrorVariant::FatalWithState(_, _) => SenderPersistedError::Unexpected, + } + } +} + +#[cfg(all(test, feature = "_test-utils"))] +mod tests { + use std::sync::{Arc, Mutex}; + + use super::SenderPersistedError; + use crate::error::{ForeignError, ReplayErrorKind}; + use crate::receive::JsonReceiverSessionPersister; + use crate::send::{JsonSenderSessionPersister, SenderBuilder}; + use crate::test_utils::{original_psbt, TestServices}; + use crate::ReceiverBuilder; + + #[derive(Default)] + struct InMemoryReceiverPersister { + events: Mutex>, + } + + impl JsonReceiverSessionPersister for InMemoryReceiverPersister { + fn save(&self, event: String) -> Result<(), ForeignError> { + self.events.lock().expect("lock").push(event); + Ok(()) + } + + fn load(&self) -> Result, ForeignError> { + Ok(self.events.lock().expect("lock").clone()) + } + + fn close(&self) -> Result<(), ForeignError> { Ok(()) } + } + + #[derive(Default)] + struct InMemorySenderPersister { + events: Mutex>, + } + + impl JsonSenderSessionPersister for InMemorySenderPersister { + fn save(&self, event: String) -> Result<(), ForeignError> { + self.events.lock().expect("lock").push(event); + Ok(()) } - if let Some(api_err) = err.api_error() { - return SenderPersistedError::BuildSenderError(Arc::new(api_err.into())); + + fn load(&self) -> Result, ForeignError> { + Ok(self.events.lock().expect("lock").clone()) } - SenderPersistedError::Unexpected + + fn close(&self) -> Result<(), ForeignError> { Ok(()) } + } + + #[derive(Default)] + struct LoadFailsSenderPersister; + + impl JsonSenderSessionPersister for LoadFailsSenderPersister { + fn save(&self, _: String) -> Result<(), ForeignError> { Ok(()) } + + fn load(&self) -> Result, ForeignError> { + Err(ForeignError::InternalError("storage offline".to_string())) + } + + fn close(&self) -> Result<(), ForeignError> { Ok(()) } + } + + fn with_reply_key() -> crate::send::WithReplyKey { + let services = TestServices::initialize().expect("services initialize"); + let ohttp_keys = Arc::new(services.fetch_ohttp_keys().expect("fetch ohttp keys")); + let receiver_persister = Arc::new(InMemoryReceiverPersister::default()); + let initialized = ReceiverBuilder::new( + "2N47mmrWXsNBvQR6k78hWJoTji57zXwNcU7".to_string(), + services.directory_url(), + ohttp_keys, + ) + .expect("receiver builder") + .build() + .save(receiver_persister) + .expect("save initialized receiver"); + let uri = initialized.pj_uri(); + + let sender_persister = Arc::new(InMemorySenderPersister::default()); + SenderBuilder::new(original_psbt(), Arc::new(uri)) + .expect("sender builder") + .build_recommended(1000) + .expect("build sender") + .save(sender_persister) + .expect("save sender") + } + + #[test] + fn test_sender_persisted_error_preserves_encapsulation_classification() { + let with_reply_key = with_reply_key(); + + let transient_request = + with_reply_key.create_v2_post_request("http://relay.invalid".to_string()).unwrap(); + let transient = with_reply_key + .clone() + .process_response(&[], transient_request.ohttp_ctx.as_ref()) + .save(Arc::new(InMemorySenderPersister::default())); + let transient = match transient { + Ok(_) => panic!("empty relay response should be transient"), + Err(error) => error, + }; + assert!(matches!(transient, SenderPersistedError::TransientEncapsulationError(_))); + + let fatal_request = + with_reply_key.create_v2_post_request("http://relay.invalid".to_string()).unwrap(); + let fatal = with_reply_key + .process_response(&fatal_request.request.body, fatal_request.ohttp_ctx.as_ref()) + .save(Arc::new(InMemorySenderPersister::default())); + let fatal = match fatal { + Ok(_) => panic!("garbage response with valid size should be fatal"), + Err(error) => error, + }; + assert!(matches!(fatal, SenderPersistedError::FatalEncapsulationError(_))); + } + + #[test] + fn test_sender_replay_error_exposes_persistence_failure_kind() { + let error = match crate::send::replay_sender_event_log(Arc::new(LoadFailsSenderPersister)) { + Ok(_) => panic!("replay should surface persistence failure"), + Err(error) => error, + }; + assert_eq!(error.kind(), ReplayErrorKind::PersistenceFailure); + assert!(error.persistence_failure().is_some()); + assert!(error.invalid_event_kind().is_none()); } } diff --git a/payjoin/src/core/error.rs b/payjoin/src/core/error.rs index 8c74f54e5..336ec85d3 100644 --- a/payjoin/src/core/error.rs +++ b/payjoin/src/core/error.rs @@ -39,6 +39,27 @@ impl From<&str> for ImplementationError { #[derive(Debug)] pub struct ReplayError(InternalReplayError); +/// High-level replay error classification for recovered session event logs. +#[cfg(feature = "v2")] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ReplayErrorKind { + NoEvents, + InvalidEvent, + Expired, + PersistenceFailure, +} + +/// Stable public decomposition of replay failures that does not expose session internals. +#[cfg(feature = "v2")] +#[derive(Debug)] +pub enum ReplayErrorVariant { + NoEvents, + InvalidFirstEvent, + InvalidEventForState, + Expired { expired_at_unix_seconds: u32 }, + PersistenceFailure(ImplementationError), +} + #[cfg(feature = "v2")] impl std::fmt::Display for ReplayError @@ -69,6 +90,31 @@ impl From) -> Self { ReplayError(e) } } +#[cfg(feature = "v2")] +impl ReplayError { + pub fn kind(&self) -> ReplayErrorKind { + match &self.0 { + InternalReplayError::NoEvents => ReplayErrorKind::NoEvents, + InternalReplayError::InvalidEvent(_, _) => ReplayErrorKind::InvalidEvent, + InternalReplayError::Expired(_) => ReplayErrorKind::Expired, + InternalReplayError::PersistenceFailure(_) => ReplayErrorKind::PersistenceFailure, + } + } + + pub fn into_variant(self) -> ReplayErrorVariant { + match self.0 { + InternalReplayError::NoEvents => ReplayErrorVariant::NoEvents, + InternalReplayError::InvalidEvent(_, None) => ReplayErrorVariant::InvalidFirstEvent, + InternalReplayError::InvalidEvent(_, Some(_)) => + ReplayErrorVariant::InvalidEventForState, + InternalReplayError::Expired(time) => + ReplayErrorVariant::Expired { expired_at_unix_seconds: time.to_unix_seconds() }, + InternalReplayError::PersistenceFailure(error) => + ReplayErrorVariant::PersistenceFailure(error), + } + } +} + #[cfg(feature = "v2")] #[derive(Debug)] pub(crate) enum InternalReplayError { @@ -81,3 +127,60 @@ pub(crate) enum InternalReplayError { /// Application storage error PersistenceFailure(ImplementationError), } + +#[cfg(all(test, feature = "v2"))] +mod tests { + use super::{ + ImplementationError, InternalReplayError, ReplayError, ReplayErrorKind, ReplayErrorVariant, + }; + use crate::time::Time; + + #[derive(Debug)] + struct DummyState; + + #[derive(Debug)] + struct DummyEvent; + + #[test] + fn test_replay_error_kind_and_variant() { + let no_events = ReplayError::::from(InternalReplayError::NoEvents); + assert_eq!(no_events.kind(), ReplayErrorKind::NoEvents); + assert!(matches!(no_events.into_variant(), ReplayErrorVariant::NoEvents)); + + let invalid_first_event = ReplayError::::from( + InternalReplayError::InvalidEvent(Box::new(DummyEvent), None), + ); + assert_eq!(invalid_first_event.kind(), ReplayErrorKind::InvalidEvent); + assert!(matches!( + invalid_first_event.into_variant(), + ReplayErrorVariant::InvalidFirstEvent + )); + + let invalid_event_for_state = ReplayError::::from( + InternalReplayError::InvalidEvent(Box::new(DummyEvent), Some(Box::new(DummyState))), + ); + assert_eq!(invalid_event_for_state.kind(), ReplayErrorKind::InvalidEvent); + assert!(matches!( + invalid_event_for_state.into_variant(), + ReplayErrorVariant::InvalidEventForState + )); + + let expired_time = Time::from_unix_seconds(1_700_000_000).expect("valid time"); + let expired = + ReplayError::::from(InternalReplayError::Expired(expired_time)); + assert_eq!(expired.kind(), ReplayErrorKind::Expired); + assert!(matches!( + expired.into_variant(), + ReplayErrorVariant::Expired { expired_at_unix_seconds: 1_700_000_000 } + )); + + let persistence_failure = ReplayError::::from( + InternalReplayError::PersistenceFailure(ImplementationError::from("storage failed")), + ); + assert_eq!(persistence_failure.kind(), ReplayErrorKind::PersistenceFailure); + assert!(matches!( + persistence_failure.into_variant(), + ReplayErrorVariant::PersistenceFailure(_) + )); + } +} diff --git a/payjoin/src/core/persist.rs b/payjoin/src/core/persist.rs index f9a164abb..be88c3320 100644 --- a/payjoin/src/core/persist.rs +++ b/payjoin/src/core/persist.rs @@ -598,12 +598,57 @@ pub struct PersistedError< ErrorState: fmt::Debug = (), >(InternalPersistedError); +/// High-level persisted error classification for session transitions. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PersistedErrorKind { + Storage, + Transient, + Fatal, + FatalWithState, +} + +/// Stable public decomposition of a persisted transition failure. +#[derive(Debug)] +pub enum PersistedErrorVariant +where + ApiErr: std::error::Error, + StorageErr: std::error::Error, + ErrorState: fmt::Debug, +{ + Storage(StorageErr), + Transient(ApiErr), + Fatal(ApiErr), + FatalWithState(ApiErr, ErrorState), +} + impl PersistedError where StorageErr: std::error::Error, ApiErr: std::error::Error, ErrorState: fmt::Debug, { + pub fn kind(&self) -> PersistedErrorKind { + match &self.0 { + InternalPersistedError::Storage(_) => PersistedErrorKind::Storage, + InternalPersistedError::Api(ApiError::Transient(_)) => PersistedErrorKind::Transient, + InternalPersistedError::Api(ApiError::Fatal(_)) => PersistedErrorKind::Fatal, + InternalPersistedError::Api(ApiError::FatalWithState(_, _)) => + PersistedErrorKind::FatalWithState, + } + } + + pub fn into_variant(self) -> PersistedErrorVariant { + match self.0 { + InternalPersistedError::Storage(error) => PersistedErrorVariant::Storage(error), + InternalPersistedError::Api(ApiError::Transient(error)) => + PersistedErrorVariant::Transient(error), + InternalPersistedError::Api(ApiError::Fatal(error)) => + PersistedErrorVariant::Fatal(error), + InternalPersistedError::Api(ApiError::FatalWithState(error, state)) => + PersistedErrorVariant::FatalWithState(error, state), + } + } + #[allow(dead_code)] pub fn storage_error(self) -> Option { match self.0 { @@ -1447,4 +1492,47 @@ mod tests { assert!(transient_error.storage_error_ref().is_none()); assert!(transient_error.api_error_ref().is_some()); } + + #[test] + fn test_persisted_error_kind_and_variant() { + let storage_error = PersistedError::( + InternalPersistedError::Storage(InMemoryTestError {}), + ); + assert_eq!(storage_error.kind(), PersistedErrorKind::Storage); + match storage_error.into_variant() { + PersistedErrorVariant::Storage(_) => {} + other => panic!("unexpected storage variant: {other:?}"), + } + + let transient_error = PersistedError::( + InternalPersistedError::Api(ApiError::Transient(InMemoryTestError {})), + ); + assert_eq!(transient_error.kind(), PersistedErrorKind::Transient); + match transient_error.into_variant() { + PersistedErrorVariant::Transient(_) => {} + other => panic!("unexpected transient variant: {other:?}"), + } + + let fatal_error = PersistedError::( + InternalPersistedError::Api(ApiError::Fatal(InMemoryTestError {})), + ); + assert_eq!(fatal_error.kind(), PersistedErrorKind::Fatal); + match fatal_error.into_variant() { + PersistedErrorVariant::Fatal(_) => {} + other => panic!("unexpected fatal variant: {other:?}"), + } + + let fatal_with_state_error = PersistedError::< + InMemoryTestError, + InMemoryTestError, + &'static str, + >(InternalPersistedError::Api( + ApiError::FatalWithState(InMemoryTestError {}, "replyable"), + )); + assert_eq!(fatal_with_state_error.kind(), PersistedErrorKind::FatalWithState); + match fatal_with_state_error.into_variant() { + PersistedErrorVariant::FatalWithState(_, "replyable") => {} + other => panic!("unexpected fatal-with-state variant: {other:?}"), + } + } } diff --git a/payjoin/src/core/time.rs b/payjoin/src/core/time.rs index 47f221f08..60a92b3dc 100644 --- a/payjoin/src/core/time.rs +++ b/payjoin/src/core/time.rs @@ -51,6 +51,9 @@ impl Time { buf } + /// Encode as a UNIX timestamp in seconds. + pub(crate) fn to_unix_seconds(self) -> u32 { self.0.to_consensus_u32() } + /// Check if the time is in the past. pub(crate) fn elapsed(self) -> bool { self <= Self::now() } }