From 54302e9e373f94bc3f2305abc8a663801d376c60 Mon Sep 17 00:00:00 2001 From: Armin Sabouri Date: Mon, 16 Jun 2025 12:45:30 -0400 Subject: [PATCH] Sender Session Events This commit adds session events generated by processing the sender state machine. To enable serialization and deserialization of session events, we implement `serde::Serialize` and `serde::Deserialize` on the underlying v1 & v2 types that are captured in session events. To enable comparisons between `SessionEvents` during tests, all event types must also implement `PartialEq` and `Eq`. Tests cover roundtrip ser/deserialization of each session event. All of our uniffi exports are under the same namespace (see payjoin_ffi.udl). This commit adds a sender/reciever name prefix to the exported session event object avoid a naming conflict. [Related ticket](https://github.com/payjoin/rust-payjoin/issues/723) --- payjoin-ffi/src/receive/uni.rs | 2 +- payjoin-ffi/src/send/mod.rs | 11 ++++++ payjoin-ffi/src/send/uni.rs | 24 ++++++++++++ payjoin/src/send/mod.rs | 1 + payjoin/src/send/v2/mod.rs | 8 ++-- payjoin/src/send/v2/persist.rs | 67 ++++++++++++++++++++++++++++++++++ 6 files changed, 108 insertions(+), 5 deletions(-) diff --git a/payjoin-ffi/src/receive/uni.rs b/payjoin-ffi/src/receive/uni.rs index e05659b18..a7f81cfb4 100644 --- a/payjoin-ffi/src/receive/uni.rs +++ b/payjoin-ffi/src/receive/uni.rs @@ -11,7 +11,7 @@ use crate::uri::error::IntoUrlError; use crate::{ClientResponse, OhttpKeys, OutputSubstitution, Request}; #[derive(Clone, uniffi::Object, serde::Serialize, serde::Deserialize)] -pub struct SessionEvent(super::SessionEvent); +pub struct ReceiverSessionEvent(super::SessionEvent); #[derive(Debug, uniffi::Object)] pub struct NewReceiver(pub super::NewReceiver); diff --git a/payjoin-ffi/src/send/mod.rs b/payjoin-ffi/src/send/mod.rs index 355c4233a..34bfb5609 100644 --- a/payjoin-ffi/src/send/mod.rs +++ b/payjoin-ffi/src/send/mod.rs @@ -14,6 +14,17 @@ pub mod error; #[cfg(feature = "uniffi")] pub mod uni; +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct SessionEvent(payjoin::send::v2::SessionEvent); + +impl From for payjoin::send::v2::SessionEvent { + fn from(value: SessionEvent) -> Self { value.0 } +} + +impl From for SessionEvent { + fn from(value: payjoin::send::v2::SessionEvent) -> Self { SessionEvent(value) } +} + ///Builder for sender-side payjoin parameters /// ///These parameters define how client wants to handle Payjoin. diff --git a/payjoin-ffi/src/send/uni.rs b/payjoin-ffi/src/send/uni.rs index 0c36183fa..8c990555c 100644 --- a/payjoin-ffi/src/send/uni.rs +++ b/payjoin-ffi/src/send/uni.rs @@ -6,6 +6,30 @@ pub use crate::send::{ }; use crate::{ClientResponse, ImplementationError, PjUri, Request}; +#[derive(uniffi::Object, Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct SenderSessionEvent(super::SessionEvent); + +impl From for super::SessionEvent { + fn from(value: SenderSessionEvent) -> Self { value.0 } +} + +impl From for SenderSessionEvent { + fn from(value: super::SessionEvent) -> Self { SenderSessionEvent(value) } +} + +#[uniffi::export] +impl SenderSessionEvent { + pub fn to_json(&self) -> Result { + serde_json::to_string(&self.0).map_err(Into::into) + } + + #[uniffi::constructor] + pub fn from_json(json: String) -> Result { + let event: payjoin::send::v2::SessionEvent = serde_json::from_str(&json)?; + Ok(SenderSessionEvent(event.into())) + } +} + #[derive(uniffi::Object)] pub struct SenderBuilder(super::SenderBuilder); diff --git a/payjoin/src/send/mod.rs b/payjoin/src/send/mod.rs index 6ae51e921..ff0ea769b 100644 --- a/payjoin/src/send/mod.rs +++ b/payjoin/src/send/mod.rs @@ -52,6 +52,7 @@ pub(crate) struct AdditionalFeeContribution { /// Data required to validate the response against the original PSBT. #[derive(Debug, Clone)] +#[cfg_attr(feature = "v2", derive(serde::Serialize, serde::Deserialize, PartialEq, Eq))] pub struct PsbtContext { original_psbt: Psbt, output_substitution: OutputSubstitution, diff --git a/payjoin/src/send/v2/mod.rs b/payjoin/src/send/v2/mod.rs index 500800180..dd79cb206 100644 --- a/payjoin/src/send/v2/mod.rs +++ b/payjoin/src/send/v2/mod.rs @@ -25,7 +25,7 @@ use bitcoin::hashes::{sha256, Hash}; pub use error::{CreateRequestError, EncapsulationError}; use error::{InternalCreateRequestError, InternalEncapsulationError}; use ohttp::ClientResponse; -pub use persist::SenderToken; +pub use persist::{SenderToken, SessionEvent}; use serde::{Deserialize, Serialize}; use url::Url; @@ -163,7 +163,7 @@ impl NewSender { /// A payjoin V2 sender, allowing the construction of a payjoin V2 request /// and the resulting [`V2PostContext`]. -#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct WithReplyKey { /// The v1 Sender. pub(crate) v1: v1::Sender, @@ -341,7 +341,7 @@ impl Sender { /// /// This type is used to make a BIP77 GET request and process the response. /// Call [`Sender::process_response`] on it to continue the BIP77 flow. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct V2GetContext { /// The endpoint in the Payjoin URI pub(crate) endpoint: Url, @@ -415,7 +415,7 @@ impl Sender { } #[cfg(feature = "v2")] -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub(crate) struct HpkeContext { pub(crate) receiver: HpkePublicKey, pub(crate) reply_pair: HpkeKeyPair, diff --git a/payjoin/src/send/v2/persist.rs b/payjoin/src/send/v2/persist.rs index 16df89355..25d890a48 100644 --- a/payjoin/src/send/v2/persist.rs +++ b/payjoin/src/send/v2/persist.rs @@ -4,6 +4,7 @@ use url::Url; use super::{Sender, WithReplyKey}; use crate::persist::Value; +use crate::send::v2::V2GetContext; /// Opaque key type for the sender #[derive(Debug, Clone, PartialEq, Eq)] @@ -26,3 +27,69 @@ impl Value for Sender { fn key(&self) -> Self::Key { SenderToken(self.endpoint().clone()) } } + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub enum SessionEvent { + /// Sender was created with a HPKE key pair + CreatedReplyKey(WithReplyKey), + /// Sender POST'd the original PSBT, and waiting to receive a Proposal PSBT using GET context + V2GetContext(V2GetContext), + /// Sender received a Proposal PSBT + ProposalReceived(bitcoin::Psbt), + /// Invalid session + SessionInvalid(String), +} + +#[cfg(test)] +mod tests { + use bitcoin::{FeeRate, ScriptBuf}; + use payjoin_test_utils::PARSED_ORIGINAL_PSBT; + + use super::*; + use crate::send::v2::HpkeContext; + use crate::send::{v1, PsbtContext}; + use crate::{HpkeKeyPair, OutputSubstitution}; + + #[test] + fn test_sender_session_event_serialization_roundtrip() { + let endpoint = Url::parse("http://localhost:1234").expect("Valid URL"); + let keypair = HpkeKeyPair::gen_keypair(); + let sender_with_reply_key = WithReplyKey { + v1: v1::Sender { + psbt: PARSED_ORIGINAL_PSBT.clone(), + endpoint: endpoint.clone(), + output_substitution: OutputSubstitution::Enabled, + fee_contribution: None, + min_fee_rate: FeeRate::ZERO, + payee: ScriptBuf::from(vec![0x00]), + }, + reply_key: keypair.0.clone(), + }; + + let v2_get_context = V2GetContext { + endpoint, + psbt_ctx: PsbtContext { + original_psbt: PARSED_ORIGINAL_PSBT.clone(), + output_substitution: OutputSubstitution::Enabled, + fee_contribution: None, + min_fee_rate: FeeRate::ZERO, + payee: ScriptBuf::from(vec![0x00]), + }, + hpke_ctx: HpkeContext { receiver: keypair.clone().1, reply_pair: keypair }, + }; + + let test_cases = vec![ + SessionEvent::CreatedReplyKey(sender_with_reply_key.clone()), + SessionEvent::V2GetContext(v2_get_context.clone()), + SessionEvent::ProposalReceived(PARSED_ORIGINAL_PSBT.clone()), + SessionEvent::SessionInvalid("error message".to_string()), + ]; + + for event in test_cases { + let serialized = serde_json::to_string(&event).expect("Should serialize"); + let deserialized: SessionEvent = + serde_json::from_str(&serialized).expect("Should deserialize"); + assert_eq!(event, deserialized); + } + } +}