From ab6c8dccfee940cc685789d47b5c9ffdcebb0a16 Mon Sep 17 00:00:00 2001 From: Armin Sabouri Date: Mon, 2 Jun 2025 14:59:29 -0400 Subject: [PATCH 1/3] Implement `serde::Deserialize` for `core::version` This commit adds deserialization support for the version enum so it can be stored in the session event log. --- payjoin/src/core/version.rs | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/payjoin/src/core/version.rs b/payjoin/src/core/version.rs index 336044209..234647617 100644 --- a/payjoin/src/core/version.rs +++ b/payjoin/src/core/version.rs @@ -1,6 +1,6 @@ use core::fmt; -use serde::{Serialize, Serializer}; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; /// The Payjoin version /// @@ -16,8 +16,7 @@ use serde::{Serialize, Serializer}; /// ``` /// /// # Note -/// - Only [`Serialize`] is implemented for json array serialization in the `unsupported-version` error message, -/// not [`serde::Deserialize`], as deserialization is not required for this type. +/// - Both [`Serialize`] and [`Deserialize`] are implemented for json array serialization in the `unsupported-version` error message, /// - [`fmt::Display`] and [`fmt::Debug`] output the `u8` representation for compatibility with BIP 78/77 /// and to match the expected wire format. #[repr(u8)] @@ -45,3 +44,17 @@ impl Serialize for Version { (*self as u8).serialize(serializer) } } + +impl<'de> Deserialize<'de> for Version { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let v = u8::deserialize(deserializer)?; + match v { + 1 => Ok(Version::One), + 2 => Ok(Version::Two), + _ => Err(serde::de::Error::custom("Invalid version")), + } + } +} From fdd0504b37c9ab4241dab04f62defa7bec94a935 Mon Sep 17 00:00:00 2001 From: Armin Sabouri Date: Mon, 2 Jun 2025 15:22:12 -0400 Subject: [PATCH 2/3] Introduce Receiver `SessionEvent` This commit adds session events generated by processing the receiver state machine. These events wrap v1 receiver types to avoid duplicating session context learned during the initial typestate transition. To enable serialization and deserialization of session events, we implement serde::Serialize and serde::Deserialize on the underlying v1 types. As a result, we must enable the bitcoin/serde feature under the v1 flag because `OptionalParameters` includes a bitcoin::FeeRate field. To enable comparisons between SessionEvents during tests, all event types must also implement `PartialEq` and `Eq`. --- payjoin-ffi/src/receive/mod.rs | 3 +++ payjoin-ffi/src/receive/uni.rs | 3 +++ payjoin/Cargo.toml | 4 ++-- payjoin/src/output_substitution.rs | 3 +-- payjoin/src/receive/optional_parameters.rs | 2 +- payjoin/src/receive/v1/mod.rs | 17 ++++++++------- payjoin/src/receive/v2/mod.rs | 4 ++-- payjoin/src/receive/v2/persist.rs | 25 +++++++++++++++++++++- 8 files changed, 45 insertions(+), 16 deletions(-) diff --git a/payjoin-ffi/src/receive/mod.rs b/payjoin-ffi/src/receive/mod.rs index 7b8376966..5ba7ae0a1 100644 --- a/payjoin-ffi/src/receive/mod.rs +++ b/payjoin-ffi/src/receive/mod.rs @@ -20,6 +20,9 @@ pub mod error; #[cfg(feature = "uniffi")] pub mod uni; +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct SessionEvent(payjoin::receive::v2::SessionEvent); + #[derive(Debug)] pub struct NewReceiver(payjoin::receive::v2::NewReceiver); diff --git a/payjoin-ffi/src/receive/uni.rs b/payjoin-ffi/src/receive/uni.rs index 7caee31a7..e05659b18 100644 --- a/payjoin-ffi/src/receive/uni.rs +++ b/payjoin-ffi/src/receive/uni.rs @@ -10,6 +10,9 @@ pub use crate::receive::{ use crate::uri::error::IntoUrlError; use crate::{ClientResponse, OhttpKeys, OutputSubstitution, Request}; +#[derive(Clone, uniffi::Object, serde::Serialize, serde::Deserialize)] +pub struct SessionEvent(super::SessionEvent); + #[derive(Debug, uniffi::Object)] pub struct NewReceiver(pub super::NewReceiver); diff --git a/payjoin/Cargo.toml b/payjoin/Cargo.toml index 3486650c5..c572a7c55 100644 --- a/payjoin/Cargo.toml +++ b/payjoin/Cargo.toml @@ -18,10 +18,10 @@ exclude = ["tests"] [features] default = ["v2"] #[doc = "Core features for payjoin state machines"] -_core = ["bitcoin/rand-std", "serde_json", "url", "bitcoin_uri", "serde"] +_core = ["bitcoin/rand-std", "serde_json", "url", "bitcoin_uri", "serde", "bitcoin/serde"] directory = [] v1 = ["_core"] -v2 = ["_core", "bitcoin/serde", "hpke", "dep:http", "bhttp", "ohttp", "url/serde", "directory"] +v2 = ["_core", "hpke", "dep:http", "bhttp", "ohttp", "url/serde", "directory"] #[doc = "Functions to fetch OHTTP keys via CONNECT proxy using reqwest. Enables `v2` since only `v2` uses OHTTP."] io = ["v2", "reqwest/rustls-tls"] _danger-local-https = ["reqwest/rustls-tls", "rustls"] diff --git a/payjoin/src/output_substitution.rs b/payjoin/src/output_substitution.rs index e6ec9090e..0f0d5b5ab 100644 --- a/payjoin/src/output_substitution.rs +++ b/payjoin/src/output_substitution.rs @@ -1,6 +1,5 @@ /// Whether the receiver is allowed to substitute original outputs or not. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -#[cfg_attr(feature = "v2", derive(serde::Serialize, serde::Deserialize))] +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub enum OutputSubstitution { Enabled, Disabled, diff --git a/payjoin/src/receive/optional_parameters.rs b/payjoin/src/receive/optional_parameters.rs index 4cb076303..6a39a7e52 100644 --- a/payjoin/src/receive/optional_parameters.rs +++ b/payjoin/src/receive/optional_parameters.rs @@ -7,7 +7,7 @@ use log::warn; use crate::output_substitution::OutputSubstitution; use crate::Version; -#[derive(Debug, Clone)] +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)] pub(crate) struct Params { // version pub v: Version, diff --git a/payjoin/src/receive/v1/mod.rs b/payjoin/src/receive/v1/mod.rs index 246d94f9c..191bd31ae 100644 --- a/payjoin/src/receive/v1/mod.rs +++ b/payjoin/src/receive/v1/mod.rs @@ -31,6 +31,7 @@ use bitcoin::psbt::Psbt; use bitcoin::secp256k1::rand::seq::SliceRandom; use bitcoin::secp256k1::rand::{self, Rng}; use bitcoin::{Amount, FeeRate, OutPoint, Script, TxIn, TxOut, Weight}; +use serde::{Deserialize, Serialize}; use super::error::{ InputContributionError, InternalInputContributionError, InternalOutputSubstitutionError, @@ -57,7 +58,7 @@ pub use exclusive::*; /// transaction with extract_tx_to_schedule_broadcast() and schedule, followed by checking /// that the transaction can be broadcast with check_broadcast_suitability. Otherwise it is safe to /// call assume_interactive_receive to proceed with validation. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct UncheckedProposal { pub(crate) psbt: Psbt, pub(crate) params: Params, @@ -128,7 +129,7 @@ impl UncheckedProposal { /// Typestate to validate that the Original PSBT has no receiver-owned inputs. /// /// Call [`Self::check_inputs_not_owned`] to proceed. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct MaybeInputsOwned { psbt: Psbt, params: Params, @@ -171,7 +172,7 @@ impl MaybeInputsOwned { /// Typestate to validate that the Original PSBT has no inputs that have been seen before. /// /// Call [`Self::check_no_inputs_seen_before`] to proceed. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct MaybeInputsSeen { psbt: Psbt, params: Params, @@ -203,7 +204,7 @@ impl MaybeInputsSeen { /// /// Only accept PSBTs that send us money. /// Identify those outputs with [`Self::identify_receiver_outputs`] to proceed. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct OutputsUnknown { psbt: Psbt, params: Params, @@ -255,7 +256,7 @@ impl OutputsUnknown { /// A checked proposal that the receiver may substitute or add outputs to /// /// Call [`Self::commit_outputs`] to proceed. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct WantsOutputs { original_psbt: Psbt, payjoin_psbt: Psbt, @@ -388,7 +389,7 @@ fn interleave_shuffle(original: &mut Vec, new: &mut [ /// A checked proposal that the receiver may contribute inputs to to make a payjoin /// /// Call [`Self::commit_inputs`] to proceed. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct WantsInputs { original_psbt: Psbt, payjoin_psbt: Psbt, @@ -551,7 +552,7 @@ impl WantsInputs { /// sender will accept. /// /// Call [`Self::finalize_proposal`] to return a finalized [`PayjoinProposal`]. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct ProvisionalProposal { original_psbt: Psbt, payjoin_psbt: Psbt, @@ -768,7 +769,7 @@ impl ProvisionalProposal { /// A finalized payjoin proposal, complete with fees and receiver signatures, that the sender /// should find acceptable. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct PayjoinProposal { payjoin_psbt: Psbt, } diff --git a/payjoin/src/receive/v2/mod.rs b/payjoin/src/receive/v2/mod.rs index 42f66183e..c74a4f6ef 100644 --- a/payjoin/src/receive/v2/mod.rs +++ b/payjoin/src/receive/v2/mod.rs @@ -7,7 +7,7 @@ use bitcoin::psbt::Psbt; use bitcoin::{Address, FeeRate, OutPoint, Script, TxOut}; pub(crate) use error::InternalSessionError; pub use error::SessionError; -pub use persist::ReceiverToken; +pub use persist::{ReceiverToken, SessionEvent}; use serde::de::Deserializer; use serde::{Deserialize, Serialize}; use url::Url; @@ -32,7 +32,7 @@ const SUPPORTED_VERSIONS: &[Version] = &[Version::One, Version::Two]; static TWENTY_FOUR_HOURS_DEFAULT_EXPIRY: Duration = Duration::from_secs(60 * 60 * 24); #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub(crate) struct SessionContext { +pub struct SessionContext { #[serde(deserialize_with = "deserialize_address_assume_checked")] address: Address, directory: url::Url, diff --git a/payjoin/src/receive/v2/persist.rs b/payjoin/src/receive/v2/persist.rs index 54a84ab90..3b4909b35 100644 --- a/payjoin/src/receive/v2/persist.rs +++ b/payjoin/src/receive/v2/persist.rs @@ -1,7 +1,10 @@ use std::fmt::{self, Display}; -use super::{Receiver, WithContext}; +use serde::{Deserialize, Serialize}; + +use super::{Receiver, SessionContext, WithContext}; use crate::persist::{self}; +use crate::receive::v1; use crate::uri::ShortId; /// Opaque key type for the receiver @@ -25,3 +28,23 @@ impl persist::Value for Receiver { fn key(&self) -> Self::Key { ReceiverToken(self.context.id()) } } + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +/// Represents a piece of information that the receiver has obtained from the session +/// Each event can be used to transition the receiver state machine to a new state +pub enum SessionEvent { + Created(SessionContext), + UncheckedProposal(v1::UncheckedProposal), + MaybeInputsOwned(v1::MaybeInputsOwned), + MaybeInputsSeen(v1::MaybeInputsSeen), + OutputsUnknown(v1::OutputsUnknown), + WantsOutputs(v1::WantsOutputs), + WantsInputs(v1::WantsInputs), + ProvisionalProposal(v1::ProvisionalProposal), + PayjoinProposal(v1::PayjoinProposal), + /// Session is invalid. This is a irrecoverable error. Fallback tx should be broadcasted. + /// TODO this should be any error type that is impl std::error and works well with serde, or as a fallback can be formatted as a string + /// Reason being in some cases we still want to preserve the error b/c we can action on it. For now this is a terminal state and there is nothing to replay and is saved to be displayed. + /// b/c its a terminal state and there is nothing to replay. So serialization will be lossy and that is fine. + SessionInvalid(String), +} From c7795cd5e8840f51478d8131274b6c8d147a7d5b Mon Sep 17 00:00:00 2001 From: Armin Sabouri Date: Mon, 2 Jun 2025 15:45:20 -0400 Subject: [PATCH 3/3] Add ser/deser tests for `ReceiverSessionEvent` This commit adds test coverage for the full serialization/deserialization round trip for each receiver session event. --- payjoin/src/receive/v2/persist.rs | 50 +++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/payjoin/src/receive/v2/persist.rs b/payjoin/src/receive/v2/persist.rs index 3b4909b35..cf9ab62af 100644 --- a/payjoin/src/receive/v2/persist.rs +++ b/payjoin/src/receive/v2/persist.rs @@ -48,3 +48,53 @@ pub enum SessionEvent { /// b/c its a terminal state and there is nothing to replay. So serialization will be lossy and that is fine. SessionInvalid(String), } + +#[cfg(test)] +mod tests { + use super::*; + use crate::receive::v1::test::unchecked_proposal_from_test_vector; + use crate::receive::v2::test::SHARED_CONTEXT; + + #[test] + fn test_session_event_serialization_roundtrip() { + let unchecked_proposal = unchecked_proposal_from_test_vector(); + let maybe_inputs_owned = unchecked_proposal.clone().assume_interactive_receiver(); + let maybe_inputs_seen = maybe_inputs_owned + .clone() + .check_inputs_not_owned(|_| Ok(false)) + .expect("No inputs should be owned"); + let outputs_unknown = maybe_inputs_seen + .clone() + .check_no_inputs_seen_before(|_| Ok(false)) + .expect("No inputs should be seen before"); + let wants_outputs = outputs_unknown + .clone() + .identify_receiver_outputs(|_| Ok(true)) + .expect("Outputs should be identified"); + let wants_inputs = wants_outputs.clone().commit_outputs(); + let provisional_proposal = wants_inputs.clone().commit_inputs(); + let payjoin_proposal = provisional_proposal + .clone() + .finalize_proposal(|psbt| Ok(psbt.clone()), None, None) + .expect("Payjoin proposal should be finalized"); + + let test_cases = vec![ + SessionEvent::Created(SHARED_CONTEXT.clone()), + SessionEvent::UncheckedProposal(unchecked_proposal), + SessionEvent::MaybeInputsOwned(maybe_inputs_owned), + SessionEvent::MaybeInputsSeen(maybe_inputs_seen), + SessionEvent::OutputsUnknown(outputs_unknown), + SessionEvent::WantsOutputs(wants_outputs), + SessionEvent::WantsInputs(wants_inputs), + SessionEvent::ProvisionalProposal(provisional_proposal), + SessionEvent::PayjoinProposal(payjoin_proposal), + ]; + + for event in test_cases { + let serialized = serde_json::to_string(&event).expect("Serialization should not fail"); + let deserialized: SessionEvent = + serde_json::from_str(&serialized).expect("Deserialization should not fail"); + assert_eq!(event, deserialized); + } + } +}