From 95ab25860abc671dce57a58c8422031802765cb5 Mon Sep 17 00:00:00 2001 From: chavic Date: Fri, 20 Mar 2026 14:34:48 +0200 Subject: [PATCH 1/3] Expose V2 Transport Error Kinds Add stable kind and detail accessors for sender and receiver v2 transport errors, including HPKE, OHTTP encapsulation, and directory response failures. The core library already distinguishes these failures internally, but the FFI only exposed opaque wrapper objects. That forced bindings to branch on display strings and hid useful detail such as nested decapsulation causes, unexpected status codes, and payload size limits. This preserves the private internal enums while giving cross-language callers typed transport diagnostics. --- payjoin-ffi/src/error.rs | 152 ++++++++++++++++++++++ payjoin-ffi/src/receive/error.rs | 67 +++++++++- payjoin-ffi/src/send/error.rs | 184 ++++++++++++++++++++++++++- payjoin/src/core/hpke.rs | 69 ++++++++++ payjoin/src/core/mod.rs | 7 +- payjoin/src/core/ohttp.rs | 123 ++++++++++++++++++ payjoin/src/core/receive/v2/error.rs | 79 +++++++++++- payjoin/src/core/receive/v2/mod.rs | 2 +- payjoin/src/core/send/v2/error.rs | 109 ++++++++++++++++ payjoin/src/core/send/v2/mod.rs | 4 +- 10 files changed, 782 insertions(+), 14 deletions(-) diff --git a/payjoin-ffi/src/error.rs b/payjoin-ffi/src/error.rs index fca35a1fe..627c3ed4e 100644 --- a/payjoin-ffi/src/error.rs +++ b/payjoin-ffi/src/error.rs @@ -1,4 +1,5 @@ use std::error; +use std::sync::Arc; /// Error arising due to the specific receiver implementation /// @@ -17,6 +18,157 @@ impl From for payjoin::ImplementationError { fn from(value: ImplementationError) -> Self { value.0 } } +#[derive(Clone, Copy, Debug, PartialEq, Eq, uniffi::Enum)] +pub enum HpkeErrorKind { + InvalidPublicKey, + Hpke, + InvalidKeyLength, + PayloadTooLarge, + PayloadTooShort, + UnexpectedSecp256k1Error, + Other, +} + +impl From for HpkeErrorKind { + fn from(value: payjoin::HpkeErrorKind) -> Self { + match value { + payjoin::HpkeErrorKind::InvalidPublicKey => Self::InvalidPublicKey, + payjoin::HpkeErrorKind::Hpke => Self::Hpke, + payjoin::HpkeErrorKind::InvalidKeyLength => Self::InvalidKeyLength, + payjoin::HpkeErrorKind::PayloadTooLarge => Self::PayloadTooLarge, + payjoin::HpkeErrorKind::PayloadTooShort => Self::PayloadTooShort, + payjoin::HpkeErrorKind::UnexpectedSecp256k1Error => Self::UnexpectedSecp256k1Error, + _ => Self::Other, + } + } +} + +#[derive(Debug, thiserror::Error, uniffi::Object)] +#[error("{message}")] +pub struct HpkeError { + kind: HpkeErrorKind, + message: String, + payload_too_large_actual: Option, + payload_too_large_max: Option, +} + +impl From for HpkeError { + fn from(value: payjoin::HpkeErrorDetails) -> Self { + Self { + kind: value.kind().into(), + message: value.message().to_owned(), + payload_too_large_actual: value.payload_too_large_actual().map(|value| value as u64), + payload_too_large_max: value.payload_too_large_max().map(|value| value as u64), + } + } +} + +#[uniffi::export] +impl HpkeError { + pub fn kind(&self) -> HpkeErrorKind { self.kind } + + pub fn message(&self) -> String { self.message.clone() } + + pub fn payload_too_large_actual(&self) -> Option { self.payload_too_large_actual } + + pub fn payload_too_large_max(&self) -> Option { self.payload_too_large_max } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, uniffi::Enum)] +pub enum OhttpEncapsulationErrorKind { + Http, + Ohttp, + Bhttp, + ParseUrl, + Other, +} + +impl From for OhttpEncapsulationErrorKind { + fn from(value: payjoin::OhttpEncapsulationErrorKind) -> Self { + match value { + payjoin::OhttpEncapsulationErrorKind::Http => Self::Http, + payjoin::OhttpEncapsulationErrorKind::Ohttp => Self::Ohttp, + payjoin::OhttpEncapsulationErrorKind::Bhttp => Self::Bhttp, + payjoin::OhttpEncapsulationErrorKind::ParseUrl => Self::ParseUrl, + _ => Self::Other, + } + } +} + +#[derive(Debug, thiserror::Error, uniffi::Object)] +#[error("{message}")] +pub struct OhttpEncapsulationError { + kind: OhttpEncapsulationErrorKind, + message: String, +} + +impl From for OhttpEncapsulationError { + fn from(value: payjoin::OhttpEncapsulationErrorDetails) -> Self { + Self { kind: value.kind().into(), message: value.message().to_owned() } + } +} + +#[uniffi::export] +impl OhttpEncapsulationError { + pub fn kind(&self) -> OhttpEncapsulationErrorKind { self.kind } + + pub fn message(&self) -> String { self.message.clone() } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, uniffi::Enum)] +pub enum DirectoryResponseErrorKind { + InvalidSize, + OhttpDecapsulation, + UnexpectedStatusCode, + Other, +} + +impl From for DirectoryResponseErrorKind { + fn from(value: payjoin::DirectoryResponseErrorKind) -> Self { + match value { + payjoin::DirectoryResponseErrorKind::InvalidSize => Self::InvalidSize, + payjoin::DirectoryResponseErrorKind::OhttpDecapsulation => Self::OhttpDecapsulation, + payjoin::DirectoryResponseErrorKind::UnexpectedStatusCode => Self::UnexpectedStatusCode, + _ => Self::Other, + } + } +} + +#[derive(Debug, thiserror::Error, uniffi::Object)] +#[error("{message}")] +pub struct DirectoryResponseError { + kind: DirectoryResponseErrorKind, + message: String, + invalid_size: Option, + unexpected_status_code: Option, + ohttp_error: Option>, +} + +impl From for DirectoryResponseError { + fn from(value: payjoin::DirectoryResponseErrorDetails) -> Self { + Self { + kind: value.kind().into(), + message: value.message().to_owned(), + invalid_size: value.invalid_size().map(|value| value as u64), + unexpected_status_code: value.unexpected_status_code(), + ohttp_error: value.ohttp_error().cloned().map(|details| Arc::new(details.into())), + } + } +} + +#[uniffi::export] +impl DirectoryResponseError { + pub fn kind(&self) -> DirectoryResponseErrorKind { self.kind } + + pub fn message(&self) -> String { self.message.clone() } + + pub fn invalid_size(&self) -> Option { self.invalid_size } + + pub fn unexpected_status_code(&self) -> Option { self.unexpected_status_code } + + pub fn ohttp_error(&self) -> Option> { self.ohttp_error.clone() } +} + #[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..f0fb11452 100644 --- a/payjoin-ffi/src/receive/error.rs +++ b/payjoin-ffi/src/receive/error.rs @@ -2,7 +2,10 @@ use std::sync::Arc; use payjoin::receive; -use crate::error::{FfiValidationError, ImplementationError}; +use crate::error::{ + DirectoryResponseError, FfiValidationError, HpkeError, ImplementationError, + OhttpEncapsulationError, +}; use crate::uri::error::IntoUrlError; /// The top-level error type for the payjoin receiver @@ -164,9 +167,67 @@ impl From for JsonReply { } /// Error that may occur during a v2 session typestate change +#[derive(Clone, Copy, Debug, PartialEq, Eq, uniffi::Enum)] +pub enum SessionErrorKind { + ParseUrl, + Expired, + OhttpEncapsulation, + Hpke, + DirectoryResponse, + Other, +} + +impl From for SessionErrorKind { + fn from(value: receive::v2::SessionErrorKind) -> Self { + match value { + receive::v2::SessionErrorKind::ParseUrl => Self::ParseUrl, + receive::v2::SessionErrorKind::Expired => Self::Expired, + receive::v2::SessionErrorKind::OhttpEncapsulation => Self::OhttpEncapsulation, + receive::v2::SessionErrorKind::Hpke => Self::Hpke, + receive::v2::SessionErrorKind::DirectoryResponse => Self::DirectoryResponse, + _ => Self::Other, + } + } +} + #[derive(Debug, thiserror::Error, uniffi::Object)] -#[error(transparent)] -pub struct SessionError(#[from] receive::v2::SessionError); +#[error("{message}")] +pub struct SessionError { + kind: SessionErrorKind, + message: String, + hpke_error: Option>, + ohttp_error: Option>, + directory_response_error: Option>, +} + +impl From for SessionError { + fn from(value: receive::v2::SessionError) -> Self { + Self { + kind: value.kind().into(), + message: value.to_string(), + hpke_error: value.hpke_error().map(|error| Arc::new(error.into())), + ohttp_error: value.ohttp_error().map(|error| Arc::new(error.into())), + directory_response_error: value + .directory_response_error() + .map(|error| Arc::new(error.into())), + } + } +} + +#[uniffi::export] +impl SessionError { + pub fn kind(&self) -> SessionErrorKind { self.kind } + + pub fn message(&self) -> String { self.message.clone() } + + pub fn hpke_error(&self) -> Option> { self.hpke_error.clone() } + + pub fn ohttp_error(&self) -> Option> { self.ohttp_error.clone() } + + pub fn directory_response_error(&self) -> Option> { + self.directory_response_error.clone() + } +} /// Protocol error raised during output substitution. #[derive(Debug, thiserror::Error, uniffi::Object)] diff --git a/payjoin-ffi/src/send/error.rs b/payjoin-ffi/src/send/error.rs index ed5438cde..b8800b697 100644 --- a/payjoin-ffi/src/send/error.rs +++ b/payjoin-ffi/src/send/error.rs @@ -3,7 +3,10 @@ use std::sync::Arc; use payjoin::bitcoin::psbt::PsbtParseError as CorePsbtParseError; use payjoin::send; -use crate::error::{FfiValidationError, ImplementationError}; +use crate::error::{ + DirectoryResponseError, FfiValidationError, HpkeError, ImplementationError, + OhttpEncapsulationError, +}; /// Error building a Sender from a SenderBuilder. /// @@ -54,14 +57,110 @@ impl From for SenderInputError { /// This error can currently only happen due to programmer mistake. /// `unwrap()`ing it is thus considered OK in Rust but you may achieve nicer message by displaying /// it. +#[derive(Clone, Copy, Debug, PartialEq, Eq, uniffi::Enum)] +pub enum CreateRequestErrorKind { + Url, + Hpke, + OhttpEncapsulation, + Expired, + Other, +} + +impl From for CreateRequestErrorKind { + fn from(value: send::v2::CreateRequestErrorKind) -> Self { + match value { + send::v2::CreateRequestErrorKind::Url => Self::Url, + send::v2::CreateRequestErrorKind::Hpke => Self::Hpke, + send::v2::CreateRequestErrorKind::OhttpEncapsulation => Self::OhttpEncapsulation, + send::v2::CreateRequestErrorKind::Expired => Self::Expired, + _ => Self::Other, + } + } +} + #[derive(Debug, thiserror::Error, uniffi::Object)] -#[error(transparent)] -pub struct CreateRequestError(#[from] send::v2::CreateRequestError); +#[error("{message}")] +pub struct CreateRequestError { + kind: CreateRequestErrorKind, + message: String, + hpke_error: Option>, + ohttp_error: Option>, +} + +impl From for CreateRequestError { + fn from(value: send::v2::CreateRequestError) -> Self { + Self { + kind: value.kind().into(), + message: value.to_string(), + hpke_error: value.hpke_error().map(|error| Arc::new(error.into())), + ohttp_error: value.ohttp_error().map(|error| Arc::new(error.into())), + } + } +} + +#[uniffi::export] +impl CreateRequestError { + pub fn kind(&self) -> CreateRequestErrorKind { self.kind } + + pub fn message(&self) -> String { self.message.clone() } + + pub fn hpke_error(&self) -> Option> { self.hpke_error.clone() } + + pub fn ohttp_error(&self) -> Option> { self.ohttp_error.clone() } +} /// Error returned for v2-specific payload encapsulation errors. +#[derive(Clone, Copy, Debug, PartialEq, Eq, uniffi::Enum)] +pub enum EncapsulationErrorKind { + Hpke, + DirectoryResponse, + Other, +} + +impl From for EncapsulationErrorKind { + fn from(value: send::v2::EncapsulationErrorKind) -> Self { + match value { + send::v2::EncapsulationErrorKind::Hpke => Self::Hpke, + send::v2::EncapsulationErrorKind::DirectoryResponse => Self::DirectoryResponse, + _ => Self::Other, + } + } +} + #[derive(Debug, thiserror::Error, uniffi::Object)] -#[error(transparent)] -pub struct EncapsulationError(#[from] send::v2::EncapsulationError); +#[error("{message}")] +pub struct EncapsulationError { + kind: EncapsulationErrorKind, + message: String, + hpke_error: Option>, + directory_response_error: Option>, +} + +impl From for EncapsulationError { + fn from(value: send::v2::EncapsulationError) -> Self { + Self { + kind: value.kind().into(), + message: value.to_string(), + hpke_error: value.hpke_error().map(|error| Arc::new(error.into())), + directory_response_error: value + .directory_response_error() + .map(|error| Arc::new(error.into())), + } + } +} + +#[uniffi::export] +impl EncapsulationError { + pub fn kind(&self) -> EncapsulationErrorKind { self.kind } + + pub fn message(&self) -> String { self.message.clone() } + + pub fn hpke_error(&self) -> Option> { self.hpke_error.clone() } + + pub fn directory_response_error(&self) -> Option> { + self.directory_response_error.clone() + } +} /// Error that may occur when the response from receiver is malformed. #[derive(Debug, thiserror::Error, uniffi::Object)] @@ -195,3 +294,78 @@ where SenderPersistedError::Unexpected } } + +#[cfg(all(test, feature = "_test-utils"))] +mod tests { + use std::str::FromStr; + use std::time::Duration; + + use payjoin::bitcoin::{Address, FeeRate}; + use payjoin::persist::NoopSessionPersister; + use payjoin::receive::v2::ReceiverBuilder; + use payjoin::send::v2::SenderBuilder; + use payjoin::OhttpKeys; + use payjoin_test_utils::{EXAMPLE_URL, KEM, KEY_ID, PARSED_ORIGINAL_PSBT, SYMMETRIC}; + + use super::*; + + fn sender_with_reply_key( + expiration: Duration, + ) -> payjoin::send::v2::Sender { + let address = Address::from_str("2N47mmrWXsNBvQR6k78hWJoTji57zXwNcU7") + .expect("valid address") + .assume_checked(); + let ohttp_keys = OhttpKeys( + ohttp::KeyConfig::new(KEY_ID, KEM, Vec::from(SYMMETRIC)).expect("valid key config"), + ); + let pj_uri = ReceiverBuilder::new(address, EXAMPLE_URL, ohttp_keys) + .expect("receiver builder should succeed") + .with_expiration(expiration) + .build() + .save(&NoopSessionPersister::default()) + .expect("receiver transition should persist") + .pj_uri(); + + SenderBuilder::new(PARSED_ORIGINAL_PSBT.clone(), pj_uri) + .build_recommended(FeeRate::BROADCAST_MIN) + .expect("sender builder should succeed") + .save(&NoopSessionPersister::default()) + .expect("sender transition should persist") + } + + #[test] + fn test_create_request_error_exposes_expired_kind() { + let sender = sender_with_reply_key(Duration::ZERO); + let error = match sender.create_v2_post_request(EXAMPLE_URL) { + Err(error) => CreateRequestError::from(error), + Ok(_) => panic!("expired sender request should fail"), + }; + + assert_eq!(error.kind(), CreateRequestErrorKind::Expired); + assert_eq!(error.message(), "session expired"); + assert!(error.hpke_error().is_none()); + assert!(error.ohttp_error().is_none()); + } + + #[test] + fn test_encapsulation_error_exposes_directory_response_details() { + let sender = sender_with_reply_key(Duration::from_secs(60)); + let (_, post_ctx) = + sender.create_v2_post_request(EXAMPLE_URL).expect("request creation should succeed"); + let error = sender + .process_response(&[], post_ctx) + .save(&NoopSessionPersister::default()) + .expect_err("empty response should fail"); + let error = EncapsulationError::from( + error.api_error().expect("encapsulation error should be available"), + ); + + assert_eq!(error.kind(), EncapsulationErrorKind::DirectoryResponse); + let directory = + error.directory_response_error().expect("directory response details should be present"); + assert_eq!(directory.kind(), crate::error::DirectoryResponseErrorKind::InvalidSize); + assert_eq!(directory.invalid_size(), Some(0)); + assert!(directory.ohttp_error().is_none()); + assert!(error.hpke_error().is_none()); + } +} diff --git a/payjoin/src/core/hpke.rs b/payjoin/src/core/hpke.rs index aa12756af..3c652ee77 100644 --- a/payjoin/src/core/hpke.rs +++ b/payjoin/src/core/hpke.rs @@ -279,6 +279,66 @@ pub enum HpkeError { UnexpectedSecp256k1Error, } +/// A stable classification for HPKE failures. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum HpkeErrorKind { + InvalidPublicKey, + Hpke, + InvalidKeyLength, + PayloadTooLarge, + PayloadTooShort, + UnexpectedSecp256k1Error, +} + +/// Stable HPKE error details that can be passed across API boundaries. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HpkeErrorDetails { + kind: HpkeErrorKind, + message: String, + payload_too_large_actual: Option, + payload_too_large_max: Option, +} + +impl HpkeErrorDetails { + pub fn kind(&self) -> HpkeErrorKind { self.kind } + + pub fn message(&self) -> &str { &self.message } + + pub fn payload_too_large_actual(&self) -> Option { self.payload_too_large_actual } + + pub fn payload_too_large_max(&self) -> Option { self.payload_too_large_max } +} + +impl HpkeError { + /// Returns the stable classification of the HPKE failure. + pub fn kind(&self) -> HpkeErrorKind { + match self { + Self::InvalidPublicKey => HpkeErrorKind::InvalidPublicKey, + Self::Hpke(_) => HpkeErrorKind::Hpke, + Self::InvalidKeyLength => HpkeErrorKind::InvalidKeyLength, + Self::PayloadTooLarge { .. } => HpkeErrorKind::PayloadTooLarge, + Self::PayloadTooShort => HpkeErrorKind::PayloadTooShort, + Self::UnexpectedSecp256k1Error => HpkeErrorKind::UnexpectedSecp256k1Error, + } + } + + /// Returns stable HPKE error details for API consumers. + pub fn details(&self) -> HpkeErrorDetails { + let (payload_too_large_actual, payload_too_large_max) = match self { + Self::PayloadTooLarge { actual, max } => (Some(*actual), Some(*max)), + _ => (None, None), + }; + + HpkeErrorDetails { + kind: self.kind(), + message: self.to_string(), + payload_too_large_actual, + payload_too_large_max, + } + } +} + impl From for HpkeError { fn from(value: hpke::HpkeError) -> Self { Self::Hpke(value) } } @@ -352,6 +412,15 @@ mod test { } } + #[test] + fn hpke_error_details_expose_payload_sizes() { + let details = HpkeError::PayloadTooLarge { actual: 33, max: 32 }.details(); + + assert_eq!(details.kind(), HpkeErrorKind::PayloadTooLarge); + assert_eq!(details.payload_too_large_actual(), Some(33)); + assert_eq!(details.payload_too_large_max(), Some(32)); + } + #[test] fn message_a_round_trip() { let mut plaintext = "foo".as_bytes().to_vec(); diff --git a/payjoin/src/core/mod.rs b/payjoin/src/core/mod.rs index 425bbae91..9bee06bc1 100644 --- a/payjoin/src/core/mod.rs +++ b/payjoin/src/core/mod.rs @@ -31,11 +31,14 @@ pub(crate) mod hpke; #[cfg(feature = "v2")] pub mod persist; #[cfg(feature = "v2")] -pub use crate::hpke::{HpkeKeyPair, HpkePublicKey}; +pub use crate::hpke::{HpkeErrorDetails, HpkeErrorKind, HpkeKeyPair, HpkePublicKey}; #[cfg(feature = "v2")] pub(crate) mod ohttp; #[cfg(feature = "v2")] -pub use crate::ohttp::OhttpKeys; +pub use crate::ohttp::{ + DirectoryResponseErrorDetails, DirectoryResponseErrorKind, OhttpEncapsulationErrorDetails, + OhttpEncapsulationErrorKind, OhttpKeys, +}; #[cfg(feature = "io")] #[cfg_attr(docsrs, doc(cfg(feature = "io")))] diff --git a/payjoin/src/core/ohttp.rs b/payjoin/src/core/ohttp.rs index 2036a9f5a..8fcacd0fa 100644 --- a/payjoin/src/core/ohttp.rs +++ b/payjoin/src/core/ohttp.rs @@ -60,6 +60,62 @@ pub enum DirectoryResponseError { UnexpectedStatusCode(http::StatusCode), } +/// A stable classification for OHTTP encapsulation and decapsulation failures. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum OhttpEncapsulationErrorKind { + Http, + Ohttp, + Bhttp, + ParseUrl, +} + +/// Stable OHTTP encapsulation error details that can be passed across API boundaries. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OhttpEncapsulationErrorDetails { + kind: OhttpEncapsulationErrorKind, + message: String, +} + +impl OhttpEncapsulationErrorDetails { + pub fn kind(&self) -> OhttpEncapsulationErrorKind { self.kind } + + pub fn message(&self) -> &str { &self.message } +} + +/// A stable classification for directory response failures. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum DirectoryResponseErrorKind { + InvalidSize, + OhttpDecapsulation, + UnexpectedStatusCode, +} + +/// Stable directory response error details that can be passed across API boundaries. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DirectoryResponseErrorDetails { + kind: DirectoryResponseErrorKind, + message: String, + invalid_size: Option, + unexpected_status_code: Option, + ohttp_error: Option, +} + +impl DirectoryResponseErrorDetails { + pub fn kind(&self) -> DirectoryResponseErrorKind { self.kind } + + pub fn message(&self) -> &str { &self.message } + + pub fn invalid_size(&self) -> Option { self.invalid_size } + + pub fn unexpected_status_code(&self) -> Option { self.unexpected_status_code } + + pub fn ohttp_error(&self) -> Option<&OhttpEncapsulationErrorDetails> { + self.ohttp_error.as_ref() + } +} + impl DirectoryResponseError { pub(crate) fn is_fatal(&self) -> bool { use DirectoryResponseError::*; @@ -70,6 +126,35 @@ impl DirectoryResponseError { UnexpectedStatusCode(status_code) => status_code.is_client_error(), } } + + /// Returns the stable classification of the directory response failure. + pub fn kind(&self) -> DirectoryResponseErrorKind { + match self { + Self::InvalidSize(_) => DirectoryResponseErrorKind::InvalidSize, + Self::OhttpDecapsulation(_) => DirectoryResponseErrorKind::OhttpDecapsulation, + Self::UnexpectedStatusCode(_) => DirectoryResponseErrorKind::UnexpectedStatusCode, + } + } + + /// Returns stable directory response error details for API consumers. + pub fn details(&self) -> DirectoryResponseErrorDetails { + DirectoryResponseErrorDetails { + kind: self.kind(), + message: self.to_string(), + invalid_size: match self { + Self::InvalidSize(size) => Some(*size), + _ => None, + }, + unexpected_status_code: match self { + Self::UnexpectedStatusCode(status_code) => Some(status_code.as_u16()), + _ => None, + }, + ohttp_error: match self { + Self::OhttpDecapsulation(error) => Some(error.details()), + _ => None, + }, + } + } } impl fmt::Display for DirectoryResponseError { @@ -183,6 +268,23 @@ impl From for OhttpEncapsulationError { fn from(value: url::ParseError) -> Self { Self::ParseUrl(value) } } +impl OhttpEncapsulationError { + /// Returns the stable classification of the OHTTP failure. + pub fn kind(&self) -> OhttpEncapsulationErrorKind { + match self { + Self::Http(_) => OhttpEncapsulationErrorKind::Http, + Self::Ohttp(_) => OhttpEncapsulationErrorKind::Ohttp, + Self::Bhttp(_) => OhttpEncapsulationErrorKind::Bhttp, + Self::ParseUrl(_) => OhttpEncapsulationErrorKind::ParseUrl, + } + } + + /// Returns stable OHTTP error details for API consumers. + pub fn details(&self) -> OhttpEncapsulationErrorDetails { + OhttpEncapsulationErrorDetails { kind: self.kind(), message: self.to_string() } + } +} + impl fmt::Display for OhttpEncapsulationError { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { use OhttpEncapsulationError::*; @@ -315,6 +417,27 @@ impl Deref for OhttpKeys { fn deref(&self) -> &Self::Target { &self.0 } } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn directory_response_details_expose_nested_ohttp_error() { + let error = DirectoryResponseError::OhttpDecapsulation(OhttpEncapsulationError::ParseUrl( + url::ParseError::EmptyHost, + )); + let details = error.details(); + + assert_eq!(details.kind(), DirectoryResponseErrorKind::OhttpDecapsulation); + assert_eq!( + details.ohttp_error().expect("nested OHTTP error should be present").kind(), + OhttpEncapsulationErrorKind::ParseUrl + ); + assert!(details.invalid_size().is_none()); + assert!(details.unexpected_status_code().is_none()); + } +} + impl DerefMut for OhttpKeys { fn deref_mut(&mut self) -> &mut Self::Target { &mut self.0 } } diff --git a/payjoin/src/core/receive/v2/error.rs b/payjoin/src/core/receive/v2/error.rs index ec9062c51..6d8f99a45 100644 --- a/payjoin/src/core/receive/v2/error.rs +++ b/payjoin/src/core/receive/v2/error.rs @@ -6,14 +6,26 @@ use crate::ohttp::{DirectoryResponseError, OhttpEncapsulationError}; use crate::receive::error::Error; use crate::receive::ProtocolError; use crate::time::Time; +use crate::{DirectoryResponseErrorDetails, HpkeErrorDetails, OhttpEncapsulationErrorDetails}; /// Error that may occur during a v2 session typestate change /// -/// This is currently opaque type because we aren't sure which variants will stay. -/// You can only display it. +/// This type keeps its internal variants private, but exposes a stable +/// classification via [`SessionError::kind`]. #[derive(Debug)] pub struct SessionError(pub(super) InternalSessionError); +/// A stable classification for receiver v2 session failures. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum SessionErrorKind { + ParseUrl, + Expired, + OhttpEncapsulation, + Hpke, + DirectoryResponse, +} + impl From for SessionError { fn from(value: InternalSessionError) -> Self { SessionError(value) } } @@ -46,6 +58,43 @@ impl From for Error { fn from(e: HpkeError) -> Self { InternalSessionError::Hpke(e).into() } } +impl SessionError { + /// Returns the stable classification of the session failure. + pub fn kind(&self) -> SessionErrorKind { + match &self.0 { + InternalSessionError::ParseUrl(_) => SessionErrorKind::ParseUrl, + InternalSessionError::Expired(_) => SessionErrorKind::Expired, + InternalSessionError::OhttpEncapsulation(_) => SessionErrorKind::OhttpEncapsulation, + InternalSessionError::Hpke(_) => SessionErrorKind::Hpke, + InternalSessionError::DirectoryResponse(_) => SessionErrorKind::DirectoryResponse, + } + } + + /// Returns nested OHTTP details when the session failed during encapsulation. + pub fn ohttp_error(&self) -> Option { + match &self.0 { + InternalSessionError::OhttpEncapsulation(error) => Some(error.details()), + _ => None, + } + } + + /// Returns nested HPKE details when the session failed during decryption or encryption. + pub fn hpke_error(&self) -> Option { + match &self.0 { + InternalSessionError::Hpke(error) => Some(error.details()), + _ => None, + } + } + + /// Returns nested directory-response details when the relay or directory reply was invalid. + pub fn directory_response_error(&self) -> Option { + match &self.0 { + InternalSessionError::DirectoryResponse(error) => Some(error.details()), + _ => None, + } + } +} + impl fmt::Display for SessionError { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { use InternalSessionError::*; @@ -73,3 +122,29 @@ impl error::Error for SessionError { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::ohttp::{DirectoryResponseErrorKind, OhttpEncapsulationErrorKind}; + + #[test] + fn session_error_exposes_directory_response_details() { + let error = SessionError::from(InternalSessionError::DirectoryResponse( + DirectoryResponseError::OhttpDecapsulation(OhttpEncapsulationError::ParseUrl( + url::ParseError::EmptyHost, + )), + )); + + assert_eq!(error.kind(), SessionErrorKind::DirectoryResponse); + let directory = + error.directory_response_error().expect("directory response details should be present"); + assert_eq!(directory.kind(), DirectoryResponseErrorKind::OhttpDecapsulation); + assert_eq!( + directory.ohttp_error().expect("nested OHTTP details should be present").kind(), + OhttpEncapsulationErrorKind::ParseUrl + ); + assert!(error.hpke_error().is_none()); + assert!(error.ohttp_error().is_none()); + } +} diff --git a/payjoin/src/core/receive/v2/mod.rs b/payjoin/src/core/receive/v2/mod.rs index 307cd6a50..6069ac02f 100644 --- a/payjoin/src/core/receive/v2/mod.rs +++ b/payjoin/src/core/receive/v2/mod.rs @@ -32,7 +32,7 @@ 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::{SessionError, SessionErrorKind}; use serde::de::Deserializer; use serde::{Deserialize, Serialize}; pub use session::{ diff --git a/payjoin/src/core/send/v2/error.rs b/payjoin/src/core/send/v2/error.rs index 09fbf7266..b29dd7f50 100644 --- a/payjoin/src/core/send/v2/error.rs +++ b/payjoin/src/core/send/v2/error.rs @@ -2,6 +2,7 @@ use core::fmt; use crate::ohttp::DirectoryResponseError; use crate::time::Time; +use crate::{DirectoryResponseErrorDetails, HpkeErrorDetails, OhttpEncapsulationErrorDetails}; /// Error returned when request could not be created. /// @@ -11,6 +12,16 @@ use crate::time::Time; #[derive(Debug)] pub struct CreateRequestError(InternalCreateRequestError); +/// A stable classification for sender request-construction failures. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum CreateRequestErrorKind { + Url, + Hpke, + OhttpEncapsulation, + Expired, +} + #[derive(Debug)] pub(crate) enum InternalCreateRequestError { Url(crate::into_url::Error), @@ -55,10 +66,47 @@ impl From for CreateRequestError { } } +impl CreateRequestError { + /// Returns the stable classification of the request creation failure. + pub fn kind(&self) -> CreateRequestErrorKind { + match &self.0 { + InternalCreateRequestError::Url(_) => CreateRequestErrorKind::Url, + InternalCreateRequestError::Hpke(_) => CreateRequestErrorKind::Hpke, + InternalCreateRequestError::OhttpEncapsulation(_) => + CreateRequestErrorKind::OhttpEncapsulation, + InternalCreateRequestError::Expired(_) => CreateRequestErrorKind::Expired, + } + } + + /// Returns nested HPKE details when request creation failed during encryption. + pub fn hpke_error(&self) -> Option { + match &self.0 { + InternalCreateRequestError::Hpke(error) => Some(error.details()), + _ => None, + } + } + + /// Returns nested OHTTP details when request creation failed during encapsulation. + pub fn ohttp_error(&self) -> Option { + match &self.0 { + InternalCreateRequestError::OhttpEncapsulation(error) => Some(error.details()), + _ => None, + } + } +} + /// Error returned for v2-specific payload encapsulation errors. #[derive(Debug)] pub struct EncapsulationError(InternalEncapsulationError); +/// A stable classification for sender response encapsulation failures. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum EncapsulationErrorKind { + Hpke, + DirectoryResponse, +} + #[derive(Debug)] pub(crate) enum InternalEncapsulationError { /// The HPKE failed. @@ -98,3 +146,64 @@ impl From for super::ResponseError { super::InternalValidationError::V2Encapsulation(value.into()).into() } } + +impl EncapsulationError { + /// Returns the stable classification of the encapsulation failure. + pub fn kind(&self) -> EncapsulationErrorKind { + match &self.0 { + InternalEncapsulationError::Hpke(_) => EncapsulationErrorKind::Hpke, + InternalEncapsulationError::DirectoryResponse(_) => + EncapsulationErrorKind::DirectoryResponse, + } + } + + /// Returns nested HPKE details when response decapsulation failed cryptographically. + pub fn hpke_error(&self) -> Option { + match &self.0 { + InternalEncapsulationError::Hpke(error) => Some(error.details()), + _ => None, + } + } + + /// Returns nested directory-response details when the relay or directory reply was invalid. + pub fn directory_response_error(&self) -> Option { + match &self.0 { + InternalEncapsulationError::DirectoryResponse(error) => Some(error.details()), + _ => None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ohttp::{DirectoryResponseErrorKind, OhttpEncapsulationErrorKind}; + + #[test] + fn create_request_error_exposes_nested_ohttp_details() { + let error = CreateRequestError::from(InternalCreateRequestError::OhttpEncapsulation( + crate::ohttp::OhttpEncapsulationError::ParseUrl(url::ParseError::EmptyHost), + )); + + assert_eq!(error.kind(), CreateRequestErrorKind::OhttpEncapsulation); + assert_eq!( + error.ohttp_error().expect("OHTTP details should be present").kind(), + OhttpEncapsulationErrorKind::ParseUrl + ); + assert!(error.hpke_error().is_none()); + } + + #[test] + fn encapsulation_error_exposes_directory_response_details() { + let error = EncapsulationError::from(InternalEncapsulationError::DirectoryResponse( + DirectoryResponseError::UnexpectedStatusCode(http::StatusCode::BAD_GATEWAY), + )); + + assert_eq!(error.kind(), EncapsulationErrorKind::DirectoryResponse); + let directory = + error.directory_response_error().expect("directory response details should be present"); + assert_eq!(directory.kind(), DirectoryResponseErrorKind::UnexpectedStatusCode); + assert_eq!(directory.unexpected_status_code(), Some(502)); + assert!(error.hpke_error().is_none()); + } +} diff --git a/payjoin/src/core/send/v2/mod.rs b/payjoin/src/core/send/v2/mod.rs index 7a36553e3..2feccbfce 100644 --- a/payjoin/src/core/send/v2/mod.rs +++ b/payjoin/src/core/send/v2/mod.rs @@ -30,7 +30,9 @@ use bitcoin::hashes::{sha256, Hash}; use bitcoin::Address; -pub use error::{CreateRequestError, EncapsulationError}; +pub use error::{ + CreateRequestError, CreateRequestErrorKind, EncapsulationError, EncapsulationErrorKind, +}; use error::{InternalCreateRequestError, InternalEncapsulationError}; use ohttp::ClientResponse; use serde::{Deserialize, Serialize}; From d4eb19b9e3a940d9b0c09750428617bc57111848 Mon Sep 17 00:00:00 2001 From: chavic Date: Mon, 30 Mar 2026 12:40:51 +0200 Subject: [PATCH 2/3] Upgrade uniffi-dart Point the Dart generator dependency at the merged upstream fix\nfor object types ending in Error.\n\nThis replaces the temporary downstream workaround path with the\nupstream uniffi-dart revision that contains the naming fix, so the\nv2 transport bindings keep their original Error names. --- payjoin-ffi/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/payjoin-ffi/Cargo.toml b/payjoin-ffi/Cargo.toml index 5c30007fc..f87607e29 100644 --- a/payjoin-ffi/Cargo.toml +++ b/payjoin-ffi/Cargo.toml @@ -34,7 +34,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 From 06a4d29dc4c49a37a130798a79b0c2cc126447a9 Mon Sep 17 00:00:00 2001 From: chavic Date: Mon, 30 Mar 2026 12:41:15 +0200 Subject: [PATCH 3/3] Update lockfiles for uniffi-dart Refresh the minimal and recent lockfiles after upgrading\nuniffi-dart to the upstream revision with the Dart naming fix.\n\nThis keeps the branch's resolved dependency graph aligned with the\nCargo.toml change used by CI and local testing. --- Cargo-minimal.lock | 4 ++-- Cargo-recent.lock | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo-minimal.lock b/Cargo-minimal.lock index f05545562..c319ffb25 100644 --- a/Cargo-minimal.lock +++ b/Cargo-minimal.lock @@ -4696,7 +4696,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", @@ -4767,7 +4767,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 f05545562..c319ffb25 100644 --- a/Cargo-recent.lock +++ b/Cargo-recent.lock @@ -4696,7 +4696,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", @@ -4767,7 +4767,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",