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/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};