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
35 changes: 12 additions & 23 deletions payjoin/src/core/persist.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ impl<Event, SuccessValue, CurrentState, Err>
persister.save_maybe_no_results_success_transition(self)
}
}
/// A transition that can result in a state transition, fatal error, transient error, or successfully have no results.
/// A transition that can result in a state transition, fatal error, or successfully have no results.
pub struct MaybeFatalTransitionWithNoResults<Event, NextState, CurrentState, Err>(
Result<AcceptOptionalTransition<Event, NextState, CurrentState>, Rejection<Event, Err>>,
);
Expand All @@ -58,11 +58,6 @@ impl<Event, NextState, CurrentState, Err>
MaybeFatalTransitionWithNoResults(Err(Rejection::fatal(event, error)))
}

#[inline]
pub(crate) fn transient(error: Err) -> Self {
MaybeFatalTransitionWithNoResults(Err(Rejection::transient(error)))
}

#[inline]
pub(crate) fn no_results(current_state: CurrentState) -> Self {
MaybeFatalTransitionWithNoResults(Ok(AcceptOptionalTransition::NoResults(current_state)))
Expand Down Expand Up @@ -92,7 +87,7 @@ impl<Event, NextState, CurrentState, Err>

/// A transition that can be either fatal, transient, or a state transition.
pub struct MaybeFatalTransition<Event, NextState, Err>(
Result<AcceptNextState<Event, NextState>, Rejection<Event, Err>>,
pub(crate) Result<AcceptNextState<Event, NextState>, Rejection<Event, Err>>,
);

impl<Event, NextState, Err> MaybeFatalTransition<Event, NextState, Err> {
Expand Down Expand Up @@ -185,7 +180,7 @@ where
}

/// A transition that always results in a state transition.
pub struct NextStateTransition<Event, NextState>(AcceptNextState<Event, NextState>);
pub struct NextStateTransition<Event, NextState>(pub(crate) AcceptNextState<Event, NextState>);

impl<Event, NextState> NextStateTransition<Event, NextState> {
#[inline]
Expand Down Expand Up @@ -232,7 +227,7 @@ impl<Event, NextState, Err> MaybeBadInitInputsTransition<Event, NextState, Err>
}

/// Wrapper that marks the progression of a state machine
pub struct AcceptNextState<Event, NextState>(Event, NextState);
pub struct AcceptNextState<Event, NextState>(pub(crate) Event, pub(crate) NextState);
/// Wrapper that marks the success of a state machine with a value that was returned
struct AcceptCompleted<SuccessValue>(SuccessValue);

Expand Down Expand Up @@ -262,12 +257,19 @@ impl<Event, Err> Rejection<Event, Err> {
pub struct RejectFatal<Event, Err>(Event, Err);
/// Represents a transient rejection of a state transition.
/// When this error occurs, the session should resume from its current state.
pub struct RejectTransient<Err>(Err);
pub struct RejectTransient<Err>(pub(crate) Err);
/// Represents a bad initial inputs to the state machine.
/// When this error occurs, the session cannot be created.
/// The wrapper contains the error and should be returned to the caller.
pub struct RejectBadInitInputs<Err>(Err);

impl<Err: std::error::Error> std::fmt::Display for RejectTransient<Err> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let RejectTransient(err) = self;
write!(f, "{err}")
}
}

