Skip to content

Commit 8c17d48

Browse files
committed
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.
1 parent 9ed621f commit 8c17d48

8 files changed

Lines changed: 600 additions & 13 deletions

File tree

payjoin-ffi/src/receive/error.rs

Lines changed: 311 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ impl From<receive::Error> for ReceiverError {
3030
use ReceiverError::*;
3131

3232
match value {
33-
receive::Error::Protocol(e) => Protocol(Arc::new(ProtocolError(e))),
33+
receive::Error::Protocol(e) => Protocol(Arc::new(e.into())),
3434
receive::Error::Implementation(e) =>
3535
Implementation(Arc::new(ImplementationError::from(e))),
3636
_ => Unexpected,
@@ -135,9 +135,165 @@ impl From<payjoin::bitcoin::address::ParseError> for AddressParseError {
135135
/// 3. Support proper error propagation through the receiver stack
136136
/// 4. Provide errors according to BIP-78 JSON error specifications for return
137137
/// after conversion into [`JsonReply`]
138+
#[derive(Clone, Copy, Debug, PartialEq, Eq, uniffi::Enum)]
139+
pub enum ProtocolErrorKind {
140+
OriginalPayload,
141+
V1Request,
142+
V2Session,
143+
Other,
144+
}
145+
146+
impl From<receive::ProtocolErrorKind> for ProtocolErrorKind {
147+
fn from(value: receive::ProtocolErrorKind) -> Self {
148+
match value {
149+
receive::ProtocolErrorKind::OriginalPayload => Self::OriginalPayload,
150+
receive::ProtocolErrorKind::V1Request => Self::V1Request,
151+
receive::ProtocolErrorKind::V2Session => Self::V2Session,
152+
_ => Self::Other,
153+
}
154+
}
155+
}
156+
157+
#[derive(Clone, Copy, Debug, PartialEq, Eq, uniffi::Enum)]
158+
pub enum PayloadErrorKind {
159+
InvalidUtf8,
160+
InvalidPsbt,
161+
UnsupportedVersion,
162+
InvalidSenderFeeRate,
163+
InconsistentPsbt,
164+
PrevTxOut,
165+
MissingPayment,
166+
OriginalPsbtNotBroadcastable,
167+
InputOwned,
168+
InputSeen,
169+
PsbtBelowFeeRate,
170+
FeeTooHigh,
171+
Other,
172+
}
173+
174+
impl From<receive::PayloadErrorKind> for PayloadErrorKind {
175+
fn from(value: receive::PayloadErrorKind) -> Self {
176+
match value {
177+
receive::PayloadErrorKind::InvalidUtf8 => Self::InvalidUtf8,
178+
receive::PayloadErrorKind::InvalidPsbt => Self::InvalidPsbt,
179+
receive::PayloadErrorKind::UnsupportedVersion => Self::UnsupportedVersion,
180+
receive::PayloadErrorKind::InvalidSenderFeeRate => Self::InvalidSenderFeeRate,
181+
receive::PayloadErrorKind::InconsistentPsbt => Self::InconsistentPsbt,
182+
receive::PayloadErrorKind::PrevTxOut => Self::PrevTxOut,
183+
receive::PayloadErrorKind::MissingPayment => Self::MissingPayment,
184+
receive::PayloadErrorKind::OriginalPsbtNotBroadcastable =>
185+
Self::OriginalPsbtNotBroadcastable,
186+
receive::PayloadErrorKind::InputOwned => Self::InputOwned,
187+
receive::PayloadErrorKind::InputSeen => Self::InputSeen,
188+
receive::PayloadErrorKind::PsbtBelowFeeRate => Self::PsbtBelowFeeRate,
189+
receive::PayloadErrorKind::FeeTooHigh => Self::FeeTooHigh,
190+
_ => Self::Other,
191+
}
192+
}
193+
}
194+
195+
#[derive(Clone, Copy, Debug, PartialEq, Eq, uniffi::Enum)]
196+
pub enum RequestErrorKind {
197+
MissingHeader,
198+
InvalidContentType,
199+
InvalidContentLength,
200+
ContentLengthMismatch,
201+
Other,
202+
}
203+
204+
impl From<receive::v1::RequestErrorKind> for RequestErrorKind {
205+
fn from(value: receive::v1::RequestErrorKind) -> Self {
206+
match value {
207+
receive::v1::RequestErrorKind::MissingHeader => Self::MissingHeader,
208+
receive::v1::RequestErrorKind::InvalidContentType => Self::InvalidContentType,
209+
receive::v1::RequestErrorKind::InvalidContentLength => Self::InvalidContentLength,
210+
receive::v1::RequestErrorKind::ContentLengthMismatch => Self::ContentLengthMismatch,
211+
_ => Self::Other,
212+
}
213+
}
214+
}
215+
216+
#[derive(Clone, Copy, Debug, PartialEq, Eq, uniffi::Enum)]
217+
pub enum SessionErrorKind {
218+
ParseUrl,
219+
Expired,
220+
OhttpEncapsulation,
221+
Hpke,
222+
DirectoryResponse,
223+
Other,
224+
}
225+
226+
impl From<receive::v2::SessionErrorKind> for SessionErrorKind {
227+
fn from(value: receive::v2::SessionErrorKind) -> Self {
228+
match value {
229+
receive::v2::SessionErrorKind::ParseUrl => Self::ParseUrl,
230+
receive::v2::SessionErrorKind::Expired => Self::Expired,
231+
receive::v2::SessionErrorKind::OhttpEncapsulation => Self::OhttpEncapsulation,
232+
receive::v2::SessionErrorKind::Hpke => Self::Hpke,
233+
receive::v2::SessionErrorKind::DirectoryResponse => Self::DirectoryResponse,
234+
_ => Self::Other,
235+
}
236+
}
237+
}
238+
138239
#[derive(Debug, thiserror::Error, uniffi::Object)]
139-
#[error(transparent)]
140-
pub struct ProtocolError(#[from] receive::ProtocolError);
240+
#[error("{message}")]
241+
pub struct ProtocolError {
242+
kind: ProtocolErrorKind,
243+
message: String,
244+
reply: receive::JsonReply,
245+
payload_error: Option<Arc<PayloadError>>,
246+
request_error: Option<Arc<RequestError>>,
247+
session_error: Option<Arc<SessionError>>,
248+
}
249+
250+
impl From<receive::ProtocolError> for ProtocolError {
251+
fn from(value: receive::ProtocolError) -> Self {
252+
let kind = value.kind().into();
253+
let message = value.to_string();
254+
let reply = receive::JsonReply::from(&value);
255+
256+
match value {
257+
receive::ProtocolError::OriginalPayload(error) => Self {
258+
kind,
259+
message,
260+
reply,
261+
payload_error: Some(Arc::new(error.into())),
262+
request_error: None,
263+
session_error: None,
264+
},
265+
receive::ProtocolError::V1(error) => Self {
266+
kind,
267+
message,
268+
reply,
269+
payload_error: None,
270+
request_error: Some(Arc::new(error.into())),
271+
session_error: None,
272+
},
273+
receive::ProtocolError::V2(error) => Self {
274+
kind,
275+
message,
276+
reply,
277+
payload_error: None,
278+
request_error: None,
279+
session_error: Some(Arc::new(error.into())),
280+
},
281+
}
282+
}
283+
}
284+
285+
#[uniffi::export]
286+
impl ProtocolError {
287+
pub fn kind(&self) -> ProtocolErrorKind { self.kind }
288+
289+
pub fn message(&self) -> String { self.message.clone() }
290+
291+
pub fn payload_error(&self) -> Option<Arc<PayloadError>> { self.payload_error.clone() }
292+
293+
pub fn request_error(&self) -> Option<Arc<RequestError>> { self.request_error.clone() }
294+
295+
pub fn session_error(&self) -> Option<Arc<SessionError>> { self.session_error.clone() }
296+
}
141297

142298
/// The standard format for errors that can be replied as JSON.
143299
///
@@ -160,13 +316,97 @@ impl From<receive::JsonReply> for JsonReply {
160316
}
161317

162318
impl From<ProtocolError> for JsonReply {
163-
fn from(value: ProtocolError) -> Self { Self((&value.0).into()) }
319+
fn from(value: ProtocolError) -> Self { Self(value.reply) }
164320
}
165321

166322
/// Error that may occur during a v2 session typestate change
167323
#[derive(Debug, thiserror::Error, uniffi::Object)]
168-
#[error(transparent)]
169-
pub struct SessionError(#[from] receive::v2::SessionError);
324+
#[error("{message}")]
325+
pub struct SessionError {
326+
kind: SessionErrorKind,
327+
message: String,
328+
}
329+
330+
impl From<receive::v2::SessionError> for SessionError {
331+
fn from(value: receive::v2::SessionError) -> Self {
332+
Self { kind: value.kind().into(), message: value.to_string() }
333+
}
334+
}
335+
336+
#[uniffi::export]
337+
impl SessionError {
338+
pub fn kind(&self) -> SessionErrorKind { self.kind }
339+
340+
pub fn message(&self) -> String { self.message.clone() }
341+
}
342+
343+
/// Receiver original payload validation error exposed over FFI.
344+
#[derive(Debug, thiserror::Error, uniffi::Object)]
345+
#[error("{message}")]
346+
pub struct PayloadError {
347+
kind: PayloadErrorKind,
348+
message: String,
349+
supported_versions: Option<Vec<u64>>,
350+
}
351+
352+
impl From<receive::PayloadError> for PayloadError {
353+
fn from(value: receive::PayloadError) -> Self {
354+
Self {
355+
kind: value.kind().into(),
356+
message: value.to_string(),
357+
supported_versions: value.supported_versions(),
358+
}
359+
}
360+
}
361+
362+
#[uniffi::export]
363+
impl PayloadError {
364+
pub fn kind(&self) -> PayloadErrorKind { self.kind }
365+
366+
pub fn message(&self) -> String { self.message.clone() }
367+
368+
pub fn supported_versions(&self) -> Option<Vec<u64>> { self.supported_versions.clone() }
369+
}
370+
371+
/// Receiver v1 request validation error exposed over FFI.
372+
#[derive(Debug, thiserror::Error, uniffi::Object)]
373+
#[error("{message}")]
374+
pub struct RequestError {
375+
kind: RequestErrorKind,
376+
message: String,
377+
header_name: Option<String>,
378+
invalid_content_type: Option<String>,
379+
expected_content_length: Option<u64>,
380+
actual_content_length: Option<u64>,
381+
}
382+
383+
impl From<receive::v1::RequestError> for RequestError {
384+
fn from(value: receive::v1::RequestError) -> Self {
385+
Self {
386+
kind: value.kind().into(),
387+
message: value.to_string(),
388+
header_name: value.header_name().map(str::to_owned),
389+
invalid_content_type: value.invalid_content_type().map(str::to_owned),
390+
expected_content_length: value.expected_content_length().map(|value| value as u64),
391+
actual_content_length: value.actual_content_length().map(|value| value as u64),
392+
}
393+
}
394+
}
395+
396+
#[uniffi::export]
397+
impl RequestError {
398+
pub fn kind(&self) -> RequestErrorKind { self.kind }
399+
400+
pub fn message(&self) -> String { self.message.clone() }
401+
402+
pub fn header_name(&self) -> Option<String> { self.header_name.clone() }
403+
404+
pub fn invalid_content_type(&self) -> Option<String> { self.invalid_content_type.clone() }
405+
406+
pub fn expected_content_length(&self) -> Option<u64> { self.expected_content_length }
407+
408+
pub fn actual_content_length(&self) -> Option<u64> { self.actual_content_length }
409+
}
170410

171411
/// Protocol error raised during output substitution.
172412
#[derive(Debug, thiserror::Error, uniffi::Object)]
@@ -237,3 +477,68 @@ impl From<FfiValidationError> for InputPairError {
237477
pub struct ReceiverReplayError(
238478
#[from] payjoin::error::ReplayError<receive::v2::ReceiveSession, receive::v2::SessionEvent>,
239479
);
480+
481+
#[cfg(test)]
482+
mod tests {
483+
use super::*;
484+
485+
struct TestHeaders {
486+
content_type: Option<&'static str>,
487+
content_length: Option<String>,
488+
}
489+
490+
impl receive::v1::Headers for TestHeaders {
491+
fn get_header(&self, key: &str) -> Option<&str> {
492+
match key {
493+
"content-type" => self.content_type,
494+
"content-length" => self.content_length.as_deref(),
495+
_ => None,
496+
}
497+
}
498+
}
499+
500+
#[test]
501+
fn test_receiver_error_exposes_payload_kind() {
502+
let body = b"not-a-psbt";
503+
let headers = TestHeaders {
504+
content_type: Some("text/plain"),
505+
content_length: Some(body.len().to_string()),
506+
};
507+
let error = receive::v1::UncheckedOriginalPayload::from_request(body, "", headers)
508+
.expect_err("invalid PSBT should fail");
509+
510+
let ReceiverError::Protocol(protocol) = ReceiverError::from(error) else {
511+
panic!("expected protocol error");
512+
};
513+
514+
assert_eq!(protocol.kind(), ProtocolErrorKind::OriginalPayload);
515+
assert_eq!(
516+
protocol.payload_error().expect("payload error should be present").kind(),
517+
PayloadErrorKind::InvalidPsbt
518+
);
519+
assert!(protocol.request_error().is_none());
520+
assert!(protocol.session_error().is_none());
521+
}
522+
523+
#[test]
524+
fn test_receiver_error_exposes_request_details() {
525+
let body = b"abc";
526+
let headers = TestHeaders {
527+
content_type: Some("text/plain"),
528+
content_length: Some((body.len() + 1).to_string()),
529+
};
530+
let error = receive::v1::UncheckedOriginalPayload::from_request(body, "", headers)
531+
.expect_err("content length mismatch should fail");
532+
533+
let ReceiverError::Protocol(protocol) = ReceiverError::from(error) else {
534+
panic!("expected protocol error");
535+
};
536+
537+
assert_eq!(protocol.kind(), ProtocolErrorKind::V1Request);
538+
let request = protocol.request_error().expect("request error should be present");
539+
assert_eq!(request.kind(), RequestErrorKind::ContentLengthMismatch);
540+
assert_eq!(request.expected_content_length(), Some((body.len() + 1) as u64));
541+
assert_eq!(request.actual_content_length(), Some(body.len() as u64));
542+
assert!(protocol.payload_error().is_none());
543+
}
544+
}

payjoin-ffi/src/receive/mod.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@ use std::sync::{Arc, RwLock};
33

44
pub use error::{
55
AddressParseError, InputContributionError, InputPairError, JsonReply, OutputSubstitutionError,
6-
ProtocolError, PsbtInputError, ReceiverBuilderError, ReceiverError, SelectionError,
7-
SessionError,
6+
PayloadError, PayloadErrorKind, ProtocolError, ProtocolErrorKind, PsbtInputError,
7+
ReceiverBuilderError, ReceiverError, RequestError, RequestErrorKind, SelectionError,
8+
SessionError, SessionErrorKind,
89
};
910
use payjoin::bitcoin::consensus::Decodable;
1011
use payjoin::bitcoin::psbt::Psbt;

0 commit comments

Comments
 (0)