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/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")), + } + } +} 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..cf9ab62af 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,73 @@ 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), +} + +#[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); + } + } +}