/// Error type that represents all possible errors that can be returned when processing a state transition
#[derive(Debug, Clone)]
pub struct PersistedError<ApiError: std::error::Error, StorageError: std::error::Error>(
Expand Down Expand Up @@ -1062,19 +1064,6 @@ mod tests {
.save(persister)
}),
},
// Transient error
TestCase {
expected_result: ExpectedResult {
events: vec![],
is_closed: false,
error: Some(InternalPersistedError::Transient(InMemoryTestError {}).into()),
success: None,
},
test: Box::new(move |persister| {
MaybeFatalTransitionWithNoResults::transient(InMemoryTestError {})
.save(persister)
}),
},
// Fatal error
TestCase {
expected_result: ExpectedResult {
Expand Down
187 changes: 164 additions & 23 deletions payjoin/src/core/receive/v2/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -281,19 +281,11 @@ impl Receiver<Initialized> {
let current_state = self.clone();
let proposal = match self.inner_process_res(body, context) {
Ok(proposal) => proposal,
Err(e) => {
// Dir and OHTTP related error are transient
// Malformities or invalid responses are considered fatal
match e {
Error::ReplyToSender(ReplyableError::Implementation(_)) =>
return MaybeFatalTransitionWithNoResults::transient(e),
_ =>
return MaybeFatalTransitionWithNoResults::fatal(
SessionEvent::SessionInvalid(e.to_string(), None),
e,
),
};
}
Err(e) =>
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just noting this so I don't forget. Removing transient errors from this state transition does make sense right now given that we are implicitly treating all the errors as fatal. However, there are certainly transient errors here that could be resolved based on some external events. e.g changing the ohttp relays, waiting for the dir to be operational again, etc...

return MaybeFatalTransitionWithNoResults::fatal(
SessionEvent::SessionInvalid(e.to_string(), None),
e,
),
};

if let Some(proposal) = proposal {
Expand Down Expand Up @@ -916,11 +908,17 @@ pub(crate) fn pj_uri<'a>(
pub mod test {
use std::str::FromStr;

use bitcoin::FeeRate;
use once_cell::sync::Lazy;
use payjoin_test_utils::{BoxError, EXAMPLE_URL, KEM, KEY_ID, SYMMETRIC};
use payjoin_test_utils::{
BoxError, EXAMPLE_URL, KEM, KEY_ID, PARSED_ORIGINAL_PSBT, QUERY_PARAMS, SYMMETRIC,
};

use super::*;
use crate::persist::NoopSessionPersister;
use crate::persist::{NoopSessionPersister, RejectTransient, Rejection};
use crate::receive::optional_parameters::Params;
use crate::receive::{v2, ReplyableError};
use crate::ImplementationError;

pub(crate) static SHARED_CONTEXT: Lazy<SessionContext> = Lazy::new(|| SessionContext {
address: Address::from_str("tb1q6d3a2w975yny0asuvd9a67ner4nks58ff0q8g4")
Expand Down Expand Up @@ -950,15 +948,123 @@ pub mod test {
e: None,
});

pub(crate) fn unchecked_proposal_v2_from_test_vector() -> UncheckedProposal {
let pairs = url::form_urlencoded::parse(QUERY_PARAMS.as_bytes());
let params = Params::from_query_pairs(pairs, &[Version::Two])
.expect("Test utils query params should not fail");
UncheckedProposal {
v1: v1::UncheckedProposal { psbt: PARSED_ORIGINAL_PSBT.clone(), params },
context: SHARED_CONTEXT.clone(),
}
}

#[test]
fn test_unchecked_proposal_transient_error() -> Result<(), BoxError> {
let unchecked_proposal = unchecked_proposal_v2_from_test_vector();
let receiver = v2::Receiver { state: unchecked_proposal };

let unchecked_proposal = receiver.check_broadcast_suitability(Some(FeeRate::MIN), |_| {
Err(ImplementationError::from(ReplyableError::Implementation("mock error".into())))
});

match unchecked_proposal {
MaybeFatalTransition(Err(Rejection::Transient(RejectTransient(
ReplyableError::Implementation(error),
)))) => assert_eq!(
error.to_string(),
ReplyableError::Implementation("mock error".into()).to_string()
),
_ => panic!("Expected ReplyableError but got unexpected error or Ok"),
}

Ok(())
}

#[test]
fn test_maybe_inputs_seen_transient_error() -> Result<(), BoxError> {
let unchecked_proposal = unchecked_proposal_v2_from_test_vector();
let receiver = v2::Receiver { state: unchecked_proposal };

let maybe_inputs_owned = receiver.assume_interactive_receiver();
let maybe_inputs_seen = maybe_inputs_owned.0 .1.check_inputs_not_owned(|_| {
Err(ImplementationError::from(ReplyableError::Implementation("mock error".into())))
});

match maybe_inputs_seen {
MaybeFatalTransition(Err(Rejection::Transient(RejectTransient(
ReplyableError::Implementation(error),
)))) => assert_eq!(
error.to_string(),
ReplyableError::Implementation("mock error".into()).to_string()
),
_ => panic!("Expected ReplyableError but got unexpected error or Ok"),
}

Ok(())
}

#[test]
fn test_outputs_unknown_transient_error() -> Result<(), BoxError> {
let unchecked_proposal = unchecked_proposal_v2_from_test_vector();
let receiver = v2::Receiver { state: unchecked_proposal };

let maybe_inputs_owned = receiver.assume_interactive_receiver();
let maybe_inputs_seen = maybe_inputs_owned.0 .1.check_inputs_not_owned(|_| Ok(false));
let outputs_unknown = match maybe_inputs_seen.0 {
Ok(state) => state.1.check_no_inputs_seen_before(|_| {
Err(ImplementationError::from(ReplyableError::Implementation("mock error".into())))
}),
Err(_) => panic!("Expected Ok, got Err"),
};

match outputs_unknown {
MaybeFatalTransition(Err(Rejection::Transient(RejectTransient(
ReplyableError::Implementation(error),
)))) => assert_eq!(
error.to_string(),
ReplyableError::Implementation("mock error".into()).to_string()
),
_ => panic!("Expected ReplyableError but got unexpected error or Ok"),
}

Ok(())
}

#[test]
fn test_wants_outputs_transient_error() -> Result<(), BoxError> {
let unchecked_proposal = unchecked_proposal_v2_from_test_vector();
let receiver = v2::Receiver { state: unchecked_proposal };

let maybe_inputs_owned = receiver.assume_interactive_receiver();
let maybe_inputs_seen = maybe_inputs_owned.0 .1.check_inputs_not_owned(|_| Ok(false));
let outputs_unknown = match maybe_inputs_seen.0 {
Ok(state) => state.1.check_no_inputs_seen_before(|_| Ok(false)),
Err(_) => panic!("Expected Ok, got Err"),
};
let wants_outputs = match outputs_unknown.0 {
Ok(state) => state.1.identify_receiver_outputs(|_| {
Err(ImplementationError::from(ReplyableError::Implementation("mock error".into())))
}),
Err(_) => panic!("Expected Ok, got Err"),
};

match wants_outputs {
MaybeFatalTransition(Err(Rejection::Transient(RejectTransient(
ReplyableError::Implementation(error),
)))) => assert_eq!(
error.to_string(),
ReplyableError::Implementation("mock error".into()).to_string()
),
_ => panic!("Expected ReplyableError but got unexpected error or Ok"),
}

Ok(())
}

#[test]
fn test_extract_err_req() -> Result<(), BoxError> {
let noop_persister = NoopSessionPersister::default();
let receiver = Receiver {
state: UncheckedProposal {
v1: crate::receive::v1::test::unchecked_proposal_from_test_vector(),
context: SHARED_CONTEXT.clone(),
},
};
let receiver = Receiver { state: unchecked_proposal_v2_from_test_vector() };

let server_error = || {
receiver
Expand All @@ -972,8 +1078,8 @@ pub mod test {
"message": "Receiver error"
});

let error = server_error().expect_err("expected error");
let res = error.api_error().expect("expected api error");
let error = server_error().expect_err("Server error should be populated with mock error");
let res = error.api_error().expect("check_broadcast error should propagate to api error");
let actual_json = JsonReply::from(&res);
assert_eq!(actual_json.to_json(), expected_json);

Expand All @@ -985,6 +1091,41 @@ pub mod test {
Ok(())
}

#[test]
fn test_extract_err_req_expiry() -> Result<(), BoxError> {
let now = SystemTime::now();
let noop_persister = NoopSessionPersister::default();
let context = SessionContext { expiry: now, ..SHARED_CONTEXT.clone() };
let receiver = Receiver {
state: UncheckedProposal {
v1: crate::receive::v1::test::unchecked_proposal_from_test_vector(),
context: context.clone(),
},
};
Comment on lines +1099 to +1104
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: can we use the helper method defined above? (unchecked_proposal_v2_from_test_vector)

Copy link
Collaborator Author

@benalleng benalleng Jul 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, the expiry time now was changed in the context above. so we need to build the receiver here.


let server_error = || {
receiver
.clone()
.check_broadcast_suitability(None, |_| Err("mock error".into()))
.save(&noop_persister)
};

let error = server_error().expect_err("Server error should be populated with mock error");
let res = error.api_error().expect("check_broadcast error should propagate to api error");
let actual_json = JsonReply::from(&res);

let expiry = extract_err_req(&actual_json, &*EXAMPLE_URL, &context);

match expiry {
Err(error) => assert_eq!(
error.to_string(),
SessionError::from(InternalSessionError::Expired(now)).to_string()
),
Ok(_) => panic!("Expected session expiry error, got success"),
}
Ok(())
}

#[test]
fn default_expiry() {
let now = SystemTime::now();
Expand Down
Loading