Skip to content
Draft
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
82 changes: 82 additions & 0 deletions payjoin-ffi/src/receive/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,66 @@ macro_rules! impl_save_for_transition {
};
}

macro_rules! impl_generic_methods_for_receiver {
($ty:ident) => {
#[uniffi::export]
impl $ty {
/// Explicitly fail the session.
///
/// This method allows implementations to terminate the payjoin session when
/// they encounter errors that cannot be resolved, such as insufficient
/// funds or a double-spend detection.
pub fn fail(&self) -> FailTransition {
FailTransition(Arc::new(RwLock::new(Some(self.0.clone().fail()))))
}

/// Explicitly cancel the session.
///
/// This method allows implementations to terminate the payjoin session when
/// the user decides to cancel the operation interactively.
pub fn cancel(&self) -> CancelTransition {
CancelTransition(Arc::new(RwLock::new(Some(self.0.clone().cancel()))))
}
}
};
}

/// Transition wrapper for `fail()` method
#[derive(uniffi::Object)]
#[allow(clippy::type_complexity)]
pub struct FailTransition(
Arc<
RwLock<
Option<
NextStateTransition<
payjoin::receive::v2::SessionEvent,
payjoin::receive::v2::Receiver<payjoin::receive::v2::HasReplyableError>,
>,
>,
>,
>,
);

impl_save_for_transition!(FailTransition, HasReplyableError);

/// Transition wrapper for `cancel()` method
#[derive(uniffi::Object)]
#[allow(clippy::type_complexity)]
pub struct CancelTransition(
Arc<
RwLock<
Option<
NextStateTransition<
payjoin::receive::v2::SessionEvent,
payjoin::receive::v2::Receiver<payjoin::receive::v2::HasReplyableError>,
>,
>,
>,
>,
);

impl_save_for_transition!(CancelTransition, HasReplyableError);

#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Object)]
pub struct ReceiverSessionEvent(payjoin::receive::v2::SessionEvent);

Expand Down Expand Up @@ -362,6 +422,8 @@ impl Initialized {
}
}

impl_generic_methods_for_receiver!(Initialized);

#[derive(Clone, uniffi::Object)]
pub struct UncheckedOriginalPayload(
payjoin::receive::v2::Receiver<payjoin::receive::v2::UncheckedOriginalPayload>,
Expand Down Expand Up @@ -455,6 +517,8 @@ impl UncheckedOriginalPayload {
}
}

impl_generic_methods_for_receiver!(UncheckedOriginalPayload);

#[derive(Clone, uniffi::Object)]
pub struct MaybeInputsOwned(payjoin::receive::v2::Receiver<payjoin::receive::v2::MaybeInputsOwned>);

Expand Down Expand Up @@ -510,6 +574,8 @@ impl MaybeInputsOwned {
}
}

impl_generic_methods_for_receiver!(MaybeInputsOwned);

#[derive(Clone, uniffi::Object)]
pub struct MaybeInputsSeen(payjoin::receive::v2::Receiver<payjoin::receive::v2::MaybeInputsSeen>);

Expand Down Expand Up @@ -561,6 +627,8 @@ impl MaybeInputsSeen {
}
}

impl_generic_methods_for_receiver!(MaybeInputsSeen);

/// The receiver has not yet identified which outputs belong to the receiver.
///
/// Only accept PSBTs that send us money.
Expand Down Expand Up @@ -610,6 +678,8 @@ impl OutputsUnknown {
}
}

impl_generic_methods_for_receiver!(OutputsUnknown);

#[derive(uniffi::Object)]
pub struct WantsOutputs(payjoin::receive::v2::Receiver<payjoin::receive::v2::WantsOutputs>);

Expand Down Expand Up @@ -670,6 +740,8 @@ impl WantsOutputs {
}
}

impl_generic_methods_for_receiver!(WantsOutputs);

#[derive(uniffi::Object)]
pub struct WantsInputs(payjoin::receive::v2::Receiver<payjoin::receive::v2::WantsInputs>);

Expand Down Expand Up @@ -739,6 +811,8 @@ impl WantsInputs {
}
}

impl_generic_methods_for_receiver!(WantsInputs);

#[derive(Debug, Clone, uniffi::Object)]
pub struct InputPair(payjoin::receive::InputPair);

Expand Down Expand Up @@ -827,6 +901,8 @@ impl WantsFeeRange {
}
}

impl_generic_methods_for_receiver!(WantsFeeRange);

