Skip to content

Commit

Permalink
Produce receive::JsonError accurately so that send can properly h…
Browse files Browse the repository at this point in the history
…andle it (payjoin#506)

- [Isolate receive::JsonError from
fmt::Display](payjoin@ffe6281)
- [Reject bad v1 requests as
original-psbt-rejected](payjoin@9a323ef)
since that's the only way v1 senders can really address their issue.
It's a payload error but that's the same as Original PSBT [payload] in
BIP 78 parlance.

This change also introduces `const` values for well known error
codes for both `send` and `receive` to share to prevent slipped typos
during maintenance.
  • Loading branch information
spacebear21 authored Jan 25, 2025
2 parents 9176ab6 + a158cf2 commit 27f7813
Show file tree
Hide file tree
Showing 8 changed files with 162 additions and 88 deletions.
14 changes: 14 additions & 0 deletions payjoin/src/error_codes.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
//! 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";

/// The receiver added some inputs but could not bump the fee of the payjoin proposal.
pub const NOT_ENOUGH_MONEY: &str = "not-enough-money";

/// This version of payjoin is not supported.
pub const VERSION_UNSUPPORTED: &str = "version-unsupported";

/// The receiver rejected the original PSBT.
pub const ORIGINAL_PSBT_REJECTED: &str = "original-psbt-rejected";
3 changes: 3 additions & 0 deletions payjoin/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,6 @@ pub use bitcoin::base64;
pub use uri::{PjParseError, PjUri, Uri, UriExt};
#[cfg(feature = "_core")]
pub use url::{ParseError, Url};

#[cfg(feature = "_core")]
pub(crate) mod error_codes;
143 changes: 90 additions & 53 deletions payjoin/src/receive/error.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
use std::{error, fmt};

use crate::error_codes::{
NOT_ENOUGH_MONEY, ORIGINAL_PSBT_REJECTED, UNAVAILABLE, VERSION_UNSUPPORTED,
};
#[cfg(feature = "v1")]
use crate::receive::v1;
#[cfg(feature = "v2")]
Expand All @@ -25,16 +28,41 @@ pub enum Error {
Implementation(Box<dyn error::Error + Send + Sync>),
}

impl Error {
pub fn to_json(&self) -> String {
/// A trait for errors that can be serialized to JSON in a standardized format.
///
/// The JSON output follows the structure:
/// ```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;
}

impl JsonError for Error {
fn to_json(&self) -> String {
match self {
Self::Validation(e) => e.to_string(),
Self::Implementation(_) =>
"{{ \"errorCode\": \"unavailable\", \"message\": \"Receiver error\" }}".to_string(),
Self::Validation(e) => e.to_json(),
Self::Implementation(_) => serialize_json_error(UNAVAILABLE, "Receiver error"),
}
}
}

pub(crate) fn serialize_json_error(code: &str, message: impl fmt::Display) -> String {
format!(r#"{{ "errorCode": "{}", "message": "{}" }}"#, code, message)
}

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 fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match &self {
Expand Down Expand Up @@ -85,6 +113,18 @@ impl From<v2::InternalSessionError> for ValidationError {
fn from(e: v2::InternalSessionError) -> Self { ValidationError::V2(e.into()) }
}

impl JsonError for ValidationError {
fn to_json(&self) -> String {
match self {
ValidationError::Payload(e) => e.to_json(),
#[cfg(feature = "v1")]
ValidationError::V1(e) => e.to_json(),
#[cfg(feature = "v2")]
ValidationError::V2(e) => e.to_json(),
}
}
}

impl fmt::Display for ValidationError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
Expand Down Expand Up @@ -164,65 +204,62 @@ pub(crate) enum InternalPayloadError {
FeeTooHigh(bitcoin::FeeRate, bitcoin::FeeRate),
}

impl fmt::Display for PayloadError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
impl JsonError for PayloadError {
fn to_json(&self) -> String {
use InternalPayloadError::*;

fn write_error(
f: &mut fmt::Formatter,
code: &str,
message: impl fmt::Display,
) -> fmt::Result {
write!(f, r#"{{ "errorCode": "{}", "message": "{}" }}"#, code, message)
}

match &self.0 {
Utf8(e) => write_error(f, "original-psbt-rejected", e),
ParsePsbt(e) => write_error(f, "original-psbt-rejected", e),
Utf8(_) => serialize_json_error(ORIGINAL_PSBT_REJECTED, self),
ParsePsbt(_) => serialize_json_error(ORIGINAL_PSBT_REJECTED, self),
SenderParams(e) => match e {
super::optional_parameters::Error::UnknownVersion { supported_versions } => {
write!(
f,
r#"{{
"errorCode": "version-unsupported",
"supported": "{}",
"message": "This version of payjoin is not supported."
}}"#,
serde_json::to_string(supported_versions).map_err(|_| fmt::Error)?
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),
)
}
_ => write_error(f, "sender-params-error", e),
_ => serialize_json_error("sender-params-error", self),
},
InconsistentPsbt(e) => write_error(f, "original-psbt-rejected", e),
PrevTxOut(e) =>
write_error(f, "original-psbt-rejected", format!("PrevTxOut Error: {}", e)),
MissingPayment => write_error(f, "original-psbt-rejected", "Missing payment."),
OriginalPsbtNotBroadcastable => write_error(
f,
"original-psbt-rejected",
"Can't broadcast. PSBT rejected by mempool.",
),
InputOwned(_) =>
write_error(f, "original-psbt-rejected", "The receiver rejected the original PSBT."),
InputWeight(e) =>
write_error(f, "original-psbt-rejected", format!("InputWeight Error: {}", e)),
InputSeen(_) =>
write_error(f, "original-psbt-rejected", "The receiver rejected the original PSBT."),
PsbtBelowFeeRate(original_psbt_fee_rate, receiver_min_fee_rate) => write_error(
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),
}
}
}

impl fmt::Display for PayloadError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
use InternalPayloadError::*;

match &self.0 {
Utf8(e) => write!(f, "{}", e),
ParsePsbt(e) => write!(f, "{}", e),
SenderParams(e) => write!(f, "{}", e),
InconsistentPsbt(e) => write!(f, "{}", e),
PrevTxOut(e) => write!(f, "PrevTxOut Error: {}", e),
MissingPayment => write!(f, "Missing payment."),
OriginalPsbtNotBroadcastable => write!(f, "Can't broadcast. PSBT rejected by mempool."),
InputOwned(_) => write!(f, "The receiver rejected the original PSBT."),
InputWeight(e) => write!(f, "InputWeight Error: {}", e),
InputSeen(_) => write!(f, "The receiver rejected the original PSBT."),
PsbtBelowFeeRate(original_psbt_fee_rate, receiver_min_fee_rate) => write!(
f,
"original-psbt-rejected",
format!(
"Original PSBT fee rate too low: {} < {}.",
original_psbt_fee_rate, receiver_min_fee_rate
),
"Original PSBT fee rate too low: {} < {}.",
original_psbt_fee_rate, receiver_min_fee_rate
),
FeeTooHigh(proposed_fee_rate, max_fee_rate) => write_error(
FeeTooHigh(proposed_fee_rate, max_fee_rate) => write!(
f,
"original-psbt-rejected",
format!(
"Effective receiver feerate exceeds maximum allowed feerate: {} > {}",
proposed_fee_rate, max_fee_rate
),
"Effective receiver feerate exceeds maximum allowed feerate: {} > {}",
proposed_fee_rate, max_fee_rate
),
}
}
Expand Down
2 changes: 1 addition & 1 deletion payjoin/src/receive/mod.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use bitcoin::{psbt, AddressType, TxIn, TxOut};
pub(crate) use error::InternalPayloadError;
pub use error::{Error, OutputSubstitutionError, PayloadError, SelectionError};
pub use error::{Error, JsonError, OutputSubstitutionError, PayloadError, SelectionError};

pub use crate::psbt::PsbtInputError;
use crate::psbt::{InternalInputPair, InternalPsbtInputError};
Expand Down
47 changes: 23 additions & 24 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::ValidationError;
use crate::receive::error::{JsonError, ValidationError};

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

impl fmt::Display for RequestError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
fn write_error(
f: &mut fmt::Formatter,
code: &str,
message: impl fmt::Display,
) -> fmt::Result {
write!(f, r#"{{ "errorCode": "{}", "message": "{}" }}"#, code, message)
impl JsonError for RequestError {
fn to_json(&self) -> String {
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),
}
}
}

impl fmt::Display for RequestError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match &self.0 {
InternalRequestError::Io(e) => write_error(f, "io-error", e),
InternalRequestError::MissingHeader(header) =>
write_error(f, "missing-header", format!("Missing header: {}", header)),
InternalRequestError::InvalidContentType(content_type) => write_error(
f,
"invalid-content-type",
format!("Invalid content type: {}", content_type),
),
InternalRequestError::InvalidContentLength(e) =>
write_error(f, "invalid-content-length", e),
InternalRequestError::ContentLengthTooLarge(length) => write_error(
f,
"content-length-too-large",
format!("Content length too large: {}.", length),
),
InternalRequestError::Io(e) => write!(f, "{}", e),
InternalRequestError::MissingHeader(header) => write!(f, "Missing header: {}", header),
InternalRequestError::InvalidContentType(content_type) =>
write!(f, "Invalid content type: {}", content_type),
InternalRequestError::InvalidContentLength(e) => write!(f, "{}", e),
InternalRequestError::ContentLengthTooLarge(length) =>
write!(f, "Content length too large: {}.", length),
}
}
}
Expand Down
16 changes: 16 additions & 0 deletions payjoin/src/receive/v2/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ use std::error;
use super::Error;
use crate::hpke::HpkeError;
use crate::ohttp::OhttpEncapsulationError;
use crate::receive::JsonError;

