From 6cbbd038a5160709088403af8dc208d8abee6912 Mon Sep 17 00:00:00 2001 From: chavic Date: Thu, 19 Mar 2026 18:26:50 +0200 Subject: [PATCH] Expose JSON reply accessors Add typed accessors for receiver JsonReply and sender WellKnownError across the core and FFI layers. This lets bindings inspect structured error data without reparsing raw JSON strings. Fix the receiver-side version-unsupported reply shape at the same time so supported versions are emitted as a JSON array, while still accepting the legacy string form for backward compatibility. Add focused Rust and Python tests to lock in both the new accessors and the wire-format compatibility. --- .../python/test/test_payjoin_unit_test.py | 37 +++++ payjoin-ffi/src/receive/error.rs | 23 ++- payjoin-ffi/src/receive/mod.rs | 2 + payjoin-ffi/src/send/error.rs | 9 ++ payjoin/src/core/receive/error.rs | 132 +++++++++++++++++- payjoin/src/core/receive/v2/mod.rs | 3 + payjoin/src/core/send/error.rs | 50 +++++-- 7 files changed, 239 insertions(+), 17 deletions(-) diff --git a/payjoin-ffi/python/test/test_payjoin_unit_test.py b/payjoin-ffi/python/test/test_payjoin_unit_test.py index f4eab2a04..8513aac3d 100644 --- a/payjoin-ffi/python/test/test_payjoin_unit_test.py +++ b/payjoin-ffi/python/test/test_payjoin_unit_test.py @@ -1,3 +1,4 @@ +import json import unittest import payjoin @@ -230,5 +231,41 @@ def test_sender_builder_rejects_bad_psbt(self): payjoin.SenderBuilder("not-a-psbt", uri) +class TestJsonReplyAccessors(unittest.TestCase): + def test_json_reply_round_trips_supported_versions(self): + reply = payjoin.JsonReply.from_json( + json.dumps( + { + "errorCode": "version-unsupported", + "message": "custom message here", + "supported": [1, 2], + "debug": "keep-me", + } + ) + ) + + self.assertEqual(reply.error_code(), "version-unsupported") + self.assertEqual(reply.message(), "custom message here") + self.assertEqual(reply.status_code(), 400) + self.assertEqual(reply.supported_versions(), [1, 2]) + self.assertEqual( + json.loads(reply.to_json()), + { + "errorCode": "version-unsupported", + "message": "custom message here", + "supported": [1, 2], + "debug": "keep-me", + }, + ) + + def test_json_reply_accepts_legacy_supported_string(self): + reply = payjoin.JsonReply.from_json( + '{"errorCode":"version-unsupported","message":"custom message here","supported":"[1,2]"}' + ) + + self.assertEqual(reply.supported_versions(), [1, 2]) + self.assertEqual(json.loads(reply.to_json())["supported"], [1, 2]) + + if __name__ == "__main__": unittest.main() diff --git a/payjoin-ffi/src/receive/error.rs b/payjoin-ffi/src/receive/error.rs index 2ceb53b97..f064d0478 100644 --- a/payjoin-ffi/src/receive/error.rs +++ b/payjoin-ffi/src/receive/error.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use payjoin::receive; -use crate::error::{FfiValidationError, ImplementationError}; +use crate::error::{FfiValidationError, ImplementationError, SerdeJsonError}; use crate::uri::error::IntoUrlError; /// The top-level error type for the payjoin receiver @@ -163,6 +163,27 @@ impl From for JsonReply { fn from(value: ProtocolError) -> Self { Self((&value.0).into()) } } +#[uniffi::export] +impl JsonReply { + #[uniffi::constructor] + pub fn from_json(json: String) -> Result { + let value: serde_json::Value = serde_json::from_str(&json)?; + receive::JsonReply::from_json(value).map(Self).map_err(Into::into) + } + + pub fn to_json(&self) -> Result { + serde_json::to_string(&self.0.to_json()).map_err(Into::into) + } + + pub fn error_code(&self) -> String { self.0.error_code() } + + pub fn message(&self) -> String { self.0.message().to_string() } + + pub fn supported_versions(&self) -> Option> { self.0.supported_versions() } + + pub fn status_code(&self) -> u16 { self.0.status_code() } +} + /// Error that may occur during a v2 session typestate change #[derive(Debug, thiserror::Error, uniffi::Object)] #[error(transparent)] diff --git a/payjoin-ffi/src/receive/mod.rs b/payjoin-ffi/src/receive/mod.rs index 3625362ee..5faeed775 100644 --- a/payjoin-ffi/src/receive/mod.rs +++ b/payjoin-ffi/src/receive/mod.rs @@ -1254,6 +1254,8 @@ impl HasReplyableErrorTransition { #[uniffi::export] impl HasReplyableError { + pub fn error_reply(&self) -> JsonReply { self.0.error_reply().clone().into() } + pub fn create_error_request( &self, ohttp_relay: String, diff --git a/payjoin-ffi/src/send/error.rs b/payjoin-ffi/src/send/error.rs index ed5438cde..8b12d966f 100644 --- a/payjoin-ffi/src/send/error.rs +++ b/payjoin-ffi/src/send/error.rs @@ -109,6 +109,15 @@ impl From for ResponseError { #[error(transparent)] pub struct WellKnownError(#[from] send::WellKnownError); +#[uniffi::export] +impl WellKnownError { + pub fn code(&self) -> String { self.0.code() } + + pub fn message(&self) -> String { self.0.message().to_string() } + + pub fn supported_versions(&self) -> Option> { self.0.supported_versions() } +} + /// Error that may occur when the sender session event log is replayed #[derive(Debug, thiserror::Error, uniffi::Object)] #[error(transparent)] diff --git a/payjoin/src/core/receive/error.rs b/payjoin/src/core/receive/error.rs index 5d9b02083..455fc4c50 100644 --- a/payjoin/src/core/receive/error.rs +++ b/payjoin/src/core/receive/error.rs @@ -1,5 +1,8 @@ +use std::str::FromStr; use std::{error, fmt}; +use serde::Deserialize; + use crate::error_codes::ErrorCode::{ self, NotEnoughMoney, OriginalPsbtRejected, Unavailable, VersionUnsupported, }; @@ -93,6 +96,47 @@ impl JsonReply { Self { error_code, message: message.to_string(), extra: serde_json::Map::new() } } + /// Parse a reply from the BIP78 wire JSON shape. + pub fn from_json(json: serde_json::Value) -> Result { + #[derive(serde::Deserialize)] + struct WireJsonReply { + #[serde(rename = "errorCode")] + error_code: String, + message: String, + #[serde(default, deserialize_with = "deserialize_supported_versions")] + supported: Option>, + #[serde(flatten)] + extra: serde_json::Map, + } + + fn deserialize_supported_versions<'de, D>( + deserializer: D, + ) -> Result>, D::Error> + where + D: serde::Deserializer<'de>, + { + use serde::de::Error as _; + + let supported = Option::::deserialize(deserializer)?; + parse_supported_versions_value(supported.as_ref()).map_err(D::Error::custom) + } + + let wire: WireJsonReply = serde_json::from_value(json)?; + let error_code = ErrorCode::from_str(&wire.error_code).map_err(|()| { + ::custom(format!( + "invalid errorCode: {}", + wire.error_code + )) + })?; + + let mut reply = Self::new(error_code, wire.message); + if let Some(supported) = wire.supported { + reply = reply.with_extra("supported", serde_json::to_value(supported)?); + } + reply.extra.extend(wire.extra); + Ok(reply) + } + /// Add an additional field to the JSON response pub fn with_extra(mut self, key: &str, value: impl Into) -> Self { self.extra.insert(key.to_string(), value.into()); @@ -119,6 +163,45 @@ impl JsonReply { } .as_u16() } + + /// Return the wire-format error code, such as `version-unsupported`. + pub fn error_code(&self) -> String { self.error_code.to_string() } + + /// Return the human-readable message. + pub fn message(&self) -> &str { &self.message } + + /// Return the supported versions when present. + pub fn supported_versions(&self) -> Option> { + self.extra + .get("supported") + .and_then(|value| parse_supported_versions_value(Some(value)).ok().flatten()) + } +} + +fn parse_supported_versions_value( + value: Option<&serde_json::Value>, +) -> Result>, String> { + use serde_json::Value; + + let Some(value) = value else { + return Ok(None); + }; + + match value { + Value::Array(items) => items + .iter() + .map(|item| { + item.as_u64() + .ok_or_else(|| "supported versions must be an array of u64 values".to_string()) + }) + .collect::, _>>() + .map(Some), + // Backward compatibility for the old broken wire shape where `supported` + // was serialized as a JSON string containing the array. + Value::String(json) => + serde_json::from_str::>(json).map(Some).map_err(|err| err.to_string()), + other => Err(format!("unsupported versions must be an array or JSON string, got {other}")), + } } impl From<&ProtocolError> for JsonReply { @@ -235,12 +318,12 @@ impl From<&PayloadError> for JsonReply { FeeTooHigh(_, _) => JsonReply::new(NotEnoughMoney, e), SenderParams(e) => match e { - super::optional_parameters::Error::UnknownVersion { supported_versions } => { - let supported_versions_json = - serde_json::to_string(supported_versions).unwrap_or_default(); + super::optional_parameters::Error::UnknownVersion { supported_versions } => JsonReply::new(VersionUnsupported, "This version of payjoin is not supported.") - .with_extra("supported", supported_versions_json) - } + .with_extra( + "supported", + serde_json::to_value(supported_versions).unwrap_or_default(), + ), super::optional_parameters::Error::FeeRate => JsonReply::new(OriginalPsbtRejected, e), }, @@ -504,4 +587,43 @@ mod tests { assert_eq!(json["errorCode"], "original-psbt-rejected"); assert_eq!(json["message"], "Missing payment."); } + + #[test] + fn test_json_reply_supported_versions_are_arrays() { + let supported_versions = &[crate::Version::One, crate::Version::Two]; + let reply = JsonReply::new(ErrorCode::VersionUnsupported, "unsupported") + .with_extra("supported", serde_json::to_value(supported_versions).unwrap()); + + assert_eq!(reply.supported_versions(), Some(vec![1, 2])); + assert_eq!( + reply.to_json(), + serde_json::json!({ + "errorCode": "version-unsupported", + "message": "unsupported", + "supported": [1, 2], + }) + ); + } + + #[test] + fn test_json_reply_from_json_accepts_legacy_supported_string() { + let reply = JsonReply::from_json(serde_json::json!({ + "errorCode": "version-unsupported", + "message": "unsupported", + "supported": "[1,2]", + })) + .expect("legacy supported string should parse"); + + assert_eq!(reply.error_code(), "version-unsupported"); + assert_eq!(reply.message(), "unsupported"); + assert_eq!(reply.supported_versions(), Some(vec![1, 2])); + assert_eq!( + reply.to_json(), + serde_json::json!({ + "errorCode": "version-unsupported", + "message": "unsupported", + "supported": [1, 2], + }) + ); + } } diff --git a/payjoin/src/core/receive/v2/mod.rs b/payjoin/src/core/receive/v2/mod.rs index 307cd6a50..83cccd4a2 100644 --- a/payjoin/src/core/receive/v2/mod.rs +++ b/payjoin/src/core/receive/v2/mod.rs @@ -1172,6 +1172,9 @@ pub struct HasReplyableError { } impl Receiver { + /// Return the replyable error payload that will be sent back to the sender. + pub fn error_reply(&self) -> &JsonReply { &self.state.error_reply } + /// Construct an OHTTP Encapsulated HTTP POST request to return /// a Receiver Error Response pub fn create_error_request( diff --git a/payjoin/src/core/send/error.rs b/payjoin/src/core/send/error.rs index 7e70286b5..73c787789 100644 --- a/payjoin/src/core/send/error.rs +++ b/payjoin/src/core/send/error.rs @@ -330,6 +330,22 @@ impl fmt::Debug for ResponseError { impl ResponseError { pub(crate) fn from_json(json: serde_json::Value) -> Self { + fn supported_versions(json: &serde_json::Value) -> Vec { + let Some(supported) = json.as_object().and_then(|v| v.get("supported")) else { + return vec![]; + }; + + if let Some(array) = supported.as_array() { + return array.iter().filter_map(|v| v.as_u64()).collect(); + } + + if let Some(json) = supported.as_str() { + return serde_json::from_str::>(json).unwrap_or_default(); + } + + vec![] + } + let message = json .as_object() .and_then(|v| v.get("message")) @@ -341,15 +357,8 @@ impl ResponseError { match error_code { Some(code) => match ErrorCode::from_str(code) { - Ok(ErrorCode::VersionUnsupported) => { - let supported = json - .as_object() - .and_then(|v| v.get("supported")) - .and_then(|v| v.as_array()) - .map(|array| array.iter().filter_map(|v| v.as_u64()).collect::>()) - .unwrap_or_default(); - WellKnownError::version_unsupported(message, supported).into() - } + Ok(ErrorCode::VersionUnsupported) => + WellKnownError::version_unsupported(message, supported_versions(&json)).into(), Ok(code) => WellKnownError::new(code, message).into(), Err(_) => Self::Unrecognized { error_code: code.to_string(), message }, }, @@ -399,6 +408,15 @@ impl WellKnownError { pub(crate) fn version_unsupported(message: String, supported: Vec) -> Self { Self { code: ErrorCode::VersionUnsupported, message, supported_versions: Some(supported) } } + + /// Return the wire-format error code, such as `version-unsupported`. + pub fn code(&self) -> String { self.code.to_string() } + + /// Return the protocol message associated with the error. + pub fn message(&self) -> &str { &self.message } + + /// Return the advertised supported versions when present. + pub fn supported_versions(&self) -> Option> { self.supported_versions.clone() } } #[cfg(test)] @@ -410,10 +428,11 @@ mod tests { #[test] fn test_parse_json() { let known_str_error = r#"{"errorCode":"version-unsupported", "message":"custom message here", "supported": [1, 2]}"#; - match ResponseError::parse(known_str_error) { + match ResponseError::from_json(serde_json::from_str(known_str_error).unwrap()) { ResponseError::WellKnown(e) => { assert_eq!(e.code, ErrorCode::VersionUnsupported); assert_eq!(e.message, "custom message here"); + assert_eq!(e.supported_versions(), Some(vec![1, 2])); assert_eq!( e.to_string(), "This version of payjoin is not supported. Use version [1, 2]." @@ -421,9 +440,18 @@ mod tests { } _ => panic!("Expected WellKnown error"), }; + let legacy_supported_string = r#"{"errorCode":"version-unsupported", "message":"custom message here", "supported": "[1,2]"}"#; + match ResponseError::from_json(serde_json::from_str(legacy_supported_string).unwrap()) { + ResponseError::WellKnown(e) => { + assert_eq!(e.code(), "version-unsupported"); + assert_eq!(e.message(), "custom message here"); + assert_eq!(e.supported_versions(), Some(vec![1, 2])); + } + _ => panic!("Expected WellKnown error"), + }; let unrecognized_error = r#"{"errorCode":"random", "message":"random"}"#; assert!(matches!( - ResponseError::parse(unrecognized_error), + ResponseError::from_json(serde_json::from_str(unrecognized_error).unwrap()), ResponseError::Unrecognized { .. } )); let invalid_json_error = json!({