#[derive(uniffi::Object)]
pub struct ProvisionalProposal(
pub payjoin::receive::v2::Receiver<payjoin::receive::v2::ProvisionalProposal>,
Expand Down Expand Up @@ -884,6 +960,8 @@ impl ProvisionalProposal {
pub fn psbt_to_sign(&self) -> bitcoin_ffi::Psbt { self.0.clone().psbt_to_sign().into() }
}

impl_generic_methods_for_receiver!(ProvisionalProposal);

#[derive(Clone, uniffi::Object)]
pub struct PayjoinProposal(
pub payjoin::receive::v2::Receiver<payjoin::receive::v2::PayjoinProposal>,
Expand Down Expand Up @@ -983,6 +1061,8 @@ impl PayjoinProposal {
}
}

impl_generic_methods_for_receiver!(PayjoinProposal);

#[derive(Clone, uniffi::Object)]
pub struct HasReplyableError(
pub payjoin::receive::v2::Receiver<payjoin::receive::v2::HasReplyableError>,
Expand Down Expand Up @@ -1057,6 +1137,8 @@ impl HasReplyableError {
}
}

impl_generic_methods_for_receiver!(HasReplyableError);

/// Session persister that should save and load events as JSON strings.
#[uniffi::export(with_foreign)]
pub trait JsonReceiverSessionPersister: Send + Sync {
Expand Down
74 changes: 65 additions & 9 deletions payjoin/src/core/receive/v2/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,10 @@ impl ReceiveSession {

(session, SessionEvent::GotReplyableError(error)) =>
Ok(ReceiveSession::HasReplyableError(Receiver {
state: HasReplyableError { error_reply: error.clone() },
state: HasReplyableError {
error_reply: error.clone(),
outcome: SessionOutcome::Failure,
},
session_context: match session {
ReceiveSession::Initialized(r) => r.session_context,
ReceiveSession::UncheckedOriginalPayload(r) => r.session_context,
Expand Down Expand Up @@ -555,7 +558,10 @@ impl Receiver<UncheckedOriginalPayload> {
Err(e) => MaybeFatalTransition::replyable_error(
SessionEvent::GotReplyableError((&e).into()),
Receiver {
state: HasReplyableError { error_reply: (&e).into() },
state: HasReplyableError {
error_reply: (&e).into(),
outcome: SessionOutcome::Failure,
},
session_context: self.session_context,
},
e,
Expand Down Expand Up @@ -632,7 +638,10 @@ impl Receiver<MaybeInputsOwned> {
return MaybeFatalTransition::replyable_error(
SessionEvent::GotReplyableError((&e).into()),
Receiver {
state: HasReplyableError { error_reply: (&e).into() },
state: HasReplyableError {
error_reply: (&e).into(),
outcome: SessionOutcome::Failure,
},
session_context: self.session_context,
},
e,
Expand Down Expand Up @@ -694,7 +703,10 @@ impl Receiver<MaybeInputsSeen> {
return MaybeFatalTransition::replyable_error(
SessionEvent::GotReplyableError((&e).into()),
Receiver {
state: HasReplyableError { error_reply: (&e).into() },
state: HasReplyableError {
error_reply: (&e).into(),
outcome: SessionOutcome::Failure,
},
session_context: self.session_context,
},
e,
Expand Down Expand Up @@ -761,7 +773,10 @@ impl Receiver<OutputsUnknown> {
return MaybeFatalTransition::replyable_error(
SessionEvent::GotReplyableError((&e).into()),
Receiver {
state: HasReplyableError { error_reply: (&e).into() },
state: HasReplyableError {
error_reply: (&e).into(),
outcome: SessionOutcome::Failure,
},
session_context: self.session_context,
},
e,
Expand Down Expand Up @@ -1121,6 +1136,7 @@ impl Receiver<PayjoinProposal> {
#[derive(Debug, Clone, PartialEq)]
pub struct HasReplyableError {
error_reply: JsonReply,
outcome: SessionOutcome,
}

impl Receiver<HasReplyableError> {
Expand Down Expand Up @@ -1167,11 +1183,11 @@ impl Receiver<HasReplyableError> {
) -> MaybeSuccessTransition<SessionEvent, (), ProtocolError> {
match process_post_res(res, ohttp_context) {
Ok(_) =>
MaybeSuccessTransition::success(SessionEvent::Closed(SessionOutcome::Failure), ()),
MaybeSuccessTransition::success(SessionEvent::Closed(self.outcome.clone()), ()),
Err(e) =>
if e.is_fatal() {
MaybeSuccessTransition::fatal(
SessionEvent::Closed(SessionOutcome::Failure),
SessionEvent::Closed(self.outcome.clone()),
ProtocolError::V2(InternalSessionError::DirectoryResponse(e).into()),
)
} else {
Expand All @@ -1183,6 +1199,43 @@ impl Receiver<HasReplyableError> {
}
}

/// Generic methods available for all receiver states
impl<State> Receiver<State>
where
State: sealed::State,
{
/// Explicitly fail the session.
///
/// This method allows implementations to terminate the payjoin session when
/// they encounter errors that cannot be resolved, such as insufficient
/// funds or a double-spend detection.
pub fn fail(self) -> NextStateTransition<SessionEvent, Receiver<HasReplyableError>> {
let err = JsonReply::new(crate::error_codes::ErrorCode::Unavailable, "Receiver error");
NextStateTransition::success(
SessionEvent::GotReplyableError(err.clone()),
Receiver {
state: HasReplyableError { error_reply: err, outcome: SessionOutcome::Failure },
session_context: self.session_context,
},
)
}

/// Explicitly cancel the session.
///
/// This method allows implementations to terminate the payjoin session when
/// the user decides to cancel the operation interactively.
pub fn cancel(self) -> NextStateTransition<SessionEvent, Receiver<HasReplyableError>> {
let err = JsonReply::new(crate::error_codes::ErrorCode::Unavailable, "Receiver error");
NextStateTransition::success(
SessionEvent::GotReplyableError(err.clone()),
Receiver {
state: HasReplyableError { error_reply: err, outcome: SessionOutcome::Cancel },
session_context: self.session_context,
},
)
}
}

/// Derive a mailbox endpoint on a directory given a [`ShortId`].
/// It consists of a directory URL and the session ShortID in the path.
fn mailbox_endpoint(directory: &Url, id: &ShortId) -> Url {
Expand Down Expand Up @@ -1461,7 +1514,10 @@ pub mod test {
assert_eq!(mock_err.to_json(), expected_json);

let receiver = Receiver {
state: HasReplyableError { error_reply: mock_err.clone() },
state: HasReplyableError {
error_reply: mock_err.clone(),
outcome: SessionOutcome::Failure,
},
session_context: SHARED_CONTEXT.clone(),
};

Expand All @@ -1475,7 +1531,7 @@ pub mod test {
let now = crate::time::Time::now();
let context = SessionContext { expiration: now, ..SHARED_CONTEXT.clone() };
let receiver = Receiver {
state: HasReplyableError { error_reply: mock_err() },
state: HasReplyableError { error_reply: mock_err(), outcome: SessionOutcome::Failure },
session_context: context.clone(),
};

Expand Down
65 changes: 64 additions & 1 deletion payjoin/src/core/receive/v2/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -593,7 +593,10 @@ mod tests {
expected_status: SessionStatus::Failed,
},
expected_receiver_state: ReceiveSession::HasReplyableError(Receiver {
state: HasReplyableError { error_reply: (&expected_error).into() },
state: HasReplyableError {
error_reply: (&expected_error).into(),
outcome: SessionOutcome::Failure,
},
session_context: SessionContext { reply_key, ..session_context },
}),
};
Expand Down Expand Up @@ -648,4 +651,64 @@ mod tests {

Ok(())
}

#[test]
fn test_session_fail() -> Result<(), BoxError> {
let persister = InMemoryTestPersister::<SessionEvent>::default();
let session_context = SHARED_CONTEXT.clone();

let receiver = Receiver { state: Initialized {}, session_context: session_context.clone() };
let has_error = receiver.fail().save(&persister)?;
let expected_err =
JsonReply::new(crate::error_codes::ErrorCode::Unavailable, "Receiver error");

let test = SessionHistoryTest {
events: vec![
SessionEvent::Created(session_context.clone()),
SessionEvent::GotReplyableError(expected_err.clone()),
],
expected_session_history: SessionHistoryExpectedOutcome {
fallback_tx: None,
expected_status: SessionStatus::Active,
},
expected_receiver_state: ReceiveSession::HasReplyableError(Receiver {
state: HasReplyableError {
error_reply: expected_err,
outcome: SessionOutcome::Failure,
},
session_context,
}),
};
run_session_history_test(test)
}

#[test]
fn test_session_cancel() -> Result<(), BoxError> {
let persister = InMemoryTestPersister::<SessionEvent>::default();
let session_context = SHARED_CONTEXT.clone();

let receiver = Receiver { state: Initialized {}, session_context: session_context.clone() };
let has_error = receiver.cancel().save(&persister)?;
let expected_err =
JsonReply::new(crate::error_codes::ErrorCode::Unavailable, "Receiver error");

let test = SessionHistoryTest {
events: vec![
SessionEvent::Created(session_context.clone()),
SessionEvent::GotReplyableError(expected_err.clone()),
],
expected_session_history: SessionHistoryExpectedOutcome {
fallback_tx: None,
expected_status: SessionStatus::Active,
},
expected_receiver_state: ReceiveSession::HasReplyableError(Receiver {
state: HasReplyableError {
error_reply: expected_err,
outcome: SessionOutcome::Cancel,
},
session_context,
}),
};
run_session_history_test(test)
}
}