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
19 changes: 15 additions & 4 deletions payjoin-cli/src/app/v2/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
}
Expand All @@ -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");
}
}
Expand Down
76 changes: 75 additions & 1 deletion payjoin-ffi/src/receive/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,23 @@ impl From<receive::Error> for ReceiverError {
}
}

/// Error returned when a receiver request could not be created.
///
/// 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]
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
#[derive(Debug, thiserror::Error, uniffi::Error)]
#[error(transparent)]
Expand Down Expand Up @@ -174,6 +191,12 @@ impl From<ProtocolError> 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)]
Expand Down Expand Up @@ -203,7 +226,7 @@ impl From<FfiValidationError> 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)]
Expand Down Expand Up @@ -248,3 +271,54 @@ impl From<FfiValidationError> for InputPairError {
pub struct ReceiverReplayError(
#[from] payjoin::error::ReplayError<receive::v2::ReceiveSession, receive::v2::SessionEvent>,
);

#[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_create_request_error_is_expired() {
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 dedicated create-request error.
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::<SessionEvent>::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!(ReceiverCreateRequestError::from(expired).is_expired());
}
}
12 changes: 6 additions & 6 deletions payjoin-ffi/src/receive/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ use std::str::FromStr;
use std::sync::{Arc, RwLock};

pub use error::{
AddressParseError, InputContributionError, InputPairError, JsonReply, OutputSubstitutionError,
ProtocolError, PsbtInputError, ReceiverBuilderError, 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;
Expand Down Expand Up @@ -689,7 +689,7 @@ impl Initialized {
pub fn create_poll_request(
&self,
ohttp_relay: String,
) -> Result<RequestResponse, ReceiverError> {
) -> Result<RequestResponse, ReceiverCreateRequestError> {
self.0
.create_poll_request(ohttp_relay)
.map(|(req, ctx)| RequestResponse {
Expand Down Expand Up @@ -1069,7 +1069,7 @@ impl WantsInputs {
pub fn try_preserving_privacy(
&self,
candidate_inputs: Vec<Arc<InputPair>>,
) -> Result<Arc<InputPair>, SelectionError> {
) -> Result<Arc<InputPair>, CoinSelectionError> {
let candidate_inputs: Vec<payjoin::receive::InputPair> =
candidate_inputs.into_iter().map(|pair| Arc::unwrap_or_clone(pair).into()).collect();
match self.0.clone().try_preserving_privacy(candidate_inputs) {
Expand Down Expand Up @@ -1324,7 +1324,7 @@ impl PayjoinProposal {
pub fn create_post_request(
&self,
ohttp_relay: String,
) -> Result<RequestResponse, ReceiverError> {
) -> Result<RequestResponse, ReceiverCreateRequestError> {
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()) }
})
Expand Down
84 changes: 84 additions & 0 deletions payjoin-ffi/src/send/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,13 @@ impl From<FfiValidationError> 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)]
Expand Down Expand Up @@ -104,6 +111,40 @@ impl From<send::ResponseError> 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() },
}
}
}

/// 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<send::ErrorCode> 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,
}
}
}
Expand All @@ -114,6 +155,13 @@ impl From<send::ResponseError> for ResponseError {
#[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)]
Expand All @@ -122,6 +170,13 @@ pub struct SenderReplayError(
#[from] payjoin::error::ReplayError<send::v2::SendSession, send::v2::SessionEvent>,
);

#[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)]
Expand Down Expand Up @@ -201,3 +256,32 @@ 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;
}

#[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;
}
}
22 changes: 22 additions & 0 deletions payjoin/src/core/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,13 @@ impl<SessionState: Debug, SessionEvent: Debug> From<InternalReplayError<SessionS
fn from(e: InternalReplayError<SessionState, SessionEvent>) -> Self { ReplayError(e) }
}

#[cfg(feature = "v2")]
impl<SessionState, SessionEvent> ReplayError<SessionState, SessionEvent> {
/// 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<SessionState, SessionEvent> {
Expand All @@ -81,3 +88,18 @@ pub(crate) enum InternalReplayError<SessionState, SessionEvent> {
/// 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());
}
}
1 change: 1 addition & 0 deletions payjoin/src/core/error_codes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
//! See: <https://github.com/bitcoin/bips/blob/master/bip-0078.mediawiki#receivers-well-known-errors>

#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[non_exhaustive]
pub enum ErrorCode {
/// The payjoin endpoint is not available for now.
Unavailable,
Expand Down
Loading
Loading