Skip to content
Merged
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
5 changes: 3 additions & 2 deletions payjoin-cli/src/app/v2.rs
Original file line number Diff line number Diff line change
Expand Up @@ -287,7 +287,8 @@ async fn handle_recoverable_error(
mut receiver: UncheckedProposal,
ohttp_relay: &payjoin::Url,
) -> anyhow::Error {
let (err_req, err_ctx) = match receiver.extract_err_req(&e, ohttp_relay) {
let to_return = anyhow!("Replied with error: {}", e);
let (err_req, err_ctx) = match receiver.extract_err_req(&e.into(), ohttp_relay) {
Ok(req_ctx) => req_ctx,
Err(e) => return anyhow!("Failed to extract error request: {}", e),
};
Expand All @@ -306,7 +307,7 @@ async fn handle_recoverable_error(
return anyhow!("Failed to process error response: {}", e);
}

e.into()
to_return
}

fn try_contributing_inputs(
Expand Down
47 changes: 39 additions & 8 deletions payjoin/src/error_codes.rs
Original file line number Diff line number Diff line change
@@ -1,14 +1,45 @@
//! Well-known error codes as defined in BIP-78
//! See: <https://github.com/bitcoin/bips/blob/master/bip-0078.mediawiki#receivers-well-known-errors>

/// The payjoin endpoint is not available for now.
pub const UNAVAILABLE: &str = "unavailable";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ErrorCode {
/// The payjoin endpoint is not available for now.
Unavailable,
/// The receiver added some inputs but could not bump the fee of the payjoin proposal.
NotEnoughMoney,
/// This version of payjoin is not supported.
VersionUnsupported,
/// The receiver rejected the original PSBT.
OriginalPsbtRejected,
}

/// The receiver added some inputs but could not bump the fee of the payjoin proposal.
pub const NOT_ENOUGH_MONEY: &str = "not-enough-money";
impl ErrorCode {
pub const fn as_str(&self) -> &'static str {
match self {
Self::Unavailable => "unavailable",
Self::NotEnoughMoney => "not-enough-money",
Self::VersionUnsupported => "version-unsupported",
Self::OriginalPsbtRejected => "original-psbt-rejected",
}
}
}

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

/// The receiver rejected the original PSBT.
pub const ORIGINAL_PSBT_REJECTED: &str = "original-psbt-rejected";
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"unavailable" => Ok(Self::Unavailable),
"not-enough-money" => Ok(Self::NotEnoughMoney),
"version-unsupported" => Ok(Self::VersionUnsupported),
"original-psbt-rejected" => Ok(Self::OriginalPsbtRejected),
_ => Err(()),
}
}
}

impl core::fmt::Display for ErrorCode {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.write_str(self.as_str())
}
}
107 changes: 62 additions & 45 deletions payjoin/src/receive/error.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use std::{error, fmt};

