Skip to content

Commit 8f68d8d

Browse files
authored
Reply json (payjoin#606)
Fix payjoin#605
2 parents 752fcee + 2041f54 commit 8f68d8d

File tree

8 files changed

+200
-149
lines changed

8 files changed

+200
-149
lines changed

payjoin-cli/src/app/v2.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -287,7 +287,8 @@ async fn handle_recoverable_error(
287287
mut receiver: UncheckedProposal,
288288
ohttp_relay: &payjoin::Url,
289289
) -> anyhow::Error {
290-
let (err_req, err_ctx) = match receiver.extract_err_req(&e, ohttp_relay) {
290+
let to_return = anyhow!("Replied with error: {}", e);
291+
let (err_req, err_ctx) = match receiver.extract_err_req(&e.into(), ohttp_relay) {
291292
Ok(req_ctx) => req_ctx,
292293
Err(e) => return anyhow!("Failed to extract error request: {}", e),
293294
};
@@ -306,7 +307,7 @@ async fn handle_recoverable_error(
306307
return anyhow!("Failed to process error response: {}", e);
307308
}
308309

309-
e.into()
310+
to_return
310311
}
311312

312313
fn try_contributing_inputs(

payjoin/src/error_codes.rs

Lines changed: 39 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,45 @@
11
//! Well-known error codes as defined in BIP-78
22
//! See: <https://github.com/bitcoin/bips/blob/master/bip-0078.mediawiki#receivers-well-known-errors>
33
4-
/// The payjoin endpoint is not available for now.
5-
pub const UNAVAILABLE: &str = "unavailable";
4+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5+
pub enum ErrorCode {
6+
/// The payjoin endpoint is not available for now.
7+
Unavailable,
8+
/// The receiver added some inputs but could not bump the fee of the payjoin proposal.
9+
NotEnoughMoney,
10+
/// This version of payjoin is not supported.
11+
VersionUnsupported,
12+
/// The receiver rejected the original PSBT.
13+
OriginalPsbtRejected,
14+
}
615

7-
/// The receiver added some inputs but could not bump the fee of the payjoin proposal.
8-
pub const NOT_ENOUGH_MONEY: &str = "not-enough-money";
16+
impl ErrorCode {
17+
pub const fn as_str(&self) -> &'static str {
18+
match self {
19+
Self::Unavailable => "unavailable",
20+
Self::NotEnoughMoney => "not-enough-money",
21+
Self::VersionUnsupported => "version-unsupported",
22+
Self::OriginalPsbtRejected => "original-psbt-rejected",
23+
}
24+
}
25+
}
926

10-
/// This version of payjoin is not supported.
11-
pub const VERSION_UNSUPPORTED: &str = "version-unsupported";
27+
impl core::str::FromStr for ErrorCode {
28+
type Err = ();
1229

13-
/// The receiver rejected the original PSBT.
14-
pub const ORIGINAL_PSBT_REJECTED: &str = "original-psbt-rejected";
30+
fn from_str(s: &str) -> Result<Self, Self::Err> {
31+
match s {
32+
"unavailable" => Ok(Self::Unavailable),
33+
"not-enough-money" => Ok(Self::NotEnoughMoney),
34+
"version-unsupported" => Ok(Self::VersionUnsupported),
35+
"original-psbt-rejected" => Ok(Self::OriginalPsbtRejected),
36+
_ => Err(()),
37+
}
38+
}
39+
}
40+
41+
impl core::fmt::Display for ErrorCode {
42+
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
43+
f.write_str(self.as_str())
44+
}
45+
}

payjoin/src/receive/error.rs

Lines changed: 62 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
use std::{error, fmt};
22

3-
use crate::error_codes::{
4-
NOT_ENOUGH_MONEY, ORIGINAL_PSBT_REJECTED, UNAVAILABLE, VERSION_UNSUPPORTED,
3+
use crate::error_codes::ErrorCode::{
4+
self, NotEnoughMoney, OriginalPsbtRejected, Unavailable, VersionUnsupported,
55
};
66

77
pub type ImplementationError = Box<dyn error::Error + Send + Sync>;
@@ -48,7 +48,8 @@ impl error::Error for Error {
4848
/// 1. Provide structured error responses for protocol-level failures
4949
/// 2. Hide implementation details of external errors for security
5050
/// 3. Support proper error propagation through the receiver stack
51-
/// 4. Provide errors according to BIP-78 JSON error specifications for return using [`JsonError::to_json`]
51+
/// 4. Provide errors according to BIP-78 JSON error specifications for return
52+
/// after conversion into [`JsonReply`]
5253
#[derive(Debug)]
5354
pub enum ReplyableError {
5455
/// Error arising from validation of the original PSBT payload
@@ -62,41 +63,57 @@ pub enum ReplyableError {
6263
Implementation(ImplementationError),
6364
}
6465

65-
/// A trait for errors that can be serialized to JSON in a standardized format.
66+
/// The standard format for errors that can be replied as JSON.
6667
///
67-
/// The JSON output follows the structure:
68+
/// The JSON output includes the following fields:
6869
/// ```json
6970
/// {
7071
/// "errorCode": "specific-error-code",
7172
/// "message": "Human readable error message"
7273
/// }
7374
/// ```
74-
pub trait JsonError {
75-
/// Converts the error into a JSON string representation.
76-
fn to_json(&self) -> String;
75+
pub struct JsonReply {
76+
/// The error code
77+
error_code: ErrorCode,
78+
/// The error message to be displayed only in debug logs
79+
message: String,
80+
/// Additional fields to be included in the JSON response
81+
extra: serde_json::Map<String, serde_json::Value>,
7782
}
7883

79-
impl JsonError for ReplyableError {
80-
fn to_json(&self) -> String {
81-
match self {
82-
Self::Payload(e) => e.to_json(),
83-
#[cfg(feature = "v1")]
84-
Self::V1(e) => e.to_json(),
85-
Self::Implementation(_) => serialize_json_error(UNAVAILABLE, "Receiver error"),
86-
}
84+
impl JsonReply {
85+
/// Create a new Reply
86+
pub fn new(error_code: ErrorCode, message: impl fmt::Display) -> Self {
87+
Self { error_code, message: message.to_string(), extra: serde_json::Map::new() }
8788
}
88-
}
8989

90-
pub(crate) fn serialize_json_error(code: &str, message: impl fmt::Display) -> String {
91-
format!(r#"{{ "errorCode": "{}", "message": "{}" }}"#, code, message)
90+
/// Add an additional field to the JSON response
91+
pub fn with_extra(mut self, key: &str, value: impl Into<serde_json::Value>) -> Self {
92+
self.extra.insert(key.to_string(), value.into());
93+
self
94+
}
95+
96+
/// Serialize the Reply to a JSON string
97+
pub fn to_json(&self) -> serde_json::Value {
98+
let mut map = serde_json::Map::new();
99+
map.insert("errorCode".to_string(), self.error_code.to_string().into());
100+
map.insert("message".to_string(), self.message.clone().into());
101+
map.extend(self.extra.clone());
102+
103+
serde_json::Value::Object(map)
104+
}
92105
}
93106

94-
pub(crate) fn serialize_json_plus_fields(
95-
code: &str,
96-
message: impl fmt::Display,
97-
additional_fields: &str,
98-
) -> String {
99-
format!(r#"{{ "errorCode": "{}", "message": "{}", {} }}"#, code, message, additional_fields)
107+
impl From<ReplyableError> for JsonReply {
108+
fn from(e: ReplyableError) -> Self {
109+
use ReplyableError::*;
110+
match e {
111+
Payload(e) => e.into(),
112+
#[cfg(feature = "v1")]
113+
V1(e) => e.into(),
114+
Implementation(_) => JsonReply::new(Unavailable, "Receiver error"),
115+
}
116+
}
100117
}
101118

102119
impl fmt::Display for ReplyableError {
@@ -180,34 +197,34 @@ pub(crate) enum InternalPayloadError {
180197
FeeTooHigh(bitcoin::FeeRate, bitcoin::FeeRate),
181198
}
182199

183-
impl JsonError for PayloadError {
184-
fn to_json(&self) -> String {
200+
impl From<PayloadError> for JsonReply {
201+
fn from(e: PayloadError) -> Self {
185202
use InternalPayloadError::*;
186203

187-
match &self.0 {
188-
Utf8(_) => serialize_json_error(ORIGINAL_PSBT_REJECTED, self),
189-
ParsePsbt(_) => serialize_json_error(ORIGINAL_PSBT_REJECTED, self),
204+
match &e.0 {
205+
Utf8(_)
206+
| ParsePsbt(_)
207+
| InconsistentPsbt(_)
208+
| PrevTxOut(_)
209+
| MissingPayment
210+
| OriginalPsbtNotBroadcastable
211+
| InputOwned(_)
212+
| InputWeight(_)
213+
| InputSeen(_)
214+
| PsbtBelowFeeRate(_, _) => JsonReply::new(OriginalPsbtRejected, e),
215+
216+
FeeTooHigh(_, _) => JsonReply::new(NotEnoughMoney, e),
217+
190218
SenderParams(e) => match e {
191219
super::optional_parameters::Error::UnknownVersion { supported_versions } => {
192220
let supported_versions_json =
193221
serde_json::to_string(supported_versions).unwrap_or_default();
194-
serialize_json_plus_fields(
195-
VERSION_UNSUPPORTED,
196-
"This version of payjoin is not supported.",
197-
&format!(r#""supported": {}"#, supported_versions_json),
198-
)
222+
JsonReply::new(VersionUnsupported, "This version of payjoin is not supported.")
223+
.with_extra("supported", supported_versions_json)
199224
}
200-
_ => serialize_json_error("sender-params-error", self),
225+
super::optional_parameters::Error::FeeRate =>
226+
JsonReply::new(OriginalPsbtRejected, e),
201227
},
202-
InconsistentPsbt(_) => serialize_json_error(ORIGINAL_PSBT_REJECTED, self),
203-
PrevTxOut(_) => serialize_json_error(ORIGINAL_PSBT_REJECTED, self),
204-
MissingPayment => serialize_json_error(ORIGINAL_PSBT_REJECTED, self),
205-
OriginalPsbtNotBroadcastable => serialize_json_error(ORIGINAL_PSBT_REJECTED, self),
206-
InputOwned(_) => serialize_json_error(ORIGINAL_PSBT_REJECTED, self),
207-
InputWeight(_) => serialize_json_error(ORIGINAL_PSBT_REJECTED, self),
208-
InputSeen(_) => serialize_json_error(ORIGINAL_PSBT_REJECTED, self),
209-
PsbtBelowFeeRate(_, _) => serialize_json_error(ORIGINAL_PSBT_REJECTED, self),
210-
FeeTooHigh(_, _) => serialize_json_error(NOT_ENOUGH_MONEY, self),
211228
}
212229
}
213230
}

payjoin/src/receive/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ use std::str::FromStr;
1414
use bitcoin::{psbt, AddressType, Psbt, TxIn, TxOut};
1515
pub(crate) use error::InternalPayloadError;
1616
pub use error::{
17-
Error, ImplementationError, InputContributionError, JsonError, OutputSubstitutionError,
17+
Error, ImplementationError, InputContributionError, JsonReply, OutputSubstitutionError,
1818
PayloadError, ReplyableError, SelectionError,
1919
};
2020
use optional_parameters::Params;

payjoin/src/receive/v1/exclusive/error.rs

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
use core::fmt;
22
use std::error;
33

4-
use crate::receive::error::JsonError;
4+
use crate::receive::JsonReply;
55

66
/// Error that occurs during validation of an incoming v1 payjoin request.
77
///
@@ -38,17 +38,17 @@ impl From<InternalRequestError> for super::ReplyableError {
3838
fn from(e: InternalRequestError) -> Self { super::ReplyableError::V1(e.into()) }
3939
}
4040

41-
impl JsonError for RequestError {
42-
fn to_json(&self) -> String {
41+
impl From<RequestError> for JsonReply {
42+
fn from(e: RequestError) -> Self {
4343
use InternalRequestError::*;
4444

45-
use crate::receive::error::serialize_json_error;
46-
match &self.0 {
47-
Io(_) => serialize_json_error("original-psbt-rejected", self),
48-
MissingHeader(_) => serialize_json_error("original-psbt-rejected", self),
49-
InvalidContentType(_) => serialize_json_error("original-psbt-rejected", self),
50-
InvalidContentLength(_) => serialize_json_error("original-psbt-rejected", self),
51-
ContentLengthTooLarge(_) => serialize_json_error("original-psbt-rejected", self),
45+
match &e.0 {
46+
Io(_)
47+
| MissingHeader(_)
48+
| InvalidContentType(_)
49+
| InvalidContentLength(_)
50+
| ContentLengthTooLarge(_) =>
51+
JsonReply::new(crate::error_codes::ErrorCode::OriginalPsbtRejected, e),
5252
}
5353
}
5454
}

payjoin/src/receive/v2/mod.rs

Lines changed: 23 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ use url::Url;
1313

1414
use super::error::{Error, InputContributionError};
1515
use super::{
16-
v1, ImplementationError, InternalPayloadError, JsonError, OutputSubstitutionError,
16+
v1, ImplementationError, InternalPayloadError, JsonReply, OutputSubstitutionError,
1717
ReplyableError, SelectionError,
1818
};
1919
use crate::hpke::{decrypt_message_a, encrypt_message_b, HpkeKeyPair, HpkePublicKey};
@@ -278,15 +278,15 @@ impl UncheckedProposal {
278278
/// a Receiver Error Response
279279
pub fn extract_err_req(
280280
&mut self,
281-
err: &ReplyableError,
281+
err: &JsonReply,
282282
ohttp_relay: impl IntoUrl,
283283
) -> Result<(Request, ohttp::ClientResponse), SessionError> {
284284
let subdir = subdir(&self.context.directory, &id(&self.context.s));
285285
let (body, ohttp_ctx) = ohttp_encapsulate(
286286
&mut self.context.ohttp_keys,
287287
"POST",
288288
subdir.as_str(),
289-
Some(err.to_json().as_bytes()),
289+
Some(err.to_json().to_string().as_bytes()),
290290
)
291291
.map_err(InternalSessionError::OhttpEncapsulation)?;
292292
let req = Request::new_v2(&self.context.full_relay_url(ohttp_relay)?, &body);
@@ -620,19 +620,26 @@ mod test {
620620
context: SHARED_CONTEXT.clone(),
621621
};
622622

623-
let server_error = proposal
624-
.clone()
625-
.check_broadcast_suitability(None, |_| Err("mock error".into()))
626-
.err()
627-
.ok_or("expected error but got success")?;
628-
assert_eq!(
629-
server_error.to_json(),
630-
r#"{ "errorCode": "unavailable", "message": "Receiver error" }"#
631-
);
632-
let (_req, _ctx) = proposal.clone().extract_err_req(&server_error, &*EXAMPLE_URL)?;
633-
634-
let internal_error = InternalPayloadError::MissingPayment.into();
635-
let (_req, _ctx) = proposal.extract_err_req(&internal_error, &*EXAMPLE_URL)?;
623+
let server_error = || {
624+
proposal
625+
.clone()
626+
.check_broadcast_suitability(None, |_| Err("mock error".into()))
627+
.expect_err("expected broadcast suitability check to fail")
628+
};
629+
630+
let expected_json = serde_json::json!({
631+
"errorCode": "unavailable",
632+
"message": "Receiver error"
633+
});
634+
635+
let actual_json = JsonReply::from(server_error()).to_json().clone();
636+
assert_eq!(actual_json, expected_json);
637+
638+
let (_req, _ctx) =
639+
proposal.clone().extract_err_req(&server_error().into(), &*EXAMPLE_URL)?;
640+
641+
let internal_error: ReplyableError = InternalPayloadError::MissingPayment.into();
642+
let (_req, _ctx) = proposal.extract_err_req(&internal_error.into(), &*EXAMPLE_URL)?;
636643
Ok(())
637644
}
638645

0 commit comments

Comments
 (0)