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!({