use crate::error_codes::{
NOT_ENOUGH_MONEY, ORIGINAL_PSBT_REJECTED, UNAVAILABLE, VERSION_UNSUPPORTED,
use crate::error_codes::ErrorCode::{
self, NotEnoughMoney, OriginalPsbtRejected, Unavailable, VersionUnsupported,
};

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

/// A trait for errors that can be serialized to JSON in a standardized format.
/// The standard format for errors that can be replied as JSON.
///
/// The JSON output follows the structure:
/// The JSON output includes the following fields:
/// ```json
/// {
/// "errorCode": "specific-error-code",
/// "message": "Human readable error message"
/// }
/// ```
pub trait JsonError {
/// Converts the error into a JSON string representation.
fn to_json(&self) -> String;
pub struct JsonReply {
/// The error code
error_code: ErrorCode,
/// The error message to be displayed only in debug logs
message: String,
/// Additional fields to be included in the JSON response
extra: serde_json::Map<String, serde_json::Value>,
}

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

pub(crate) fn serialize_json_error(code: &str, message: impl fmt::Display) -> String {
format!(r#"{{ "errorCode": "{}", "message": "{}" }}"#, code, message)
/// 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());
self
}

/// Serialize the Reply to a JSON string
pub fn to_json(&self) -> serde_json::Value {
let mut map = serde_json::Map::new();
map.insert("errorCode".to_string(), self.error_code.to_string().into());
map.insert("message".to_string(), self.message.clone().into());
map.extend(self.extra.clone());

serde_json::Value::Object(map)
}
}

pub(crate) fn serialize_json_plus_fields(
code: &str,
message: impl fmt::Display,
additional_fields: &str,
) -> String {
format!(r#"{{ "errorCode": "{}", "message": "{}", {} }}"#, code, message, additional_fields)
impl From<ReplyableError> for JsonReply {
fn from(e: ReplyableError) -> Self {
use ReplyableError::*;
match e {
Payload(e) => e.into(),
#[cfg(feature = "v1")]
V1(e) => e.into(),
Implementation(_) => JsonReply::new(Unavailable, "Receiver error"),
}
}
}

impl fmt::Display for ReplyableError {
Expand Down Expand Up @@ -180,34 +197,34 @@ pub(crate) enum InternalPayloadError {
FeeTooHigh(bitcoin::FeeRate, bitcoin::FeeRate),
}

impl JsonError for PayloadError {
fn to_json(&self) -> String {
impl From<PayloadError> for JsonReply {
fn from(e: PayloadError) -> Self {
use InternalPayloadError::*;

match &self.0 {
Utf8(_) => serialize_json_error(ORIGINAL_PSBT_REJECTED, self),
ParsePsbt(_) => serialize_json_error(ORIGINAL_PSBT_REJECTED, self),
match &e.0 {
Utf8(_)
| ParsePsbt(_)
| InconsistentPsbt(_)
| PrevTxOut(_)
| MissingPayment
| OriginalPsbtNotBroadcastable
| InputOwned(_)
| InputWeight(_)
| InputSeen(_)
| PsbtBelowFeeRate(_, _) => JsonReply::new(OriginalPsbtRejected, e),

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();
serialize_json_plus_fields(
VERSION_UNSUPPORTED,
"This version of payjoin is not supported.",
&format!(r#""supported": {}"#, supported_versions_json),
)
JsonReply::new(VersionUnsupported, "This version of payjoin is not supported.")
.with_extra("supported", supported_versions_json)
}
_ => serialize_json_error("sender-params-error", self),
super::optional_parameters::Error::FeeRate =>
JsonReply::new(OriginalPsbtRejected, e),
},
InconsistentPsbt(_) => serialize_json_error(ORIGINAL_PSBT_REJECTED, self),
PrevTxOut(_) => serialize_json_error(ORIGINAL_PSBT_REJECTED, self),
MissingPayment => serialize_json_error(ORIGINAL_PSBT_REJECTED, self),
OriginalPsbtNotBroadcastable => serialize_json_error(ORIGINAL_PSBT_REJECTED, self),
InputOwned(_) => serialize_json_error(ORIGINAL_PSBT_REJECTED, self),
InputWeight(_) => serialize_json_error(ORIGINAL_PSBT_REJECTED, self),
InputSeen(_) => serialize_json_error(ORIGINAL_PSBT_REJECTED, self),
PsbtBelowFeeRate(_, _) => serialize_json_error(ORIGINAL_PSBT_REJECTED, self),
FeeTooHigh(_, _) => serialize_json_error(NOT_ENOUGH_MONEY, self),
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion payjoin/src/receive/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ use std::str::FromStr;
use bitcoin::{psbt, AddressType, Psbt, TxIn, TxOut};
pub(crate) use error::InternalPayloadError;
pub use error::{
Error, ImplementationError, InputContributionError, JsonError, OutputSubstitutionError,
Error, ImplementationError, InputContributionError, JsonReply, OutputSubstitutionError,
PayloadError, ReplyableError, SelectionError,
};
use optional_parameters::Params;
Expand Down
20 changes: 10 additions & 10 deletions payjoin/src/receive/v1/exclusive/error.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use core::fmt;
use std::error;

use crate::receive::error::JsonError;
use crate::receive::JsonReply;

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

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

use crate::receive::error::serialize_json_error;
match &self.0 {
Io(_) => serialize_json_error("original-psbt-rejected", self),
MissingHeader(_) => serialize_json_error("original-psbt-rejected", self),
InvalidContentType(_) => serialize_json_error("original-psbt-rejected", self),
InvalidContentLength(_) => serialize_json_error("original-psbt-rejected", self),
ContentLengthTooLarge(_) => serialize_json_error("original-psbt-rejected", self),
match &e.0 {
Io(_)
| MissingHeader(_)
| InvalidContentType(_)
| InvalidContentLength(_)
| ContentLengthTooLarge(_) =>
JsonReply::new(crate::error_codes::ErrorCode::OriginalPsbtRejected, e),
}
}
}
Expand Down
39 changes: 23 additions & 16 deletions payjoin/src/receive/v2/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ use url::Url;

use super::error::{Error, InputContributionError};
use super::{
v1, ImplementationError, InternalPayloadError, JsonError, OutputSubstitutionError,
v1, ImplementationError, InternalPayloadError, JsonReply, OutputSubstitutionError,
ReplyableError, SelectionError,
};
use crate::hpke::{decrypt_message_a, encrypt_message_b, HpkeKeyPair, HpkePublicKey};
Expand Down Expand Up @@ -278,15 +278,15 @@ impl UncheckedProposal {
/// a Receiver Error Response
pub fn extract_err_req(
&mut self,
err: &ReplyableError,
err: &JsonReply,
ohttp_relay: impl IntoUrl,
) -> Result<(Request, ohttp::ClientResponse), SessionError> {
let subdir = subdir(&self.context.directory, &id(&self.context.s));
let (body, ohttp_ctx) = ohttp_encapsulate(
&mut self.context.ohttp_keys,
"POST",
subdir.as_str(),
Some(err.to_json().as_bytes()),
Some(err.to_json().to_string().as_bytes()),
)
.map_err(InternalSessionError::OhttpEncapsulation)?;
let req = Request::new_v2(&self.context.full_relay_url(ohttp_relay)?, &body);
Expand Down Expand Up @@ -620,19 +620,26 @@ mod test {
context: SHARED_CONTEXT.clone(),
};

let server_error = proposal
.clone()
.check_broadcast_suitability(None, |_| Err("mock error".into()))
.err()
.ok_or("expected error but got success")?;
assert_eq!(
server_error.to_json(),
r#"{ "errorCode": "unavailable", "message": "Receiver error" }"#
);
let (_req, _ctx) = proposal.clone().extract_err_req(&server_error, &*EXAMPLE_URL)?;

let internal_error = InternalPayloadError::MissingPayment.into();
let (_req, _ctx) = proposal.extract_err_req(&internal_error, &*EXAMPLE_URL)?;
let server_error = || {
proposal
.clone()
.check_broadcast_suitability(None, |_| Err("mock error".into()))
.expect_err("expected broadcast suitability check to fail")
};

let expected_json = serde_json::json!({
"errorCode": "unavailable",
"message": "Receiver error"
});

let actual_json = JsonReply::from(server_error()).to_json().clone();
assert_eq!(actual_json, expected_json);

let (_req, _ctx) =
proposal.clone().extract_err_req(&server_error().into(), &*EXAMPLE_URL)?;

let internal_error: ReplyableError = InternalPayloadError::MissingPayment.into();
let (_req, _ctx) = proposal.extract_err_req(&internal_error.into(), &*EXAMPLE_URL)?;
Ok(())
}

Expand Down
Loading