From 8c920501d294beb5f3b87ff3320b2509e1f382d4 Mon Sep 17 00:00:00 2001 From: spacebear Date: Wed, 1 Jul 2026 21:24:21 -0400 Subject: [PATCH 1/4] Mark public error and status enums non-exhaustive Adding a variant to a public exhaustive enum is a breaking change, so without #[non_exhaustive] these enums could not grow after 1.0 without a 2.0. Mark only the enums where a wildcard arm has meaningful semantics for integrators: the error enums (ProtocolError, IntoUrlError, UrlParseError, ShortIdError), Version, and the receive/send SessionStatus. Keep the closed sets and state machines exhaustive: ReceiveSession and SendSession, SessionEvent, SessionOutcome, MaybePayjoinExtras, and the persist transition/outcome enums. A wildcard arm has no meaningful semantics for those, since a caller cannot resume a session in an unknown state. Downstream code would be forced into unreachable!() or silent fallback arms that turn a new variant into a runtime failure on live sessions. Adding a state is semantically breaking regardless of exhaustiveness, and new session events already break event-log replay on downgrade, so compatibility there is governed by persistence versioning. The compile error on a new variant is the feature. payjoin-cli keeps wildcard arms where it matches Version, mapping unknown versions to a config error. --- payjoin-cli/src/app/config.rs | 2 ++ payjoin/src/core/into_url.rs | 1 + payjoin/src/core/receive/error.rs | 1 + payjoin/src/core/receive/v2/session.rs | 1 + payjoin/src/core/send/v2/session.rs | 1 + payjoin/src/core/url.rs | 1 + payjoin/src/core/version.rs | 1 + payjoin/src/directory.rs | 1 + 8 files changed, 9 insertions(+) diff --git a/payjoin-cli/src/app/config.rs b/payjoin-cli/src/app/config.rs index dbb51a478..9b072ae94 100644 --- a/payjoin-cli/src/app/config.rs +++ b/payjoin-cli/src/app/config.rs @@ -127,6 +127,7 @@ impl Config { "BIP77 (v2) selected but v2 feature not enabled".to_string(), )); } + _ => return Err(ConfigError::Message("Unsupported payjoin version".to_string())), } config = handle_subcommands(config, cli)?; @@ -235,6 +236,7 @@ impl Config { "BIP77 (v2) selected but v2 feature not enabled".to_string(), )); } + _ => return Err(ConfigError::Message("Unsupported payjoin version".to_string())), } if config.version.is_none() { diff --git a/payjoin/src/core/into_url.rs b/payjoin/src/core/into_url.rs index 9d45755a5..c8f5c9aaf 100644 --- a/payjoin/src/core/into_url.rs +++ b/payjoin/src/core/into_url.rs @@ -1,6 +1,7 @@ use crate::core::{Url, UrlParseError}; #[derive(Debug, PartialEq, Eq)] +#[non_exhaustive] pub enum Error { BadScheme, ParseError(UrlParseError), diff --git a/payjoin/src/core/receive/error.rs b/payjoin/src/core/receive/error.rs index 8e0d31e8a..6048f86fe 100644 --- a/payjoin/src/core/receive/error.rs +++ b/payjoin/src/core/receive/error.rs @@ -57,6 +57,7 @@ impl error::Error for Error { /// 4. Provide errors according to BIP-78 JSON error specifications for return /// after conversion into [`JsonReply`] #[derive(Debug)] +#[non_exhaustive] pub enum ProtocolError { /// Error arising from validation of the original PSBT payload OriginalPayload(PayloadError), diff --git a/payjoin/src/core/receive/v2/session.rs b/payjoin/src/core/receive/v2/session.rs index 57f4e133e..f24d6ef3c 100644 --- a/payjoin/src/core/receive/v2/session.rs +++ b/payjoin/src/core/receive/v2/session.rs @@ -180,6 +180,7 @@ impl SessionHistory { // Represents the status of a session that can be inferred from the information in the session // event log. #[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] pub enum SessionStatus { Active, Expired, diff --git a/payjoin/src/core/send/v2/session.rs b/payjoin/src/core/send/v2/session.rs index fc6955951..f38938739 100644 --- a/payjoin/src/core/send/v2/session.rs +++ b/payjoin/src/core/send/v2/session.rs @@ -143,6 +143,7 @@ impl SessionHistory { /// Represents the status of a session that can be inferred from the information in the session /// event log. #[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] pub enum SessionStatus { Expired, Active, diff --git a/payjoin/src/core/url.rs b/payjoin/src/core/url.rs index 7628bf04a..1e6c52d41 100644 --- a/payjoin/src/core/url.rs +++ b/payjoin/src/core/url.rs @@ -143,6 +143,7 @@ impl<'a> UrlQueryPairs<'a> { /// /// Re-exported at the crate root as `UrlParseError`. #[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] pub enum ParseError { /// The authority section had no host between `://` and the path. EmptyHost, diff --git a/payjoin/src/core/version.rs b/payjoin/src/core/version.rs index 234647617..a0c7cbaaf 100644 --- a/payjoin/src/core/version.rs +++ b/payjoin/src/core/version.rs @@ -21,6 +21,7 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer}; /// and to match the expected wire format. #[repr(u8)] #[derive(Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] pub enum Version { /// BIP 78 Payjoin One = 1, diff --git a/payjoin/src/directory.rs b/payjoin/src/directory.rs index a82f70153..ef458d2a0 100644 --- a/payjoin/src/directory.rs +++ b/payjoin/src/directory.rs @@ -42,6 +42,7 @@ impl std::fmt::Display for ShortId { } #[derive(Debug)] +#[non_exhaustive] pub enum ShortIdError { DecodeBech32(bitcoin::bech32::primitives::decode::CheckedHrpstringError), IncorrectLength(std::array::TryFromSliceError), From f21fdb9a89b9074856fe23f4413587ea21db105f Mon Sep 17 00:00:00 2001 From: spacebear Date: Wed, 1 Jul 2026 21:25:23 -0400 Subject: [PATCH 2/4] Implement Error and Display for ShortIdError ShortIdError is the error type of ShortId's TryFrom<&[u8]> and FromStr impls, but it derived only Debug. Without Display and std::error::Error it could not be `?`-propagated into Box or anyhow, forcing callers to handle it specially. Implement Display and Error with source() chaining to the wrapped bech32 and slice-conversion errors. --- payjoin/src/directory.rs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/payjoin/src/directory.rs b/payjoin/src/directory.rs index ef458d2a0..66166bff8 100644 --- a/payjoin/src/directory.rs +++ b/payjoin/src/directory.rs @@ -48,6 +48,24 @@ pub enum ShortIdError { IncorrectLength(std::array::TryFromSliceError), } +impl std::fmt::Display for ShortIdError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ShortIdError::DecodeBech32(e) => write!(f, "Failed to decode short ID: {e}"), + ShortIdError::IncorrectLength(e) => write!(f, "Short ID has an incorrect length: {e}"), + } + } +} + +impl std::error::Error for ShortIdError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + ShortIdError::DecodeBech32(e) => Some(e), + ShortIdError::IncorrectLength(e) => Some(e), + } + } +} + impl std::convert::From for ShortId { fn from(h: bitcoin::hashes::sha256::Hash) -> Self { bitcoin::hashes::Hash::as_byte_array(&h)[..8] From 42c5c859d272a68bbbaa74f2cacad55a26cbf5a5 Mon Sep 17 00:00:00 2001 From: spacebear Date: Wed, 1 Jul 2026 21:26:54 -0400 Subject: [PATCH 3/4] Export OutputSubstitution under v2, not just v1 The v2 API returns OutputSubstitution (Receiver:: output_substitution and PayjoinExtras::output_substitution), but its crate-root re-export was gated on the v1 feature. Since v2 does not enable v1, default (v2-only) builds returned a value of a type callers could not name or import. Drop the v1 gate. The re-export lives inside the _core-gated core module, so it is now available whenever v1 or v2 is enabled. --- payjoin/src/core/mod.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/payjoin/src/core/mod.rs b/payjoin/src/core/mod.rs index ec64e9963..1bb912a75 100644 --- a/payjoin/src/core/mod.rs +++ b/payjoin/src/core/mod.rs @@ -25,7 +25,6 @@ pub use uri::{PjParam, PjParseError, PjUri, Uri, UriExt}; pub(crate) mod error_codes; pub(crate) mod output_substitution; -#[cfg(feature = "v1")] pub use output_substitution::OutputSubstitution; #[cfg(feature = "v2")] From 6f27ca3e0483ebca0248ad1a16cc97197584f87a Mon Sep 17 00:00:00 2001 From: spacebear Date: Wed, 1 Jul 2026 21:28:56 -0400 Subject: [PATCH 4/4] Mark persist transition types must_use Every state-machine transition returns a transition wrapper whose .save() both persists the session event and yields the next typestate. Dropping the wrapper without calling .save() silently loses the event and the state advance, with no diagnostic. Add #[must_use] to the transition types so that dropping one is a compiler warning. --- payjoin/src/core/persist.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/payjoin/src/core/persist.rs b/payjoin/src/core/persist.rs index 969513120..5961da6cb 100644 --- a/payjoin/src/core/persist.rs +++ b/payjoin/src/core/persist.rs @@ -69,6 +69,7 @@ impl PersistActions { /// Handles cases where the transition either succeeds with a final result that ends the session, or hits a static condition and stays in the same state. /// State transition may also be a fatal error or transient error. +#[must_use = "a transition must be persisted with .save() to advance the session"] pub struct MaybeSuccessTransitionWithNoResults( Result, Rejection>, ); @@ -156,6 +157,7 @@ where } /// A transition that can result in a state transition, fatal error, or successfully have no results. +#[must_use = "a transition must be persisted with .save() to advance the session"] pub struct MaybeFatalTransitionWithNoResults( Result, Rejection>, ); @@ -240,6 +242,7 @@ where } /// A transition that can be either fatal, transient, or a state transition. +#[must_use = "a transition must be persisted with .save() to advance the session"] pub struct MaybeFatalTransition( pub(crate) Result, Rejection>, ); @@ -310,6 +313,7 @@ where /// A transition that can result in a state transition or a transient error. /// Fatal errors cannot occur in this transition. +#[must_use = "a transition must be persisted with .save() to advance the session"] pub struct MaybeTransientTransition( Result, RejectTransient>, ); @@ -362,6 +366,7 @@ where } /// A transition that always results in a state transition. +#[must_use = "a transition must be persisted with .save() to advance the session"] pub struct NextStateTransition(AcceptNextState); impl NextStateTransition { @@ -400,6 +405,7 @@ impl NextStateTransition { /// No error path exists. Both outcomes are successful from the protocol's point /// of view. The choice is determined by the source typestate's internal data, /// not by the caller. +#[must_use = "a transition must be persisted with .save() to advance the session"] pub struct MaybeTerminalTransition(MaybeTerminalOutcome); impl MaybeTerminalTransition { @@ -446,6 +452,7 @@ impl MaybeTerminalTransition { /// Fatal outcomes still persist an event. When the fatal outcome advances, the /// saved event keeps the session live for replay while the caller receives the /// fatal protocol error. +#[must_use = "a transition must be persisted with .save() to advance the session"] pub struct MaybeTerminalSuccessTransition( MaybeTerminalSuccessOutcome, ); @@ -531,6 +538,7 @@ where /// being persisted. This lets callers receive derived data (e.g. a fallback /// transaction) through the same `.save()` call pattern used by every other /// transition type. +#[must_use = "a transition must be persisted with .save() to advance the session"] pub struct TerminalTransition(Event, T); impl TerminalTransition { @@ -557,6 +565,7 @@ impl TerminalTransition { /// A transition that can result in a succession completion, fatal error, or transient error. /// The transition can also result in no state change. +#[must_use = "a transition must be persisted with .save() to advance the session"] pub enum MaybeFatalOrSuccessTransition { Success(Event), NoResults(CurrentState),