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", 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 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::{