From d1349bb22a80ea2ee2d43108627357440dd4e4d8 Mon Sep 17 00:00:00 2001 From: DanGould Date: Wed, 3 Jun 2026 21:22:13 +0800 Subject: [PATCH 1/7] Add is_expired to session, request, replay errors Persisted-session callers that catch expiry on SessionError, CreateRequestError, and ReplayError could only detect it by matching on the Display string, since the Expired variant of each internal error is hidden behind the opaque public type. Add a payload-free is_expired() predicate to each, mapped from the internal Expired discriminant. This gives callers a stable signal for the one lifecycle condition they branch on, without widening the public error surface. Also add is_expired() to the top-level receive::Error, true only when it wraps an expired v2 SessionError, so receiver callers holding the aggregate error can branch on expiry without unwrapping it. --- payjoin/src/core/error.rs | 22 ++++++++++++++++++++++ payjoin/src/core/receive/error.rs | 15 +++++++++++++++ payjoin/src/core/receive/v2/error.rs | 28 ++++++++++++++++++++++++++++ payjoin/src/core/send/v2/error.rs | 21 +++++++++++++++++++++ payjoin/src/core/send/v2/mod.rs | 24 ++++++++++++++++++++++++ 5 files changed, 110 insertions(+) diff --git a/payjoin/src/core/error.rs b/payjoin/src/core/error.rs index 8c74f54e5..e8f546a05 100644 --- a/payjoin/src/core/error.rs +++ b/payjoin/src/core/error.rs @@ -69,6 +69,13 @@ impl From) -> Self { ReplayError(e) } } +#[cfg(feature = "v2")] +impl ReplayError { + /// Returns `true` if the event log could not be replayed because the + /// session has expired. + pub fn is_expired(&self) -> bool { matches!(self.0, InternalReplayError::Expired(_)) } +} + #[cfg(feature = "v2")] #[derive(Debug)] pub(crate) enum InternalReplayError { @@ -81,3 +88,18 @@ pub(crate) enum InternalReplayError { /// Application storage error PersistenceFailure(ImplementationError), } + +#[cfg(all(test, feature = "v2"))] +mod tests { + use super::*; + use crate::time::Time; + + #[test] + fn replay_error_is_expired() { + let expired: ReplayError<(), ()> = ReplayError(InternalReplayError::Expired(Time::now())); + assert!(expired.is_expired()); + + let other: ReplayError<(), ()> = ReplayError(InternalReplayError::NoEvents); + assert!(!other.is_expired()); + } +} diff --git a/payjoin/src/core/receive/error.rs b/payjoin/src/core/receive/error.rs index d7bd23e39..2c4a71a31 100644 --- a/payjoin/src/core/receive/error.rs +++ b/payjoin/src/core/receive/error.rs @@ -16,6 +16,21 @@ pub enum Error { Implementation(crate::ImplementationError), } +impl Error { + /// Returns `true` if this error is caused by an expired v2 session. + /// + /// This is the one lifecycle condition persisted-session callers branch on. + /// It reuses [`SessionError::is_expired`](crate::receive::v2::SessionError::is_expired) + /// so callers need not match the opaque inner error or its Display string. + pub fn is_expired(&self) -> bool { + #[cfg(feature = "v2")] + if let Error::Protocol(ProtocolError::V2(session)) = self { + return session.is_expired(); + } + false + } +} + impl From<&Error> for JsonReply { fn from(e: &Error) -> Self { match e { diff --git a/payjoin/src/core/receive/v2/error.rs b/payjoin/src/core/receive/v2/error.rs index ec9062c51..0e79e60b1 100644 --- a/payjoin/src/core/receive/v2/error.rs +++ b/payjoin/src/core/receive/v2/error.rs @@ -18,6 +18,11 @@ impl From for SessionError { fn from(value: InternalSessionError) -> Self { SessionError(value) } } +impl SessionError { + /// Returns `true` if the session has expired. + pub fn is_expired(&self) -> bool { matches!(self.0, InternalSessionError::Expired(_)) } +} + impl From for Error { fn from(e: InternalSessionError) -> Self { Error::Protocol(ProtocolError::V2(e.into())) } } @@ -73,3 +78,26 @@ impl error::Error for SessionError { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn session_error_is_expired() { + let expired = SessionError(InternalSessionError::Expired(Time::now())); + assert!(expired.is_expired()); + + let other = SessionError(InternalSessionError::ParseUrl(crate::into_url::Error::BadScheme)); + assert!(!other.is_expired()); + } + + #[test] + fn top_level_error_is_expired() { + let expired = Error::from(InternalSessionError::Expired(Time::now())); + assert!(expired.is_expired()); + + let other = Error::from(InternalSessionError::ParseUrl(crate::into_url::Error::BadScheme)); + assert!(!other.is_expired()); + } +} diff --git a/payjoin/src/core/send/v2/error.rs b/payjoin/src/core/send/v2/error.rs index 4ef7bee06..b6977c252 100644 --- a/payjoin/src/core/send/v2/error.rs +++ b/payjoin/src/core/send/v2/error.rs @@ -49,6 +49,12 @@ impl From for CreateRequestError { fn from(value: InternalCreateRequestError) -> Self { CreateRequestError(value) } } +impl CreateRequestError { + /// Returns `true` if the request could not be created because the session + /// has expired. + pub fn is_expired(&self) -> bool { matches!(self.0, InternalCreateRequestError::Expired(_)) } +} + impl From for CreateRequestError { fn from(value: crate::into_url::Error) -> Self { CreateRequestError(InternalCreateRequestError::Url(value)) @@ -98,3 +104,18 @@ impl From for super::ResponseError { super::InternalValidationError::V2Decapsulation(value.into()).into() } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn create_request_error_is_expired() { + let expired = CreateRequestError(InternalCreateRequestError::Expired(Time::now())); + assert!(expired.is_expired()); + + let other = + CreateRequestError(InternalCreateRequestError::Url(crate::into_url::Error::BadScheme)); + assert!(!other.is_expired()); + } +} diff --git a/payjoin/src/core/send/v2/mod.rs b/payjoin/src/core/send/v2/mod.rs index e7aaa4fde..db6c0a801 100644 --- a/payjoin/src/core/send/v2/mod.rs +++ b/payjoin/src/core/send/v2/mod.rs @@ -482,6 +482,13 @@ impl Sender { &self, ohttp_relay: impl IntoUrl, ) -> Result<(Request, ohttp::ClientResponse), CreateRequestError> { + if self.session_context.pj_param.expiration().elapsed() { + return Err(InternalCreateRequestError::Expired( + self.session_context.pj_param.expiration(), + ) + .into()); + } + // TODO unify with receiver's fn short_id_from_pubkey let hash = sha256::Hash::hash( &HpkeKeyPair::from_secret_key(&self.session_context.reply_key) @@ -698,6 +705,23 @@ mod test { Ok(()) } + #[test] + fn test_poll_request_fails_when_expired() -> Result<(), BoxError> { + let expiration = Time::try_from(SystemTime::now() - Duration::from_secs(1)) + .expect("time in the past should be representable"); + let sender = create_sender_context(expiration)?; + let sender = + Sender { state: PollingForProposal, session_context: sender.session_context.clone() }; + + let result = sender.create_poll_request(EXAMPLE_URL); + + match result { + Ok(_) => panic!("Expected session expiration error, got success"), + Err(error) => assert!(error.is_expired()), + } + Ok(()) + } + #[test] fn test_v2_sender_builder() { let address = Address::from_str("2N47mmrWXsNBvQR6k78hWJoTji57zXwNcU7") From 166473dbc6e53d0ff0b88aa4507cd49c84225c2a Mon Sep 17 00:00:00 2001 From: DanGould Date: Wed, 3 Jun 2026 21:27:58 +0800 Subject: [PATCH 2/7] Expose is_expired on FFI lifecycle errors Bullbitcoin detects session expiry across the binding errors it catches (SessionError, CreateRequestError, SenderReplayError, ReceiverReplayError, and the top-level ReceiverError) by string-matching the Display output, which is brittle. Add an is_expired() accessor to each uniffi Object that delegates to the core predicate. uniffi attaches methods to Object types, not to error enums, so the top-level ReceiverError enum instead gets a receiver_error_is_expired() free function over the caught error. Bullbitcoin can drop its _isExpiredString check at every site. --- payjoin-ffi/src/receive/error.rs | 78 ++++++++++++++++++++++++++++++++ payjoin-ffi/src/send/error.rs | 31 +++++++++++++ 2 files changed, 109 insertions(+) diff --git a/payjoin-ffi/src/receive/error.rs b/payjoin-ffi/src/receive/error.rs index 60f25fd5f..f794697b7 100644 --- a/payjoin-ffi/src/receive/error.rs +++ b/payjoin-ffi/src/receive/error.rs @@ -38,6 +38,21 @@ impl From for ReceiverError { } } +/// Returns `true` if the receiver error is caused by an expired v2 session. +/// +/// Mirrors [`payjoin::receive::Error::is_expired`] so bindings that catch the +/// top-level `ReceiverError` can branch on expiry without matching the Display +/// string. uniffi attaches methods to Object types, not to error enums, so the +/// predicate is exposed as a free function over the caught error. +#[uniffi::export] +pub fn receiver_error_is_expired(error: &ReceiverError) -> bool { + match error { + ReceiverError::Protocol(e) => + matches!(&e.0, receive::ProtocolError::V2(session) if session.is_expired()), + _ => false, + } +} + /// Error that may occur during state machine transitions #[derive(Debug, thiserror::Error, uniffi::Error)] #[error(transparent)] @@ -174,6 +189,12 @@ impl From for JsonReply { #[error(transparent)] pub struct SessionError(#[from] receive::v2::SessionError); +#[uniffi::export] +impl SessionError { + /// Returns `true` if the session has expired. + pub fn is_expired(&self) -> bool { self.0.is_expired() } +} + /// Protocol error raised during output substitution. #[derive(Debug, thiserror::Error, uniffi::Object)] #[uniffi::export(Debug, Display)] @@ -248,3 +269,60 @@ impl From for InputPairError { pub struct ReceiverReplayError( #[from] payjoin::error::ReplayError, ); + +#[uniffi::export] +impl ReceiverReplayError { + /// Returns `true` if the event log could not be replayed because the + /// session has expired. + pub fn is_expired(&self) -> bool { self.0.is_expired() } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn session_and_replay_errors_expose_is_expired() { + // uniffi Objects expose the core predicate to bindings. + let _: fn(&SessionError) -> bool = SessionError::is_expired; + let _: fn(&ReceiverReplayError) -> bool = ReceiverReplayError::is_expired; + } + + #[cfg(feature = "_test-utils")] + #[test] + fn receiver_error_expiry_predicate() { + use std::str::FromStr; + use std::time::Duration; + + use payjoin::bitcoin::Address; + use payjoin::persist::InMemoryPersister; + use payjoin::receive::v2::{ReceiverBuilder, SessionEvent}; + use payjoin::OhttpKeys; + use payjoin_test_utils::{EXAMPLE_URL, KEM, KEY_ID, SYMMETRIC}; + + // Build a receiver whose session is already expired, then surface the + // expiry error through the public polling API. + let address = Address::from_str("tb1q6d3a2w975yny0asuvd9a67ner4nks58ff0q8g4") + .expect("valid address") + .assume_checked(); + let ohttp_keys = OhttpKeys( + ohttp::KeyConfig::new(KEY_ID, KEM, Vec::from(SYMMETRIC)).expect("valid keys"), + ); + let persister = InMemoryPersister::::default(); + let receiver = ReceiverBuilder::new(address, EXAMPLE_URL, ohttp_keys) + .expect("valid builder") + .with_expiration(Duration::from_secs(0)) + .build() + .save(&persister) + .expect("in-memory persister is infallible"); + let expired = + receiver.create_poll_request(EXAMPLE_URL).map(|_| ()).expect_err("session is expired"); + assert!(receiver_error_is_expired(&ReceiverError::from(expired))); + + // A non-expiry error is not reported as expired. + let other = ReceiverError::from(receive::Error::Implementation( + payjoin::ImplementationError::from("not an expiry error"), + )); + assert!(!receiver_error_is_expired(&other)); + } +} diff --git a/payjoin-ffi/src/send/error.rs b/payjoin-ffi/src/send/error.rs index 20fa1171a..0c50f97ed 100644 --- a/payjoin-ffi/src/send/error.rs +++ b/payjoin-ffi/src/send/error.rs @@ -60,6 +60,13 @@ impl From for SenderInputError { #[error(transparent)] pub struct CreateRequestError(#[from] send::v2::CreateRequestError); +#[uniffi::export] +impl CreateRequestError { + /// Returns `true` if the request could not be created because the session + /// has expired. + pub fn is_expired(&self) -> bool { self.0.is_expired() } +} + /// Error returned for v2-specific payload decapsulation errors. #[derive(Debug, thiserror::Error, uniffi::Object)] #[uniffi::export(Debug, Display)] @@ -122,6 +129,13 @@ pub struct SenderReplayError( #[from] payjoin::error::ReplayError, ); +#[uniffi::export] +impl SenderReplayError { + /// Returns `true` if the event log could not be replayed because the + /// session has expired. + pub fn is_expired(&self) -> bool { self.0.is_expired() } +} + /// Error that may occur during state machine transitions #[derive(Debug, thiserror::Error, uniffi::Error)] #[error(transparent)] @@ -201,3 +215,20 @@ where SenderPersistedError::Unexpected } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn create_request_error_exposes_is_expired() { + // A non-expiry CreateRequestError delegates to the core predicate. + let err = CreateRequestError::from(send::v2::CreateRequestError::from( + payjoin::IntoUrlError::BadScheme, + )); + assert!(!err.is_expired()); + + // The accessor is also exposed on the sender replay error. + let _: fn(&SenderReplayError) -> bool = SenderReplayError::is_expired; + } +} From 100f1e5e3c163cbc711d45d2e65a8ed344bf7800 Mon Sep 17 00:00:00 2001 From: DanGould Date: Wed, 3 Jun 2026 21:32:29 +0800 Subject: [PATCH 3/7] Expose ErrorCode and WellKnownError::code Senders could read a BIP-78 well-known error code only by parsing the Display string, because ErrorCode was pub(crate) and WellKnownError kept its code field private. Re-export ErrorCode publicly from the send module and add a WellKnownError::code() accessor that returns it, leaving supported_versions pub(crate). Mark ErrorCode and ResponseError non_exhaustive to reserve room for new variants; this is an intended, pre-1.0 downstream-breaking change that locks the surface now. Marking ResponseError non_exhaustive forces payjoin-ffi's exhaustive match to gain a fallback arm, which maps any future variant to an unrecognized error. --- payjoin-ffi/src/send/error.rs | 4 ++++ payjoin/src/core/error_codes.rs | 1 + payjoin/src/core/send/error.rs | 12 +++++++++++- payjoin/src/core/send/mod.rs | 1 + 4 files changed, 17 insertions(+), 1 deletion(-) diff --git a/payjoin-ffi/src/send/error.rs b/payjoin-ffi/src/send/error.rs index 0c50f97ed..578edf745 100644 --- a/payjoin-ffi/src/send/error.rs +++ b/payjoin-ffi/src/send/error.rs @@ -111,6 +111,10 @@ impl From for ResponseError { send::ResponseError::Validation(e) => ResponseError::Validation(Arc::new(e.into())), send::ResponseError::Unrecognized { error_code, message } => ResponseError::Unrecognized { error_code, msg: message }, + // `send::ResponseError` is non_exhaustive; surface any future + // variant as an unrecognized error rather than failing to build. + other => + ResponseError::Unrecognized { error_code: String::new(), msg: other.to_string() }, } } } diff --git a/payjoin/src/core/error_codes.rs b/payjoin/src/core/error_codes.rs index 2ef372ea1..b60c5375e 100644 --- a/payjoin/src/core/error_codes.rs +++ b/payjoin/src/core/error_codes.rs @@ -2,6 +2,7 @@ //! See: #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[non_exhaustive] pub enum ErrorCode { /// The payjoin endpoint is not available for now. Unavailable, diff --git a/payjoin/src/core/send/error.rs b/payjoin/src/core/send/error.rs index 8c8dc4bbe..6cae042b3 100644 --- a/payjoin/src/core/send/error.rs +++ b/payjoin/src/core/send/error.rs @@ -251,6 +251,7 @@ impl std::error::Error for InternalProposalError { } /// Represent an error returned by Payjoin receiver. +#[non_exhaustive] pub enum ResponseError { /// `WellKnown` Errors are defined in the [`BIP78::ReceiverWellKnownError`] spec. /// @@ -390,6 +391,9 @@ impl From for ResponseError { } impl WellKnownError { + /// Return the well-known BIP-78 error code. + pub fn code(&self) -> ErrorCode { self.code } + /// Create a new well-known error with the given code and message. pub(crate) fn new(code: ErrorCode, message: String) -> Self { Self { code, message, supported_versions: None } @@ -411,7 +415,7 @@ mod tests { let known_str_error = r#"{"errorCode":"version-unsupported", "message":"custom message here", "supported": [1, 2]}"#; match ResponseError::parse(known_str_error) { ResponseError::WellKnown(e) => { - assert_eq!(e.code, ErrorCode::VersionUnsupported); + assert_eq!(e.code(), ErrorCode::VersionUnsupported); assert_eq!(e.message, "custom message here"); assert_eq!( e.to_string(), @@ -420,6 +424,12 @@ mod tests { } _ => panic!("Expected WellKnown error"), }; + let not_enough_money_error = + r#"{"errorCode":"not-enough-money", "message":"not enough money"}"#; + match ResponseError::parse(not_enough_money_error) { + ResponseError::WellKnown(e) => assert_eq!(e.code(), ErrorCode::NotEnoughMoney), + _ => panic!("Expected WellKnown error"), + }; let unrecognized_error = r#"{"errorCode":"random", "message":"random"}"#; assert!(matches!( ResponseError::parse(unrecognized_error), diff --git a/payjoin/src/core/send/mod.rs b/payjoin/src/core/send/mod.rs index 5aa3521d8..bd1a5bee7 100644 --- a/payjoin/src/core/send/mod.rs +++ b/payjoin/src/core/send/mod.rs @@ -21,6 +21,7 @@ use bitcoin::{Amount, FeeRate, Script, ScriptBuf, TxOut, Weight}; pub use error::{BuildSenderError, ResponseError, ValidationError, WellKnownError}; pub(crate) use error::{InternalBuildSenderError, InternalProposalError, InternalValidationError}; +pub use crate::core::error_codes::ErrorCode; use crate::core::Url; use crate::output_substitution::OutputSubstitution; use crate::psbt::{AddressTypeError, PsbtExt, NON_WITNESS_INPUT_WEIGHT}; From 9660537195df69d4b4aa62231e038b6e5dd412ff Mon Sep 17 00:00:00 2001 From: DanGould Date: Wed, 3 Jun 2026 21:35:59 +0800 Subject: [PATCH 4/7] Expose well-known error code over FFI Senders using the bindings could only read a BIP-78 well-known error code by parsing the Display string of WellKnownError. With the core WellKnownError::code() accessor now public, mirror it across the FFI boundary. Add a uniffi ErrorCode enum and a WellKnownError::code() method that maps the core code into it. The enum carries an Unrecognized catch-all so a future core code maps cleanly while the binding catches up. uniffi exports methods on Object types, so the accessor reaches the bindings. --- payjoin-ffi/src/send/error.rs | 49 +++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/payjoin-ffi/src/send/error.rs b/payjoin-ffi/src/send/error.rs index 578edf745..96fdec825 100644 --- a/payjoin-ffi/src/send/error.rs +++ b/payjoin-ffi/src/send/error.rs @@ -119,12 +119,49 @@ impl From for ResponseError { } } +/// BIP-78 well-known error code, surfaced to bindings so senders can branch +/// on the code instead of parsing the Display string. +#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)] +pub enum ErrorCode { + /// The payjoin endpoint is not available for now. + Unavailable, + /// The receiver added some inputs but could not bump the fee. + NotEnoughMoney, + /// This version of payjoin is not supported. + VersionUnsupported, + /// The receiver rejected the original PSBT. + OriginalPsbtRejected, + /// A well-known code newer than this binding understands. + Unrecognized, +} + +impl From for ErrorCode { + fn from(value: send::ErrorCode) -> Self { + match value { + send::ErrorCode::Unavailable => ErrorCode::Unavailable, + send::ErrorCode::NotEnoughMoney => ErrorCode::NotEnoughMoney, + send::ErrorCode::VersionUnsupported => ErrorCode::VersionUnsupported, + send::ErrorCode::OriginalPsbtRejected => ErrorCode::OriginalPsbtRejected, + // `send::ErrorCode` is non_exhaustive; map codes this binding does + // not yet know to Unrecognized. + _ => ErrorCode::Unrecognized, + } + } +} + /// A well-known error that can be safely displayed to end users. #[derive(Debug, thiserror::Error, uniffi::Object)] #[uniffi::export(Debug, Display)] #[error(transparent)] pub struct WellKnownError(#[from] send::WellKnownError); +#[uniffi::export] +impl WellKnownError { + /// Return the BIP-78 well-known error code, letting senders branch on it + /// instead of parsing the Display string. + pub fn code(&self) -> ErrorCode { self.0.code().into() } +} + /// Error that may occur when the sender session event log is replayed #[derive(Debug, thiserror::Error, uniffi::Object)] #[uniffi::export(Debug, Display)] @@ -235,4 +272,16 @@ mod tests { // The accessor is also exposed on the sender replay error. let _: fn(&SenderReplayError) -> bool = SenderReplayError::is_expired; } + + #[test] + fn well_known_error_exposes_code() { + use payjoin::send::ErrorCode as Core; + assert_eq!(ErrorCode::from(Core::Unavailable), ErrorCode::Unavailable); + assert_eq!(ErrorCode::from(Core::NotEnoughMoney), ErrorCode::NotEnoughMoney); + assert_eq!(ErrorCode::from(Core::VersionUnsupported), ErrorCode::VersionUnsupported); + assert_eq!(ErrorCode::from(Core::OriginalPsbtRejected), ErrorCode::OriginalPsbtRejected); + + // The accessor is exposed on the binding's WellKnownError. + let _: fn(&WellKnownError) -> ErrorCode = WellKnownError::code; + } } From 9459fb8906a66a06a1033d68ade8a145af92b849 Mon Sep 17 00:00:00 2001 From: DanGould Date: Thu, 4 Jun 2026 19:41:23 +0800 Subject: [PATCH 5/7] Use is_expired in payjoin-cli When resuming sessions, a replay that fails because the session expired is a normal lifecycle end, not a fault. Branch on the typed ReplayError::is_expired() predicate so an expired session is reported as expired and closed quietly, instead of being logged as an error and printed as a replay failure. --- payjoin-cli/src/app/v2/mod.rs | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/payjoin-cli/src/app/v2/mod.rs b/payjoin-cli/src/app/v2/mod.rs index 8a1ff785e..f6ca9c78e 100644 --- a/payjoin-cli/src/app/v2/mod.rs +++ b/payjoin-cli/src/app/v2/mod.rs @@ -329,8 +329,15 @@ impl AppTrait for App { )); } Err(e) => { - tracing::error!("An error {:?} occurred while replaying receiver session", e); - println!("Session {session_id} receiver failed to replay - {e}"); + if e.is_expired() { + println!("Session {session_id} receiver expired."); + } else { + tracing::error!( + "An error {:?} occurred while replaying receiver session", + e + ); + println!("Session {session_id} receiver failed to replay - {e}"); + } Self::close_failed_session(&recv_persister, &session_id, "receiver"); } } @@ -350,8 +357,12 @@ impl AppTrait for App { )); } Err(e) => { - tracing::error!("An error {:?} occurred while replaying Sender session", e); - println!("Session {session_id} sender failed to replay - {e}"); + if e.is_expired() { + println!("Session {session_id} sender expired."); + } else { + tracing::error!("An error {:?} occurred while replaying Sender session", e); + println!("Session {session_id} sender failed to replay - {e}"); + } Self::close_failed_session(&sender_persister, &session_id, "sender"); } } From f68ebda415dade499821e0e94c1811e2c03d72cc Mon Sep 17 00:00:00 2001 From: DanGould Date: Fri, 5 Jun 2026 18:00:15 +0800 Subject: [PATCH 6/7] Give receiver create-request a dedicated error create_poll_request and create_post_request returned the broad receive::Error, while the sender's equivalents return a narrow CreateRequestError. Callers branching on expiry had to use the top-level receive::Error::is_expired predicate (or, over FFI, a receiver_error_is_expired free function), since an error enum cannot carry a method cleanly across the binding boundary. Add receive::v2::CreateRequestError, symmetric to the sender's: an opaque type over a pub(crate) enum holding only the constructors' real failure modes (URL parse, HPKE, OHTTP encapsulation, expiry), with an is_expired() method. Return it from both create_poll_request and create_post_request. full_relay_url now yields the neutral into_url::Error so each caller converts into its own error type. Over FFI, expose it as the uniffi Object ReceiverCreateRequestError with an is_expired() method. Drop the now-redundant receive::Error::is_expired and the receiver_error_is_expired free function it backed; SessionError and ReplayError keep is_expired for the process_response and replay paths. With create-request no longer producing the broad receive::Error, the From, From, and From conversions into it are now unused; remove them and the ProtocolError import they required. --- payjoin-ffi/src/receive/error.rs | 36 +++++------ payjoin-ffi/src/receive/mod.rs | 8 +-- payjoin/src/core/receive/error.rs | 15 ----- payjoin/src/core/receive/v2/error.rs | 96 +++++++++++++++++++++++----- payjoin/src/core/receive/v2/mod.rs | 48 ++++++++++---- 5 files changed, 133 insertions(+), 70 deletions(-) diff --git a/payjoin-ffi/src/receive/error.rs b/payjoin-ffi/src/receive/error.rs index f794697b7..b8c980e34 100644 --- a/payjoin-ffi/src/receive/error.rs +++ b/payjoin-ffi/src/receive/error.rs @@ -38,19 +38,21 @@ impl From for ReceiverError { } } -/// Returns `true` if the receiver error is caused by an expired v2 session. +/// Error returned when a receiver request could not be created. /// -/// Mirrors [`payjoin::receive::Error::is_expired`] so bindings that catch the -/// top-level `ReceiverError` can branch on expiry without matching the Display -/// string. uniffi attaches methods to Object types, not to error enums, so the -/// predicate is exposed as a free function over the caught error. +/// Returned by `create_poll_request` and `create_post_request`. uniffi attaches +/// methods to Object types, so the expiry predicate is a method here rather than +/// a free function over the top-level `ReceiverError`. +#[derive(Debug, thiserror::Error, uniffi::Object)] +#[uniffi::export(Debug, Display)] +#[error(transparent)] +pub struct ReceiverCreateRequestError(#[from] receive::v2::CreateRequestError); + #[uniffi::export] -pub fn receiver_error_is_expired(error: &ReceiverError) -> bool { - match error { - ReceiverError::Protocol(e) => - matches!(&e.0, receive::ProtocolError::V2(session) if session.is_expired()), - _ => false, - } +impl ReceiverCreateRequestError { + /// Returns `true` if the request could not be created because the session + /// has expired. + pub fn is_expired(&self) -> bool { self.0.is_expired() } } /// Error that may occur during state machine transitions @@ -290,7 +292,7 @@ mod tests { #[cfg(feature = "_test-utils")] #[test] - fn receiver_error_expiry_predicate() { + fn receiver_create_request_error_is_expired() { use std::str::FromStr; use std::time::Duration; @@ -301,7 +303,7 @@ mod tests { use payjoin_test_utils::{EXAMPLE_URL, KEM, KEY_ID, SYMMETRIC}; // Build a receiver whose session is already expired, then surface the - // expiry error through the public polling API. + // expiry error through the dedicated create-request error. let address = Address::from_str("tb1q6d3a2w975yny0asuvd9a67ner4nks58ff0q8g4") .expect("valid address") .assume_checked(); @@ -317,12 +319,6 @@ mod tests { .expect("in-memory persister is infallible"); let expired = receiver.create_poll_request(EXAMPLE_URL).map(|_| ()).expect_err("session is expired"); - assert!(receiver_error_is_expired(&ReceiverError::from(expired))); - - // A non-expiry error is not reported as expired. - let other = ReceiverError::from(receive::Error::Implementation( - payjoin::ImplementationError::from("not an expiry error"), - )); - assert!(!receiver_error_is_expired(&other)); + assert!(ReceiverCreateRequestError::from(expired).is_expired()); } } diff --git a/payjoin-ffi/src/receive/mod.rs b/payjoin-ffi/src/receive/mod.rs index a2cabd805..284ecc5e6 100644 --- a/payjoin-ffi/src/receive/mod.rs +++ b/payjoin-ffi/src/receive/mod.rs @@ -3,8 +3,8 @@ use std::sync::{Arc, RwLock}; pub use error::{ AddressParseError, InputContributionError, InputPairError, JsonReply, OutputSubstitutionError, - ProtocolError, PsbtInputError, ReceiverBuilderError, ReceiverError, SelectionError, - SessionError, + ProtocolError, PsbtInputError, ReceiverBuilderError, ReceiverCreateRequestError, ReceiverError, + SelectionError, SessionError, }; use payjoin::bitcoin::consensus::Decodable; use payjoin::bitcoin::psbt::Psbt; @@ -689,7 +689,7 @@ impl Initialized { pub fn create_poll_request( &self, ohttp_relay: String, - ) -> Result { + ) -> Result { self.0 .create_poll_request(ohttp_relay) .map(|(req, ctx)| RequestResponse { @@ -1324,7 +1324,7 @@ impl PayjoinProposal { pub fn create_post_request( &self, ohttp_relay: String, - ) -> Result { + ) -> Result { self.0.clone().create_post_request(ohttp_relay).map_err(Into::into).map(|(req, ctx)| { RequestResponse { request: req.into(), client_response: Arc::new(ctx.into()) } }) diff --git a/payjoin/src/core/receive/error.rs b/payjoin/src/core/receive/error.rs index 2c4a71a31..d7bd23e39 100644 --- a/payjoin/src/core/receive/error.rs +++ b/payjoin/src/core/receive/error.rs @@ -16,21 +16,6 @@ pub enum Error { Implementation(crate::ImplementationError), } -impl Error { - /// Returns `true` if this error is caused by an expired v2 session. - /// - /// This is the one lifecycle condition persisted-session callers branch on. - /// It reuses [`SessionError::is_expired`](crate::receive::v2::SessionError::is_expired) - /// so callers need not match the opaque inner error or its Display string. - pub fn is_expired(&self) -> bool { - #[cfg(feature = "v2")] - if let Error::Protocol(ProtocolError::V2(session)) = self { - return session.is_expired(); - } - false - } -} - impl From<&Error> for JsonReply { fn from(e: &Error) -> Self { match e { diff --git a/payjoin/src/core/receive/v2/error.rs b/payjoin/src/core/receive/v2/error.rs index 0e79e60b1..e68ee97e2 100644 --- a/payjoin/src/core/receive/v2/error.rs +++ b/payjoin/src/core/receive/v2/error.rs @@ -3,8 +3,6 @@ use std::error; use crate::hpke::HpkeError; use crate::ohttp::{DirectoryResponseError, OhttpEncapsulationError}; -use crate::receive::error::Error; -use crate::receive::ProtocolError; use crate::time::Time; /// Error that may occur during a v2 session typestate change @@ -23,8 +21,8 @@ impl SessionError { pub fn is_expired(&self) -> bool { matches!(self.0, InternalSessionError::Expired(_)) } } -impl From for Error { - fn from(e: InternalSessionError) -> Self { Error::Protocol(ProtocolError::V2(e.into())) } +impl From for SessionError { + fn from(e: crate::into_url::Error) -> Self { SessionError(InternalSessionError::ParseUrl(e)) } } #[derive(Debug)] @@ -41,16 +39,6 @@ pub(crate) enum InternalSessionError { DirectoryResponse(DirectoryResponseError), } -impl From for Error { - fn from(e: OhttpEncapsulationError) -> Self { - InternalSessionError::OhttpEncapsulation(e).into() - } -} - -impl From for Error { - fn from(e: HpkeError) -> Self { InternalSessionError::Hpke(e).into() } -} - impl fmt::Display for SessionError { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { use InternalSessionError::*; @@ -79,6 +67,79 @@ impl error::Error for SessionError { } } +/// Error returned when a receiver request could not be created. +/// +/// Mirrors [`crate::send::v2::CreateRequestError`]: a narrow, opaque error for +/// the `create_poll_request` and `create_post_request` constructors. It carries +/// only their real failure modes, so callers can branch on expiry without +/// matching the broad [`crate::receive::Error`] or its Display string. +#[derive(Debug)] +pub struct CreateRequestError(InternalCreateRequestError); + +#[derive(Debug)] +pub(crate) enum InternalCreateRequestError { + /// Url parsing failed + Url(crate::into_url::Error), + /// Hybrid Public Key Encryption failed + Hpke(HpkeError), + /// OHTTP Encapsulation failed + OhttpEncapsulation(OhttpEncapsulationError), + /// The session has expired + Expired(Time), +} + +impl fmt::Display for CreateRequestError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + use InternalCreateRequestError::*; + + match &self.0 { + Url(e) => write!(f, "cannot parse url: {e:#?}"), + Hpke(e) => write!(f, "v2 error: {e}"), + OhttpEncapsulation(e) => write!(f, "v2 error: {e}"), + Expired(_expiration) => write!(f, "session expired"), + } + } +} + +impl error::Error for CreateRequestError { + fn source(&self) -> Option<&(dyn error::Error + 'static)> { + use InternalCreateRequestError::*; + + match &self.0 { + Url(e) => Some(e), + Hpke(e) => Some(e), + OhttpEncapsulation(e) => Some(e), + Expired(_) => None, + } + } +} + +impl CreateRequestError { + /// Returns `true` if the request could not be created because the session + /// has expired. + pub fn is_expired(&self) -> bool { matches!(self.0, InternalCreateRequestError::Expired(_)) } +} + +impl From for CreateRequestError { + fn from(value: InternalCreateRequestError) -> Self { CreateRequestError(value) } +} + +impl From for CreateRequestError { + fn from(e: crate::into_url::Error) -> Self { + CreateRequestError(InternalCreateRequestError::Url(e)) + } +} + +impl From for CreateRequestError { + fn from(e: HpkeError) -> Self { CreateRequestError(InternalCreateRequestError::Hpke(e)) } +} + +impl From for CreateRequestError { + fn from(e: OhttpEncapsulationError) -> Self { + CreateRequestError(InternalCreateRequestError::OhttpEncapsulation(e)) + } +} + #[cfg(test)] mod tests { use super::*; @@ -93,11 +154,12 @@ mod tests { } #[test] - fn top_level_error_is_expired() { - let expired = Error::from(InternalSessionError::Expired(Time::now())); + fn create_request_error_is_expired() { + let expired = CreateRequestError(InternalCreateRequestError::Expired(Time::now())); assert!(expired.is_expired()); - let other = Error::from(InternalSessionError::ParseUrl(crate::into_url::Error::BadScheme)); + let other = + CreateRequestError(InternalCreateRequestError::Url(crate::into_url::Error::BadScheme)); assert!(!other.is_expired()); } } diff --git a/payjoin/src/core/receive/v2/mod.rs b/payjoin/src/core/receive/v2/mod.rs index 978802369..4fa9653a4 100644 --- a/payjoin/src/core/receive/v2/mod.rs +++ b/payjoin/src/core/receive/v2/mod.rs @@ -31,8 +31,8 @@ use std::time::Duration; use bitcoin::hashes::{sha256, Hash}; use bitcoin::psbt::Psbt; use bitcoin::{Address, Amount, FeeRate, OutPoint, Script, TxOut, Txid}; -pub(crate) use error::InternalSessionError; -pub use error::SessionError; +pub use error::{CreateRequestError, SessionError}; +pub(crate) use error::{InternalCreateRequestError, InternalSessionError}; use serde::de::Deserializer; use serde::{Deserialize, Serialize}; pub use session::{ @@ -85,17 +85,14 @@ pub struct SessionContext { } impl SessionContext { - fn full_relay_url(&self, ohttp_relay: impl IntoUrl) -> Result { - let relay_base = ohttp_relay.into_url().map_err(InternalSessionError::ParseUrl)?; + fn full_relay_url(&self, ohttp_relay: impl IntoUrl) -> Result { + let relay_base = ohttp_relay.into_url()?; // Only reveal scheme and authority to the relay - let directory_base = - self.directory.join("/").map_err(|e| InternalSessionError::ParseUrl(e.into()))?; + let directory_base = self.directory.join("/")?; // Append that information as a path to the relay URL - relay_base - .join(&format!("/{directory_base}")) - .map_err(|e| InternalSessionError::ParseUrl(e.into())) + Ok(relay_base.join(&format!("/{directory_base}"))?) } /// The mailbox ID where the receiver expects the sender's Original PSBT. @@ -550,12 +547,11 @@ impl Receiver { pub fn create_poll_request( &self, ohttp_relay: impl IntoUrl, - ) -> Result<(Request, ohttp::ClientResponse), Error> { + ) -> Result<(Request, ohttp::ClientResponse), CreateRequestError> { if self.session_context.expiration.elapsed() { - return Err(InternalSessionError::Expired(self.session_context.expiration).into()); + return Err(InternalCreateRequestError::Expired(self.session_context.expiration).into()); } - let (body, ohttp_ctx) = - self.fallback_req_body().map_err(InternalSessionError::OhttpEncapsulation)?; + let (body, ohttp_ctx) = self.fallback_req_body()?; let req = Request::new_v2(&self.session_context.full_relay_url(ohttp_relay)?, &body); Ok((req, ohttp_ctx)) } @@ -1288,7 +1284,11 @@ impl Receiver { pub fn create_post_request( &self, ohttp_relay: impl IntoUrl, - ) -> Result<(Request, ohttp::ClientResponse), Error> { + ) -> Result<(Request, ohttp::ClientResponse), CreateRequestError> { + if self.session_context.expiration.elapsed() { + return Err(InternalCreateRequestError::Expired(self.session_context.expiration).into()); + } + let target_resource: Url; let body: Vec; let method: &str; @@ -2134,6 +2134,26 @@ pub mod test { Ok(()) } + #[test] + fn test_create_post_request_expiration() -> Result<(), BoxError> { + let now = crate::time::Time::now(); + let context = SessionContext { expiration: now, ..SHARED_CONTEXT.clone() }; + let psbt_context = PsbtContext { + original_psbt: PARSED_ORIGINAL_PSBT.clone(), + payjoin_psbt: PARSED_PAYJOIN_PROPOSAL.clone(), + }; + let receiver = + Receiver { state: PayjoinProposal { psbt_context }, session_context: context }; + + let expiration = receiver.create_post_request(EXAMPLE_URL); + + match expiration { + Err(error) => assert!(error.is_expired()), + Ok(_) => panic!("Expected session expiration error, got success"), + } + Ok(()) + } + #[test] fn default_max_fee_rate() { let persister = InMemoryPersister::default(); From bf8c3de9549d4f77f3c6abe9d94ca09615ac6c44 Mon Sep 17 00:00:00 2001 From: DanGould Date: Sat, 6 Jun 2026 01:03:45 +0800 Subject: [PATCH 7/7] Rename SelectionError to CoinSelectionError The public error returned by coin-selection paths was named SelectionError, which is ambiguous in a wallet context. Rename it (and its pub(crate) InternalSelectionError) to CoinSelectionError to name what it is, before the 1.0 surface locks the name. Requested on #403. --- payjoin-ffi/src/receive/error.rs | 2 +- payjoin-ffi/src/receive/mod.rs | 8 ++++---- payjoin/src/core/receive/common/mod.rs | 22 +++++++++++----------- payjoin/src/core/receive/error.rs | 20 ++++++++++---------- payjoin/src/core/receive/mod.rs | 4 ++-- payjoin/src/core/receive/v2/mod.rs | 5 +++-- 6 files changed, 31 insertions(+), 30 deletions(-) diff --git a/payjoin-ffi/src/receive/error.rs b/payjoin-ffi/src/receive/error.rs index b8c980e34..542d39aa4 100644 --- a/payjoin-ffi/src/receive/error.rs +++ b/payjoin-ffi/src/receive/error.rs @@ -226,7 +226,7 @@ impl From for OutputSubstitutionError { #[derive(Debug, thiserror::Error, uniffi::Object)] #[uniffi::export(Debug, Display)] #[error(transparent)] -pub struct SelectionError(#[from] receive::SelectionError); +pub struct CoinSelectionError(#[from] receive::CoinSelectionError); /// Error that may occur when input contribution fails. #[derive(Debug, thiserror::Error, uniffi::Object)] diff --git a/payjoin-ffi/src/receive/mod.rs b/payjoin-ffi/src/receive/mod.rs index 284ecc5e6..e560c1806 100644 --- a/payjoin-ffi/src/receive/mod.rs +++ b/payjoin-ffi/src/receive/mod.rs @@ -2,9 +2,9 @@ use std::str::FromStr; use std::sync::{Arc, RwLock}; pub use error::{ - AddressParseError, InputContributionError, InputPairError, JsonReply, OutputSubstitutionError, - ProtocolError, PsbtInputError, ReceiverBuilderError, ReceiverCreateRequestError, ReceiverError, - SelectionError, SessionError, + AddressParseError, CoinSelectionError, InputContributionError, InputPairError, JsonReply, + OutputSubstitutionError, ProtocolError, PsbtInputError, ReceiverBuilderError, + ReceiverCreateRequestError, ReceiverError, SessionError, }; use payjoin::bitcoin::consensus::Decodable; use payjoin::bitcoin::psbt::Psbt; @@ -1069,7 +1069,7 @@ impl WantsInputs { pub fn try_preserving_privacy( &self, candidate_inputs: Vec>, - ) -> Result, SelectionError> { + ) -> Result, CoinSelectionError> { let candidate_inputs: Vec = candidate_inputs.into_iter().map(|pair| Arc::unwrap_or_clone(pair).into()).collect(); match self.0.clone().try_preserving_privacy(candidate_inputs) { diff --git a/payjoin/src/core/receive/common/mod.rs b/payjoin/src/core/receive/common/mod.rs index 9a93fb8f0..f1a066e3b 100644 --- a/payjoin/src/core/receive/common/mod.rs +++ b/payjoin/src/core/receive/common/mod.rs @@ -12,11 +12,11 @@ use bitcoin::{Amount, FeeRate, Script, TxIn, TxOut, Weight}; use serde::{Deserialize, Serialize}; use super::error::{ - InputContributionError, InternalInputContributionError, InternalOutputSubstitutionError, - InternalSelectionError, + InputContributionError, InternalCoinSelectionError, InternalInputContributionError, + InternalOutputSubstitutionError, }; use super::optional_parameters::Params; -use super::{InputPair, OutputSubstitutionError, SelectionError}; +use super::{CoinSelectionError, InputPair, OutputSubstitutionError}; use crate::output_substitution::OutputSubstitution; use crate::psbt::PsbtExt; use crate::receive::{InternalPayloadError, OriginalPayload, PsbtContext}; @@ -207,7 +207,7 @@ impl WantsInputs { pub fn try_preserving_privacy( &self, candidate_inputs: impl IntoIterator, - ) -> Result { + ) -> Result { let mut candidate_inputs = candidate_inputs.into_iter().peekable(); self.avoid_uih(&mut candidate_inputs) @@ -227,9 +227,9 @@ impl WantsInputs { pub(super) fn avoid_uih( &self, candidate_inputs: impl IntoIterator, - ) -> Result { + ) -> Result { if self.payjoin_psbt.outputs.len() != 2 { - return Err(InternalSelectionError::UnsupportedOutputLength.into()); + return Err(InternalCoinSelectionError::UnsupportedOutputLength.into()); } let min_out_sats = self @@ -263,15 +263,15 @@ impl WantsInputs { } // No suitable privacy preserving selection found - Err(InternalSelectionError::NotFound.into()) + Err(InternalCoinSelectionError::NotFound.into()) } /// Returns the first candidate input in the provided list or errors if the list is empty. fn select_first_candidate( &self, candidate_inputs: impl IntoIterator, - ) -> Result { - candidate_inputs.into_iter().next().ok_or(InternalSelectionError::Empty.into()) + ) -> Result { + candidate_inputs.into_iter().next().ok_or(InternalCoinSelectionError::Empty.into()) } /// Contributes the provided list of inputs to the transaction at random indices. If the total input @@ -522,7 +522,7 @@ mod tests { let result = wants_inputs.try_preserving_privacy(empty_candidate_inputs); assert_eq!( result.unwrap_err(), - SelectionError::from(InternalSelectionError::Empty), + CoinSelectionError::from(InternalCoinSelectionError::Empty), "try_preserving_privacy should fail with empty candidate inputs" ); } @@ -604,7 +604,7 @@ mod tests { let avoid_uih = payjoin.avoid_uih(input_iter); assert_eq!( avoid_uih.unwrap_err(), - SelectionError::from(InternalSelectionError::UnsupportedOutputLength), + CoinSelectionError::from(InternalCoinSelectionError::UnsupportedOutputLength), "Payjoin below minimum allowed outputs for avoid uih and should error" ); } diff --git a/payjoin/src/core/receive/error.rs b/payjoin/src/core/receive/error.rs index d7bd23e39..0a3649478 100644 --- a/payjoin/src/core/receive/error.rs +++ b/payjoin/src/core/receive/error.rs @@ -353,10 +353,10 @@ impl std::error::Error for OutputSubstitutionError { /// This is currently opaque type because we aren't sure which variants will stay. /// You can only display it. #[derive(Debug, PartialEq, Eq)] -pub struct SelectionError(InternalSelectionError); +pub struct CoinSelectionError(InternalCoinSelectionError); #[derive(Debug, PartialEq, Eq)] -pub(crate) enum InternalSelectionError { +pub(crate) enum InternalCoinSelectionError { /// No candidates available for selection Empty, /// Current privacy selection implementation only supports 2-output transactions @@ -365,23 +365,23 @@ pub(crate) enum InternalSelectionError { NotFound, } -impl fmt::Display for SelectionError { +impl fmt::Display for CoinSelectionError { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { match &self.0 { - InternalSelectionError::Empty => write!(f, "No candidates available for selection"), - InternalSelectionError::UnsupportedOutputLength => write!( + InternalCoinSelectionError::Empty => write!(f, "No candidates available for selection"), + InternalCoinSelectionError::UnsupportedOutputLength => write!( f, "Current privacy selection implementation only supports 2-output transactions" ), - InternalSelectionError::NotFound => + InternalCoinSelectionError::NotFound => write!(f, "No selection candidates improve privacy"), } } } -impl error::Error for SelectionError { +impl error::Error for CoinSelectionError { fn source(&self) -> Option<&(dyn error::Error + 'static)> { - use InternalSelectionError::*; + use InternalCoinSelectionError::*; match &self.0 { Empty => None, @@ -390,8 +390,8 @@ impl error::Error for SelectionError { } } } -impl From for SelectionError { - fn from(value: InternalSelectionError) -> Self { SelectionError(value) } +impl From for CoinSelectionError { + fn from(value: InternalCoinSelectionError) -> Self { CoinSelectionError(value) } } /// Error that may occur when input contribution fails. diff --git a/payjoin/src/core/receive/mod.rs b/payjoin/src/core/receive/mod.rs index bac207c53..259e91422 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::{ - Error, InputContributionError, JsonReply, OutputSubstitutionError, PayloadError, ProtocolError, - SelectionError, + CoinSelectionError, Error, InputContributionError, JsonReply, OutputSubstitutionError, + PayloadError, ProtocolError, }; use optional_parameters::Params; use serde::{Deserialize, Serialize}; diff --git a/payjoin/src/core/receive/v2/mod.rs b/payjoin/src/core/receive/v2/mod.rs index 4fa9653a4..79e3ef47d 100644 --- a/payjoin/src/core/receive/v2/mod.rs +++ b/payjoin/src/core/receive/v2/mod.rs @@ -45,7 +45,8 @@ use web_time::Duration; use self::sealed::FallbackTx; use super::error::{Error, InputContributionError}; use super::{ - common, InternalPayloadError, JsonReply, OutputSubstitutionError, ProtocolError, SelectionError, + common, CoinSelectionError, InternalPayloadError, JsonReply, OutputSubstitutionError, + ProtocolError, }; use crate::core::Url; use crate::error::{InternalReplayError, ReplayError}; @@ -1093,7 +1094,7 @@ impl Receiver { pub fn try_preserving_privacy( &self, candidate_inputs: impl IntoIterator, - ) -> Result { + ) -> Result { self.inner.try_preserving_privacy(candidate_inputs) }