Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions payjoin-ffi/python/test/test_payjoin_unit_test.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import json
import unittest
import payjoin

Expand Down Expand Up @@ -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()
23 changes: 22 additions & 1 deletion payjoin-ffi/src/receive/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -163,6 +163,27 @@ impl From<ProtocolError> for JsonReply {
fn from(value: ProtocolError) -> Self { Self((&value.0).into()) }
}

#[uniffi::export]
impl JsonReply {
#[uniffi::constructor]
pub fn from_json(json: String) -> Result<Self, SerdeJsonError> {
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<String, SerdeJsonError> {
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<Vec<u64>> { 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)]
Expand Down
2 changes: 2 additions & 0 deletions payjoin-ffi/src/receive/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
9 changes: 9 additions & 0 deletions payjoin-ffi/src/send/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,15 @@ impl From<send::ResponseError> 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<Vec<u64>> { self.0.supported_versions() }
}

/// Error that may occur when the sender session event log is replayed
#[derive(Debug, thiserror::Error, uniffi::Object)]
#[error(transparent)]
Expand Down
132 changes: 127 additions & 5 deletions payjoin/src/core/receive/error.rs
Original file line number Diff line number Diff line change
@@ -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,
};
Expand Down Expand Up @@ -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<Self, serde_json::Error> {
#[derive(serde::Deserialize)]
struct WireJsonReply {
#[serde(rename = "errorCode")]
error_code: String,
message: String,
#[serde(default, deserialize_with = "deserialize_supported_versions")]
supported: Option<Vec<u64>>,
#[serde(flatten)]
extra: serde_json::Map<String, serde_json::Value>,
}

fn deserialize_supported_versions<'de, D>(
deserializer: D,
) -> Result<Option<Vec<u64>>, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::de::Error as _;

let supported = Option::<serde_json::Value>::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(|()| {
<serde_json::Error as serde::de::Error>::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<serde_json::Value>) -> Self {
self.extra.insert(key.to_string(), value.into());
Expand All @@ -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<Vec<u64>> {
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<Option<Vec<u64>>, 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::<Result<Vec<_>, _>>()
.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::<Vec<u64>>(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 {
Expand Down Expand Up @@ -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),
},
Expand Down Expand Up @@ -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],
})
);
}
}
3 changes: 3 additions & 0 deletions payjoin/src/core/receive/v2/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1172,6 +1172,9 @@ pub struct HasReplyableError {
}

impl Receiver<HasReplyableError> {
/// 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(
Expand Down
50 changes: 39 additions & 11 deletions payjoin/src/core/send/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u64> {
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::<Vec<u64>>(json).unwrap_or_default();
}

vec![]
}

let message = json
.as_object()
.and_then(|v| v.get("message"))
Expand All @@ -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::<Vec<u64>>())
.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 },
},
Expand Down Expand Up @@ -399,6 +408,15 @@ impl WellKnownError {
pub(crate) fn version_unsupported(message: String, supported: Vec<u64>) -> 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<Vec<u64>> { self.supported_versions.clone() }
}

#[cfg(test)]
Expand All @@ -410,20 +428,30 @@ 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]."
);
}
_ => 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!({
Expand Down
Loading