From 8c17d48c2b7b9c2cb28be68c572d64ab00af9b5b Mon Sep 17 00:00:00 2001 From: chavic Date: Fri, 20 Mar 2026 12:45:49 +0200 Subject: [PATCH 1/3] Expose Receiver Error Kinds Add stable kind accessors for receiver protocol, payload,\nrequest, and session errors in the core crate and surface\nmatching snapshot objects over UniFFI.\n\nBindings could previously only tell that a receiver protocol\nerror happened, not whether the failure came from the original\npayload, the v1 HTTP request, or the v2 session machinery.\nThat made cross-language integrations parse display strings or\ntreat actionable failures as opaque.\n\nThe new accessors preserve the existing display behavior while\nmaking the branchable error shape available to Rust and FFI\ncallers. Focused tests cover the core accessors and the FFI\nmapping for payload and request failures. --- payjoin-ffi/src/receive/error.rs | 317 ++++++++++++++++++++++++++- payjoin-ffi/src/receive/mod.rs | 5 +- payjoin/src/core/receive/error.rs | 154 ++++++++++++- payjoin/src/core/receive/mod.rs | 4 +- payjoin/src/core/receive/v1/error.rs | 87 ++++++++ payjoin/src/core/receive/v1/mod.rs | 2 +- payjoin/src/core/receive/v2/error.rs | 42 ++++ payjoin/src/core/receive/v2/mod.rs | 2 +- 8 files changed, 600 insertions(+), 13 deletions(-) diff --git a/payjoin-ffi/src/receive/error.rs b/payjoin-ffi/src/receive/error.rs index 2ceb53b97..a98923aeb 100644 --- a/payjoin-ffi/src/receive/error.rs +++ b/payjoin-ffi/src/receive/error.rs @@ -30,7 +30,7 @@ impl From for ReceiverError { use ReceiverError::*; match value { - receive::Error::Protocol(e) => Protocol(Arc::new(ProtocolError(e))), + receive::Error::Protocol(e) => Protocol(Arc::new(e.into())), receive::Error::Implementation(e) => Implementation(Arc::new(ImplementationError::from(e))), _ => Unexpected, @@ -135,9 +135,165 @@ impl From for AddressParseError { /// 3. Support proper error propagation through the receiver stack /// 4. Provide errors according to BIP-78 JSON error specifications for return /// after conversion into [`JsonReply`] +#[derive(Clone, Copy, Debug, PartialEq, Eq, uniffi::Enum)] +pub enum ProtocolErrorKind { + OriginalPayload, + V1Request, + V2Session, + Other, +} + +impl From for ProtocolErrorKind { + fn from(value: receive::ProtocolErrorKind) -> Self { + match value { + receive::ProtocolErrorKind::OriginalPayload => Self::OriginalPayload, + receive::ProtocolErrorKind::V1Request => Self::V1Request, + receive::ProtocolErrorKind::V2Session => Self::V2Session, + _ => Self::Other, + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, uniffi::Enum)] +pub enum PayloadErrorKind { + InvalidUtf8, + InvalidPsbt, + UnsupportedVersion, + InvalidSenderFeeRate, + InconsistentPsbt, + PrevTxOut, + MissingPayment, + OriginalPsbtNotBroadcastable, + InputOwned, + InputSeen, + PsbtBelowFeeRate, + FeeTooHigh, + Other, +} + +impl From for PayloadErrorKind { + fn from(value: receive::PayloadErrorKind) -> Self { + match value { + receive::PayloadErrorKind::InvalidUtf8 => Self::InvalidUtf8, + receive::PayloadErrorKind::InvalidPsbt => Self::InvalidPsbt, + receive::PayloadErrorKind::UnsupportedVersion => Self::UnsupportedVersion, + receive::PayloadErrorKind::InvalidSenderFeeRate => Self::InvalidSenderFeeRate, + receive::PayloadErrorKind::InconsistentPsbt => Self::InconsistentPsbt, + receive::PayloadErrorKind::PrevTxOut => Self::PrevTxOut, + receive::PayloadErrorKind::MissingPayment => Self::MissingPayment, + receive::PayloadErrorKind::OriginalPsbtNotBroadcastable => + Self::OriginalPsbtNotBroadcastable, + receive::PayloadErrorKind::InputOwned => Self::InputOwned, + receive::PayloadErrorKind::InputSeen => Self::InputSeen, + receive::PayloadErrorKind::PsbtBelowFeeRate => Self::PsbtBelowFeeRate, + receive::PayloadErrorKind::FeeTooHigh => Self::FeeTooHigh, + _ => Self::Other, + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, uniffi::Enum)] +pub enum RequestErrorKind { + MissingHeader, + InvalidContentType, + InvalidContentLength, + ContentLengthMismatch, + Other, +} + +impl From for RequestErrorKind { + fn from(value: receive::v1::RequestErrorKind) -> Self { + match value { + receive::v1::RequestErrorKind::MissingHeader => Self::MissingHeader, + receive::v1::RequestErrorKind::InvalidContentType => Self::InvalidContentType, + receive::v1::RequestErrorKind::InvalidContentLength => Self::InvalidContentLength, + receive::v1::RequestErrorKind::ContentLengthMismatch => Self::ContentLengthMismatch, + _ => Self::Other, + } + } +} + +#[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 ProtocolError(#[from] receive::ProtocolError); +#[error("{message}")] +pub struct ProtocolError { + kind: ProtocolErrorKind, + message: String, + reply: receive::JsonReply, + payload_error: Option>, + request_error: Option>, + session_error: Option>, +} + +impl From for ProtocolError { + fn from(value: receive::ProtocolError) -> Self { + let kind = value.kind().into(); + let message = value.to_string(); + let reply = receive::JsonReply::from(&value); + + match value { + receive::ProtocolError::OriginalPayload(error) => Self { + kind, + message, + reply, + payload_error: Some(Arc::new(error.into())), + request_error: None, + session_error: None, + }, + receive::ProtocolError::V1(error) => Self { + kind, + message, + reply, + payload_error: None, + request_error: Some(Arc::new(error.into())), + session_error: None, + }, + receive::ProtocolError::V2(error) => Self { + kind, + message, + reply, + payload_error: None, + request_error: None, + session_error: Some(Arc::new(error.into())), + }, + } + } +} + +#[uniffi::export] +impl ProtocolError { + pub fn kind(&self) -> ProtocolErrorKind { self.kind } + + pub fn message(&self) -> String { self.message.clone() } + + pub fn payload_error(&self) -> Option> { self.payload_error.clone() } + + pub fn request_error(&self) -> Option> { self.request_error.clone() } + + pub fn session_error(&self) -> Option> { self.session_error.clone() } +} /// The standard format for errors that can be replied as JSON. /// @@ -160,13 +316,97 @@ impl From for JsonReply { } impl From for JsonReply { - fn from(value: ProtocolError) -> Self { Self((&value.0).into()) } + fn from(value: ProtocolError) -> Self { Self(value.reply) } } /// Error that may occur during a v2 session typestate change #[derive(Debug, thiserror::Error, uniffi::Object)] -#[error(transparent)] -pub struct SessionError(#[from] receive::v2::SessionError); +#[error("{message}")] +pub struct SessionError { + kind: SessionErrorKind, + message: String, +} + +impl From for SessionError { + fn from(value: receive::v2::SessionError) -> Self { + Self { kind: value.kind().into(), message: value.to_string() } + } +} + +#[uniffi::export] +impl SessionError { + pub fn kind(&self) -> SessionErrorKind { self.kind } + + pub fn message(&self) -> String { self.message.clone() } +} + +/// Receiver original payload validation error exposed over FFI. +#[derive(Debug, thiserror::Error, uniffi::Object)] +#[error("{message}")] +pub struct PayloadError { + kind: PayloadErrorKind, + message: String, + supported_versions: Option>, +} + +impl From for PayloadError { + fn from(value: receive::PayloadError) -> Self { + Self { + kind: value.kind().into(), + message: value.to_string(), + supported_versions: value.supported_versions(), + } + } +} + +#[uniffi::export] +impl PayloadError { + pub fn kind(&self) -> PayloadErrorKind { self.kind } + + pub fn message(&self) -> String { self.message.clone() } + + pub fn supported_versions(&self) -> Option> { self.supported_versions.clone() } +} + +/// Receiver v1 request validation error exposed over FFI. +#[derive(Debug, thiserror::Error, uniffi::Object)] +#[error("{message}")] +pub struct RequestError { + kind: RequestErrorKind, + message: String, + header_name: Option, + invalid_content_type: Option, + expected_content_length: Option, + actual_content_length: Option, +} + +impl From for RequestError { + fn from(value: receive::v1::RequestError) -> Self { + Self { + kind: value.kind().into(), + message: value.to_string(), + header_name: value.header_name().map(str::to_owned), + invalid_content_type: value.invalid_content_type().map(str::to_owned), + expected_content_length: value.expected_content_length().map(|value| value as u64), + actual_content_length: value.actual_content_length().map(|value| value as u64), + } + } +} + +#[uniffi::export] +impl RequestError { + pub fn kind(&self) -> RequestErrorKind { self.kind } + + pub fn message(&self) -> String { self.message.clone() } + + pub fn header_name(&self) -> Option { self.header_name.clone() } + + pub fn invalid_content_type(&self) -> Option { self.invalid_content_type.clone() } + + pub fn expected_content_length(&self) -> Option { self.expected_content_length } + + pub fn actual_content_length(&self) -> Option { self.actual_content_length } +} /// Protocol error raised during output substitution. #[derive(Debug, thiserror::Error, uniffi::Object)] @@ -237,3 +477,68 @@ impl From for InputPairError { pub struct ReceiverReplayError( #[from] payjoin::error::ReplayError, ); + +#[cfg(test)] +mod tests { + use super::*; + + struct TestHeaders { + content_type: Option<&'static str>, + content_length: Option, + } + + impl receive::v1::Headers for TestHeaders { + fn get_header(&self, key: &str) -> Option<&str> { + match key { + "content-type" => self.content_type, + "content-length" => self.content_length.as_deref(), + _ => None, + } + } + } + + #[test] + fn test_receiver_error_exposes_payload_kind() { + let body = b"not-a-psbt"; + let headers = TestHeaders { + content_type: Some("text/plain"), + content_length: Some(body.len().to_string()), + }; + let error = receive::v1::UncheckedOriginalPayload::from_request(body, "", headers) + .expect_err("invalid PSBT should fail"); + + let ReceiverError::Protocol(protocol) = ReceiverError::from(error) else { + panic!("expected protocol error"); + }; + + assert_eq!(protocol.kind(), ProtocolErrorKind::OriginalPayload); + assert_eq!( + protocol.payload_error().expect("payload error should be present").kind(), + PayloadErrorKind::InvalidPsbt + ); + assert!(protocol.request_error().is_none()); + assert!(protocol.session_error().is_none()); + } + + #[test] + fn test_receiver_error_exposes_request_details() { + let body = b"abc"; + let headers = TestHeaders { + content_type: Some("text/plain"), + content_length: Some((body.len() + 1).to_string()), + }; + let error = receive::v1::UncheckedOriginalPayload::from_request(body, "", headers) + .expect_err("content length mismatch should fail"); + + let ReceiverError::Protocol(protocol) = ReceiverError::from(error) else { + panic!("expected protocol error"); + }; + + assert_eq!(protocol.kind(), ProtocolErrorKind::V1Request); + let request = protocol.request_error().expect("request error should be present"); + assert_eq!(request.kind(), RequestErrorKind::ContentLengthMismatch); + assert_eq!(request.expected_content_length(), Some((body.len() + 1) as u64)); + assert_eq!(request.actual_content_length(), Some(body.len() as u64)); + assert!(protocol.payload_error().is_none()); + } +} diff --git a/payjoin-ffi/src/receive/mod.rs b/payjoin-ffi/src/receive/mod.rs index 3625362ee..4bc234ba0 100644 --- a/payjoin-ffi/src/receive/mod.rs +++ b/payjoin-ffi/src/receive/mod.rs @@ -3,8 +3,9 @@ use std::sync::{Arc, RwLock}; pub use error::{ AddressParseError, InputContributionError, InputPairError, JsonReply, OutputSubstitutionError, - ProtocolError, PsbtInputError, ReceiverBuilderError, ReceiverError, SelectionError, - SessionError, + PayloadError, PayloadErrorKind, ProtocolError, ProtocolErrorKind, PsbtInputError, + ReceiverBuilderError, ReceiverError, RequestError, RequestErrorKind, SelectionError, + SessionError, SessionErrorKind, }; use payjoin::bitcoin::consensus::Decodable; use payjoin::bitcoin::psbt::Psbt; diff --git a/payjoin/src/core/receive/error.rs b/payjoin/src/core/receive/error.rs index 5d9b02083..008107f86 100644 --- a/payjoin/src/core/receive/error.rs +++ b/payjoin/src/core/receive/error.rs @@ -68,6 +68,66 @@ pub enum ProtocolError { V2(crate::receive::v2::SessionError), } +/// A stable classification for receiver protocol errors. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum ProtocolErrorKind { + /// The sender's original PSBT payload was invalid. + OriginalPayload, + /// The receiver rejected the incoming BIP 78 v1 request. + #[cfg(feature = "v1")] + V1Request, + /// The receiver session failed before a replyable error could be produced. + #[cfg(feature = "v2")] + V2Session, +} + +impl ProtocolError { + /// Returns the stable classification of the protocol error. + pub fn kind(&self) -> ProtocolErrorKind { + match self { + Self::OriginalPayload(_) => ProtocolErrorKind::OriginalPayload, + #[cfg(feature = "v1")] + Self::V1(_) => ProtocolErrorKind::V1Request, + #[cfg(feature = "v2")] + Self::V2(_) => ProtocolErrorKind::V2Session, + } + } + + /// Returns the original payload validation error, if present. + pub fn payload_error(&self) -> Option<&PayloadError> { + match self { + Self::OriginalPayload(error) => Some(error), + #[cfg(feature = "v1")] + Self::V1(_) => None, + #[cfg(feature = "v2")] + Self::V2(_) => None, + } + } + + /// Returns the BIP 78 v1 request validation error, if present. + #[cfg(feature = "v1")] + pub fn request_error(&self) -> Option<&crate::receive::v1::RequestError> { + match self { + Self::OriginalPayload(_) => None, + Self::V1(error) => Some(error), + #[cfg(feature = "v2")] + Self::V2(_) => None, + } + } + + /// Returns the BIP 77 v2 session error, if present. + #[cfg(feature = "v2")] + pub fn session_error(&self) -> Option<&crate::receive::v2::SessionError> { + match self { + Self::OriginalPayload(_) => None, + #[cfg(feature = "v1")] + Self::V1(_) => None, + Self::V2(error) => Some(error), + } + } +} + /// The standard format for errors that can be replied as JSON. /// /// The JSON output includes the following fields: @@ -184,6 +244,70 @@ impl From for PayloadError { fn from(value: InternalPayloadError) -> Self { PayloadError(value) } } +/// A stable classification for receiver payload validation errors. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum PayloadErrorKind { + /// The payload bytes were not valid UTF-8. + InvalidUtf8, + /// The payload bytes were not a valid PSBT. + InvalidPsbt, + /// The sender requested an unsupported payjoin version. + UnsupportedVersion, + /// The sender supplied an invalid fee rate parameter. + InvalidSenderFeeRate, + /// The PSBT violated payjoin-specific invariants. + InconsistentPsbt, + /// The PSBT was missing previous transaction output data. + PrevTxOut, + /// The PSBT did not pay the receiver. + MissingPayment, + /// The original PSBT cannot be broadcast as a fallback transaction. + OriginalPsbtNotBroadcastable, + /// The sender attempted to spend a receiver-owned input. + InputOwned, + /// The original PSBT reused an input already seen before. + InputSeen, + /// The original PSBT fee rate was below the receiver minimum. + PsbtBelowFeeRate, + /// The receiver contribution would exceed the configured fee ceiling. + FeeTooHigh, +} + +impl PayloadError { + /// Returns the stable classification of the payload validation error. + pub fn kind(&self) -> PayloadErrorKind { + use InternalPayloadError::*; + + match &self.0 { + Utf8(_) => PayloadErrorKind::InvalidUtf8, + ParsePsbt(_) => PayloadErrorKind::InvalidPsbt, + SenderParams(super::optional_parameters::Error::UnknownVersion { .. }) => + PayloadErrorKind::UnsupportedVersion, + SenderParams(super::optional_parameters::Error::FeeRate) => + PayloadErrorKind::InvalidSenderFeeRate, + InconsistentPsbt(_) => PayloadErrorKind::InconsistentPsbt, + PrevTxOut(_) => PayloadErrorKind::PrevTxOut, + MissingPayment => PayloadErrorKind::MissingPayment, + OriginalPsbtNotBroadcastable => PayloadErrorKind::OriginalPsbtNotBroadcastable, + InputOwned(_) => PayloadErrorKind::InputOwned, + InputSeen(_) => PayloadErrorKind::InputSeen, + PsbtBelowFeeRate(_, _) => PayloadErrorKind::PsbtBelowFeeRate, + FeeTooHigh(_, _) => PayloadErrorKind::FeeTooHigh, + } + } + + /// Returns the supported versions when the sender requested an unsupported version. + pub fn supported_versions(&self) -> Option> { + match &self.0 { + InternalPayloadError::SenderParams( + super::optional_parameters::Error::UnknownVersion { supported_versions }, + ) => Some(supported_versions.iter().map(|version| *version as u64).collect()), + _ => None, + } + } +} + #[derive(Debug)] pub(crate) enum InternalPayloadError { /// The payload is not valid utf-8 @@ -434,7 +558,7 @@ impl From for InputContributionError { #[cfg(test)] mod tests { use super::*; - use crate::ImplementationError; + use crate::{ImplementationError, Version}; #[test] fn test_json_reply_from_implementation_error() { @@ -504,4 +628,32 @@ mod tests { assert_eq!(json["errorCode"], "original-psbt-rejected"); assert_eq!(json["message"], "Missing payment."); } + + #[test] + fn test_protocol_error_accessors() { + let protocol_error = + ProtocolError::OriginalPayload(PayloadError(InternalPayloadError::MissingPayment)); + + assert_eq!(protocol_error.kind(), ProtocolErrorKind::OriginalPayload); + assert_eq!( + protocol_error.payload_error().expect("payload error should be present").kind(), + PayloadErrorKind::MissingPayment + ); + #[cfg(feature = "v1")] + assert!(protocol_error.request_error().is_none()); + #[cfg(feature = "v2")] + assert!(protocol_error.session_error().is_none()); + } + + #[test] + fn test_payload_error_supported_versions_accessor() { + let payload_error = PayloadError(InternalPayloadError::SenderParams( + crate::receive::optional_parameters::Error::UnknownVersion { + supported_versions: &[Version::One, Version::Two], + }, + )); + + assert_eq!(payload_error.kind(), PayloadErrorKind::UnsupportedVersion); + assert_eq!(payload_error.supported_versions(), Some(vec![1, 2])); + } } diff --git a/payjoin/src/core/receive/mod.rs b/payjoin/src/core/receive/mod.rs index a2be3bc0e..ffa2b04eb 100644 --- a/payjoin/src/core/receive/mod.rs +++ b/payjoin/src/core/receive/mod.rs @@ -18,8 +18,8 @@ use bitcoin::{ }; pub(crate) use error::InternalPayloadError; pub use error::{ - Error, InputContributionError, JsonReply, OutputSubstitutionError, PayloadError, ProtocolError, - SelectionError, + Error, InputContributionError, JsonReply, OutputSubstitutionError, PayloadError, + PayloadErrorKind, ProtocolError, ProtocolErrorKind, SelectionError, }; use optional_parameters::Params; use serde::{Deserialize, Serialize}; diff --git a/payjoin/src/core/receive/v1/error.rs b/payjoin/src/core/receive/v1/error.rs index ad438230f..af18bbf15 100644 --- a/payjoin/src/core/receive/v1/error.rs +++ b/payjoin/src/core/receive/v1/error.rs @@ -14,6 +14,20 @@ use std::error; #[derive(Debug)] pub struct RequestError(InternalRequestError); +/// A stable classification for BIP 78 v1 request validation errors. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum RequestErrorKind { + /// A required HTTP header was missing. + MissingHeader, + /// The content type was invalid. + InvalidContentType, + /// The content length header could not be parsed. + InvalidContentLength, + /// The declared content length did not match the request body. + ContentLengthMismatch, +} + #[derive(Debug)] pub(crate) enum InternalRequestError { /// A required HTTP header is missing from the request @@ -30,6 +44,51 @@ impl From for RequestError { fn from(value: InternalRequestError) -> Self { RequestError(value) } } +impl RequestError { + /// Returns the stable classification of the request validation error. + pub fn kind(&self) -> RequestErrorKind { + match &self.0 { + InternalRequestError::MissingHeader(_) => RequestErrorKind::MissingHeader, + InternalRequestError::InvalidContentType(_) => RequestErrorKind::InvalidContentType, + InternalRequestError::InvalidContentLength(_) => RequestErrorKind::InvalidContentLength, + InternalRequestError::ContentLengthMismatch { .. } => + RequestErrorKind::ContentLengthMismatch, + } + } + + /// Returns the missing header name, if the request failed due to a missing header. + pub fn header_name(&self) -> Option<&str> { + match &self.0 { + InternalRequestError::MissingHeader(header) => Some(header), + _ => None, + } + } + + /// Returns the invalid content type, if present. + pub fn invalid_content_type(&self) -> Option<&str> { + match &self.0 { + InternalRequestError::InvalidContentType(content_type) => Some(content_type.as_str()), + _ => None, + } + } + + /// Returns the declared content length when the request body length mismatched. + pub fn expected_content_length(&self) -> Option { + match &self.0 { + InternalRequestError::ContentLengthMismatch { expected, .. } => Some(*expected), + _ => None, + } + } + + /// Returns the actual request body length when the request body length mismatched. + pub fn actual_content_length(&self) -> Option { + match &self.0 { + InternalRequestError::ContentLengthMismatch { actual, .. } => Some(*actual), + _ => None, + } + } +} + impl From for super::ProtocolError { fn from(e: InternalRequestError) -> Self { super::ProtocolError::V1(e.into()) } } @@ -57,3 +116,31 @@ impl error::Error for RequestError { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_request_error_missing_header_accessor() { + let error = RequestError::from(InternalRequestError::MissingHeader("Content-Type")); + + assert_eq!(error.kind(), RequestErrorKind::MissingHeader); + assert_eq!(error.header_name(), Some("Content-Type")); + assert_eq!(error.invalid_content_type(), None); + assert_eq!(error.expected_content_length(), None); + assert_eq!(error.actual_content_length(), None); + } + + #[test] + fn test_request_error_content_length_accessor() { + let error = RequestError::from(InternalRequestError::ContentLengthMismatch { + expected: 42, + actual: 41, + }); + + assert_eq!(error.kind(), RequestErrorKind::ContentLengthMismatch); + assert_eq!(error.expected_content_length(), Some(42)); + assert_eq!(error.actual_content_length(), Some(41)); + } +} diff --git a/payjoin/src/core/receive/v1/mod.rs b/payjoin/src/core/receive/v1/mod.rs index 7c2e937c3..6b36bd58c 100644 --- a/payjoin/src/core/receive/v1/mod.rs +++ b/payjoin/src/core/receive/v1/mod.rs @@ -34,7 +34,7 @@ mod error; use bitcoin::OutPoint; pub(crate) use error::InternalRequestError; -pub use error::RequestError; +pub use error::{RequestError, RequestErrorKind}; use super::*; pub use crate::receive::common::{WantsFeeRange, WantsInputs, WantsOutputs}; diff --git a/payjoin/src/core/receive/v2/error.rs b/payjoin/src/core/receive/v2/error.rs index ec9062c51..38538885b 100644 --- a/payjoin/src/core/receive/v2/error.rs +++ b/payjoin/src/core/receive/v2/error.rs @@ -14,10 +14,39 @@ use crate::time::Time; #[derive(Debug)] pub struct SessionError(pub(super) InternalSessionError); +/// A stable classification for BIP 77 v2 session errors. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum SessionErrorKind { + /// URL parsing failed. + ParseUrl, + /// The session expired. + Expired, + /// OHTTP encapsulation failed. + OhttpEncapsulation, + /// HPKE processing failed. + Hpke, + /// The directory returned a malformed or unexpected response. + DirectoryResponse, +} + impl From for SessionError { fn from(value: InternalSessionError) -> Self { SessionError(value) } } +impl SessionError { + /// Returns the stable classification of the session error. + 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, + } + } +} + impl From for Error { fn from(e: InternalSessionError) -> Self { Error::Protocol(ProtocolError::V2(e.into())) } } @@ -73,3 +102,16 @@ impl error::Error for SessionError { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_session_error_kind_accessor() { + let expiration = Time::now(); + let error = SessionError::from(InternalSessionError::Expired(expiration)); + + assert_eq!(error.kind(), SessionErrorKind::Expired); + } +} 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::{ From 4d3f6021279f7e64748e9b78c783117fc34f99c2 Mon Sep 17 00:00:00 2001 From: chavic Date: Mon, 30 Mar 2026 12:39:25 +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\nreceiver bindings can 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 4f8aae40963b7945bebc1592a906a81d4a068b4c Mon Sep 17 00:00:00 2001 From: chavic Date: Mon, 30 Mar 2026 12:40:18 +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",