/// Error that may occur during a v2 session typestate change
///
Expand Down Expand Up @@ -48,6 +49,21 @@ impl From<HpkeError> for Error {
fn from(e: HpkeError) -> Self { InternalSessionError::Hpke(e).into() }
}

impl JsonError for SessionError {
fn to_json(&self) -> String {
use InternalSessionError::*;

use crate::receive::error::serialize_json_error;
match &self.0 {
Expired(_) => serialize_json_error("session-expired", self),
OhttpEncapsulation(_) => serialize_json_error("ohttp-encapsulation-error", self),
Hpke(_) => serialize_json_error("hpke-error", self),
UnexpectedResponseSize(_) => serialize_json_error("unexpected-response-size", self),
UnexpectedStatusCode(_) => serialize_json_error("unexpected-status-code", self),
}
}
}

impl fmt::Display for SessionError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match &self.0 {
Expand Down
4 changes: 2 additions & 2 deletions payjoin/src/receive/v2/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ use serde::{Deserialize, Serialize};
use url::Url;

use super::error::InputContributionError;
use super::{v1, Error, InternalPayloadError, OutputSubstitutionError, SelectionError};
use super::{v1, Error, InternalPayloadError, JsonError, OutputSubstitutionError, SelectionError};
use crate::hpke::{decrypt_message_a, encrypt_message_b, HpkeKeyPair, HpkePublicKey};
use crate::ohttp::{ohttp_decapsulate, ohttp_encapsulate, OhttpEncapsulationError, OhttpKeys};
use crate::psbt::PsbtExt;
Expand Down Expand Up @@ -615,7 +615,7 @@ mod test {
.unwrap();
assert_eq!(
server_error.to_json(),
"{{ \"errorCode\": \"unavailable\", \"message\": \"Receiver error\" }}"
r#"{ "errorCode": "unavailable", "message": "Receiver error" }"#
);
let (_req, _ctx) = proposal.clone().extract_err_req(&server_error, &EXAMPLE_OHTTP_RELAY)?;

Expand Down
21 changes: 13 additions & 8 deletions payjoin/src/send/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ use bitcoin::locktime::absolute::LockTime;
use bitcoin::transaction::Version;
use bitcoin::Sequence;

use crate::error_codes::{
NOT_ENOUGH_MONEY, ORIGINAL_PSBT_REJECTED, UNAVAILABLE, VERSION_UNSUPPORTED,
};

/// Error building a Sender from a SenderBuilder.
///
/// This error is unrecoverable.
Expand Down Expand Up @@ -279,7 +283,7 @@ impl ResponseError {
json.as_object().and_then(|v| v.get("errorCode")).and_then(|v| v.as_str())
{
match error_code {
"version-unsupported" => {
code if code == VERSION_UNSUPPORTED => {
let supported = json
.as_object()
.and_then(|v| v.get("supported"))
Expand All @@ -288,9 +292,10 @@ impl ResponseError {
.unwrap_or_default();
WellKnownError::VersionUnsupported { message, supported }.into()
}
"unavailable" => WellKnownError::Unavailable(message).into(),
"not-enough-money" => WellKnownError::NotEnoughMoney(message).into(),
"original-psbt-rejected" => WellKnownError::OriginalPsbtRejected(message).into(),
code if code == UNAVAILABLE => WellKnownError::Unavailable(message).into(),
code if code == NOT_ENOUGH_MONEY => WellKnownError::NotEnoughMoney(message).into(),
code if code == ORIGINAL_PSBT_REJECTED =>
WellKnownError::OriginalPsbtRejected(message).into(),
_ => Self::Unrecognized { error_code: error_code.to_string(), message },
}
} else {
Expand Down Expand Up @@ -369,10 +374,10 @@ pub enum WellKnownError {
impl WellKnownError {
pub fn error_code(&self) -> &str {
match self {
WellKnownError::Unavailable(_) => "unavailable",
WellKnownError::NotEnoughMoney(_) => "not-enough-money",
WellKnownError::VersionUnsupported { .. } => "version-unsupported",
WellKnownError::OriginalPsbtRejected(_) => "original-psbt-rejected",
WellKnownError::Unavailable(_) => UNAVAILABLE,
WellKnownError::NotEnoughMoney(_) => NOT_ENOUGH_MONEY,
WellKnownError::VersionUnsupported { .. } => VERSION_UNSUPPORTED,
WellKnownError::OriginalPsbtRejected(_) => ORIGINAL_PSBT_REJECTED,
}
}
pub fn message(&self) -> &str {
Expand Down

0 comments on commit 27f7813

Please sign in to comment.