From 68be9953f9d4c410c5246228fbe4fa2976d58476 Mon Sep 17 00:00:00 2001 From: chavic Date: Thu, 9 Jul 2026 11:01:08 +0200 Subject: [PATCH] Add kind accessors to receive candidate errors CoinSelectionError and InputContributionError are opaque because their variants may change, but that leaves callers of try_preserving_privacy and contribute_inputs unable to distinguish failures that could succeed with a different candidate set from failures no candidate change can fix, short of matching display strings. A wallet driving selection with a retry loop burns attempts on the second class. Add kind() returning a non_exhaustive kind enum on both errors: callers get a stable, matchable classification while the error types stay free to evolve, since new variants only require a new kind category and consumers must already handle unknown kinds. Mirror the accessors in payjoin-ffi with an explicit Other catch-all, giving bindings the same signal; today these errors cross the FFI as display-only objects. Related to #1422. --- payjoin-ffi/src/receive/error.rs | 63 ++++++++++++++++++++ payjoin/src/core/receive/error.rs | 96 ++++++++++++++++++++++++++++++- payjoin/src/core/receive/mod.rs | 4 +- 3 files changed, 159 insertions(+), 4 deletions(-) diff --git a/payjoin-ffi/src/receive/error.rs b/payjoin-ffi/src/receive/error.rs index c3fbf8419..50ff169ea 100644 --- a/payjoin-ffi/src/receive/error.rs +++ b/payjoin-ffi/src/receive/error.rs @@ -228,12 +228,75 @@ impl From for OutputSubstitutionError { #[error(transparent)] pub struct CoinSelectionError(#[from] receive::CoinSelectionError); +/// The category of a [`CoinSelectionError`]. +/// +/// Mirrors [`payjoin::receive::CoinSelectionErrorKind`], with an `Other` +/// catch-all so categories added upstream do not break bindings. Unrecognized +/// categories should be handled conservatively. +#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)] +pub enum CoinSelectionErrorKind { + /// No candidates were available for selection. + Empty, + /// The transaction shape is not supported by the current selection + /// implementation. Retrying with different candidates will not help. + UnsupportedOutputLength, + /// No candidate improved privacy. A different candidate set may succeed. + NotFound, + /// A category this version of the bindings does not know about. + Other, +} + +#[uniffi::export] +impl CoinSelectionError { + /// Returns the category of this error. + pub fn kind(&self) -> CoinSelectionErrorKind { + match self.0.kind() { + receive::CoinSelectionErrorKind::Empty => CoinSelectionErrorKind::Empty, + receive::CoinSelectionErrorKind::UnsupportedOutputLength => + CoinSelectionErrorKind::UnsupportedOutputLength, + receive::CoinSelectionErrorKind::NotFound => CoinSelectionErrorKind::NotFound, + _ => CoinSelectionErrorKind::Other, + } + } +} + /// Error that may occur when input contribution fails. #[derive(Debug, thiserror::Error, uniffi::Object)] #[uniffi::export(Debug, Display)] #[error(transparent)] pub struct InputContributionError(#[from] receive::InputContributionError); +/// The category of an [`InputContributionError`]. +/// +/// Mirrors [`payjoin::receive::InputContributionErrorKind`], with an `Other` +/// catch-all so categories added upstream do not break bindings. Unrecognized +/// categories should be handled conservatively. +#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)] +pub enum InputContributionErrorKind { + /// Total input value does not cover the additional output value. Removing + /// candidates cannot help; only higher-value candidates can. + ValueTooLow, + /// The selected input's outpoint is already present in the transaction. + /// Contribution may succeed with a different candidate. + DuplicateInput, + /// A category this version of the bindings does not know about. + Other, +} + +#[uniffi::export] +impl InputContributionError { + /// Returns the category of this error. + pub fn kind(&self) -> InputContributionErrorKind { + match self.0.kind() { + receive::InputContributionErrorKind::ValueTooLow => + InputContributionErrorKind::ValueTooLow, + receive::InputContributionErrorKind::DuplicateInput => + InputContributionErrorKind::DuplicateInput, + _ => InputContributionErrorKind::Other, + } + } +} + /// Error validating a PSBT Input #[derive(Debug, thiserror::Error, uniffi::Object)] #[uniffi::export(Debug, Display)] diff --git a/payjoin/src/core/receive/error.rs b/payjoin/src/core/receive/error.rs index 6048f86fe..4df09b004 100644 --- a/payjoin/src/core/receive/error.rs +++ b/payjoin/src/core/receive/error.rs @@ -352,10 +352,42 @@ impl std::error::Error for OutputSubstitutionError { /// Error that may occur when coin selection fails. /// /// This is currently opaque type because we aren't sure which variants will stay. -/// You can only display it. +/// You can display it, or classify it with [`CoinSelectionError::kind`]. #[derive(Debug, PartialEq, Eq)] pub struct CoinSelectionError(InternalCoinSelectionError); +/// The category of a [`CoinSelectionError`]. +/// +/// Unlike the error itself, the kind is a stable, matchable classification +/// intended to let callers decide how to react — in particular whether trying +/// again with a different candidate set could succeed. The enum is +/// non-exhaustive so categories can be added without breaking callers; +/// unrecognized categories should be handled conservatively. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum CoinSelectionErrorKind { + /// No candidates were available for selection. Selection can only succeed + /// once candidates exist. + Empty, + /// The transaction shape is not supported by the current selection + /// implementation. Retrying with different candidates will not help. + UnsupportedOutputLength, + /// No candidate improved privacy. A different candidate set may succeed. + NotFound, +} + +impl CoinSelectionError { + /// Returns the category of this error. + pub fn kind(&self) -> CoinSelectionErrorKind { + match &self.0 { + InternalCoinSelectionError::Empty => CoinSelectionErrorKind::Empty, + InternalCoinSelectionError::UnsupportedOutputLength => + CoinSelectionErrorKind::UnsupportedOutputLength, + InternalCoinSelectionError::NotFound => CoinSelectionErrorKind::NotFound, + } + } +} + #[derive(Debug, PartialEq, Eq)] pub(crate) enum InternalCoinSelectionError { /// No candidates available for selection @@ -398,10 +430,39 @@ impl From for CoinSelectionError { /// Error that may occur when input contribution fails. /// /// This is currently opaque type because we aren't sure which variants will stay. -/// You can only display it. +/// You can display it, or classify it with [`InputContributionError::kind`]. #[derive(Debug, PartialEq, Eq)] pub struct InputContributionError(InternalInputContributionError); +/// The category of an [`InputContributionError`]. +/// +/// Unlike the error itself, the kind is a stable, matchable classification +/// intended to let callers decide how to react — in particular whether trying +/// again with a different candidate set could succeed. The enum is +/// non-exhaustive so categories can be added without breaking callers; +/// unrecognized categories should be handled conservatively. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum InputContributionErrorKind { + /// Total input value does not cover the additional output value. Removing + /// candidates cannot help; only higher-value candidates can. + ValueTooLow, + /// The selected input's outpoint is already present in the transaction. + /// Contribution may succeed with a different candidate. + DuplicateInput, +} + +impl InputContributionError { + /// Returns the category of this error. + pub fn kind(&self) -> InputContributionErrorKind { + match &self.0 { + InternalInputContributionError::ValueTooLow => InputContributionErrorKind::ValueTooLow, + InternalInputContributionError::DuplicateInput(_) => + InputContributionErrorKind::DuplicateInput, + } + } +} + #[derive(Debug, PartialEq, Eq)] pub(crate) enum InternalInputContributionError { /// Total input value is not enough to cover additional output value @@ -439,6 +500,37 @@ mod tests { use super::*; use crate::ImplementationError; + #[test] + fn test_coin_selection_error_kind_classifies_every_variant() { + assert_eq!( + CoinSelectionError::from(InternalCoinSelectionError::Empty).kind(), + CoinSelectionErrorKind::Empty + ); + assert_eq!( + CoinSelectionError::from(InternalCoinSelectionError::UnsupportedOutputLength).kind(), + CoinSelectionErrorKind::UnsupportedOutputLength + ); + assert_eq!( + CoinSelectionError::from(InternalCoinSelectionError::NotFound).kind(), + CoinSelectionErrorKind::NotFound + ); + } + + #[test] + fn test_input_contribution_error_kind_classifies_every_variant() { + assert_eq!( + InputContributionError::from(InternalInputContributionError::ValueTooLow).kind(), + InputContributionErrorKind::ValueTooLow + ); + assert_eq!( + InputContributionError::from(InternalInputContributionError::DuplicateInput( + bitcoin::OutPoint::null() + )) + .kind(), + InputContributionErrorKind::DuplicateInput + ); + } + #[test] fn test_json_reply_from_implementation_error() { struct AlwaysPanics; diff --git a/payjoin/src/core/receive/mod.rs b/payjoin/src/core/receive/mod.rs index 7aa41ea07..c09691533 100644 --- a/payjoin/src/core/receive/mod.rs +++ b/payjoin/src/core/receive/mod.rs @@ -18,8 +18,8 @@ use bitcoin::{ }; pub(crate) use error::InternalPayloadError; pub use error::{ - CoinSelectionError, Error, InputContributionError, JsonReply, OutputSubstitutionError, - PayloadError, ProtocolError, + CoinSelectionError, CoinSelectionErrorKind, Error, InputContributionError, + InputContributionErrorKind, JsonReply, OutputSubstitutionError, PayloadError, ProtocolError, }; use optional_parameters::Params; use serde::{Deserialize, Serialize};