From 387094f89e4275c44ed5d9d62414b77f44453a8f Mon Sep 17 00:00:00 2001 From: chavic Date: Fri, 20 Mar 2026 15:10:28 +0200 Subject: [PATCH 1/2] Preserve Persisted Error Classification Expose persisted and replay error classification in core so the FFI can preserve storage, transient, fatal, and fatal-with-state semantics. This keeps sender and receiver bindings from losing recovery guidance at the persistence boundary, and it snapshots replay failures with stable kind and detail accessors. The receiver save helpers now retain replyable error state instead of flattening it into implementation errors, which lets bindings continue the protocol after fatal-with-state failures. --- payjoin-ffi/src/error.rs | 14 ++ payjoin-ffi/src/receive/error.rs | 327 +++++++++++++++++++++++++++++-- payjoin-ffi/src/receive/mod.rs | 11 +- payjoin-ffi/src/send/error.rs | 290 ++++++++++++++++++++++----- payjoin/src/core/error.rs | 103 ++++++++++ payjoin/src/core/persist.rs | 88 +++++++++ payjoin/src/core/time.rs | 3 + 7 files changed, 764 insertions(+), 72 deletions(-) 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() } } From dbfed48f331621aefe2fae4a90ca2099e86c0a7c Mon Sep 17 00:00:00 2001 From: chavic Date: Sun, 5 Apr 2026 20:35:55 +0200 Subject: [PATCH 2/2] Upgrade uniffi-dart Upgrade uniffi-dart to the upstream revision that fixes Dart\nobject naming for Error-suffixed UniFFI types.\n\nThis branch exposes replay persistence failures that return\nImplementationError over FFI. The older generator revision emits\nbroken Dart type references for that shape, so the Dart jobs fail to\ncompile.\n\nUpdate the dependency and refresh both lockfiles so the branch uses\nthe fixed generator consistently in CI and local binding generation. --- Cargo-minimal.lock | 4 ++-- Cargo-recent.lock | 4 ++-- payjoin-ffi/Cargo.toml | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) 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