From 4cd70f177f4bfeadfa5dfbf69e4cdd7ff3edd290 Mon Sep 17 00:00:00 2001 From: Armin Sabouri Date: Tue, 25 Feb 2025 09:08:23 -0500 Subject: [PATCH 1/3] Introduce persistence traits Add persistence abstractions with `Value` and `Persister` traits to define a common interface for storing and retrieving Payjoin related datastructures. The `Value` trait enables types to generate their own storage keys, while the Persister trait provides a generic interface for saving and loading values. The no-op implementation stores values in memory without actual persistence. Issue: https://github.com/payjoin/rust-payjoin/issues/336 --- payjoin/src/lib.rs | 3 ++ payjoin/src/persist/mod.rs | 61 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+) create mode 100644 payjoin/src/persist/mod.rs diff --git a/payjoin/src/lib.rs b/payjoin/src/lib.rs index 5662a017a..0bcb53e35 100644 --- a/payjoin/src/lib.rs +++ b/payjoin/src/lib.rs @@ -28,6 +28,9 @@ pub mod receive; #[cfg(feature = "_core")] pub mod send; +#[cfg(feature = "v2")] +pub mod persist; + #[cfg(feature = "v2")] pub(crate) mod hpke; #[cfg(feature = "v2")] diff --git a/payjoin/src/persist/mod.rs b/payjoin/src/persist/mod.rs new file mode 100644 index 000000000..9bbb1f83e --- /dev/null +++ b/payjoin/src/persist/mod.rs @@ -0,0 +1,61 @@ +use std::fmt::Display; + +/// Types that can generate their own keys for persistent storage +pub trait Value: serde::Serialize + serde::de::DeserializeOwned + Sized + Clone { + type Key: AsRef<[u8]> + Clone + Display; + + /// Unique identifier for this persisted value + fn key(&self) -> Self::Key; +} + +/// Implemented types that should be persisted by the application. +pub trait Persister { + type Token: From; + type Error: std::error::Error + Send + Sync + 'static; + + fn save(&mut self, value: V) -> Result; + fn load(&self, token: Self::Token) -> Result; +} + +/// A key type that stores the value itself for no-op persistence +#[derive(Debug, Clone, serde::Serialize)] +pub struct NoopToken(V); + +impl AsRef<[u8]> for NoopToken { + fn as_ref(&self) -> &[u8] { + // Since this is a no-op implementation, we can return an empty slice + // as we never actually need to use the bytes + &[] + } +} + +impl<'de, V: Value> serde::Deserialize<'de> for NoopToken { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + Ok(NoopToken(V::deserialize(deserializer)?)) + } +} + +impl Value for NoopToken { + type Key = V::Key; + + fn key(&self) -> Self::Key { self.0.key() } +} + +/// A persister that does nothing but store values in memory +#[derive(Debug, Clone)] +pub struct NoopPersister; + +impl From for NoopToken { + fn from(value: V) -> Self { NoopToken(value) } +} +impl Persister for NoopPersister { + type Token = NoopToken; + type Error = std::convert::Infallible; + + fn save(&mut self, value: V) -> Result { Ok(NoopToken(value)) } + + fn load(&self, token: Self::Token) -> Result { Ok(token.0) } +} From 261ddccd873056c977b0070bce48ab70ef235361 Mon Sep 17 00:00:00 2001 From: Armin Sabouri Date: Tue, 25 Feb 2025 09:16:25 -0500 Subject: [PATCH 2/3] Persist receiver when initialized This commit introduces a persistence flow for constructing `v2::Receiver`. We enforce that receivers must be persisted before initiating their typestate. To accommodate this, callers will first create a `NewReceiver`, which must be persisted. Upon persistence, they receive a storage token that can be passed to `v2::Receiver::load(...)` to construct a `v2::Receiver`. For cases where persistence is unnecessary (e.g. in testing), implementers can use the `NoopPersister` available in `persist/mod.rs`. Issue: https://github.com/payjoin/rust-payjoin/issues/336 --- payjoin-cli/src/app/v2.rs | 12 ++++++-- payjoin-cli/src/db/error.rs | 4 +++ payjoin-cli/src/db/mod.rs | 2 +- payjoin-cli/src/db/v2.rs | 35 ++++++++++++++++------ payjoin/src/receive/v2/mod.rs | 56 +++++++++++++++++++++++++++++++---- payjoin/tests/integration.rs | 36 ++++++++++++++++------ 6 files changed, 118 insertions(+), 27 deletions(-) diff --git a/payjoin-cli/src/app/v2.rs b/payjoin-cli/src/app/v2.rs index e716a41c2..fd050d2fe 100644 --- a/payjoin-cli/src/app/v2.rs +++ b/payjoin-cli/src/app/v2.rs @@ -4,7 +4,7 @@ use anyhow::{anyhow, Context, Result}; use payjoin::bitcoin::consensus::encode::serialize_hex; use payjoin::bitcoin::psbt::Psbt; use payjoin::bitcoin::{Amount, FeeRate}; -use payjoin::receive::v2::{Receiver, UncheckedProposal}; +use payjoin::receive::v2::{NewReceiver, Receiver, UncheckedProposal}; use payjoin::receive::{Error, ImplementationError, ReplyableError}; use payjoin::send::v2::{Sender, SenderBuilder}; use payjoin::Uri; @@ -14,6 +14,7 @@ use super::config::Config; use super::wallet::BitcoindWallet; use super::App as AppTrait; use crate::app::{handle_interrupt, http_agent}; +use crate::db::v2::ReceiverPersister; use crate::db::Database; #[derive(Clone)] @@ -65,13 +66,18 @@ impl AppTrait for App { async fn receive_payjoin(&self, amount: Amount) -> Result<()> { let address = self.wallet().get_new_address()?; let ohttp_keys = unwrap_ohttp_keys_or_else_fetch(&self.config).await?; - let session = Receiver::new( + let mut persister = ReceiverPersister::new(self.db.clone()); + let new_receiver = NewReceiver::new( address, self.config.v2()?.pj_directory.clone(), ohttp_keys.clone(), None, )?; - self.db.insert_recv_session(session.clone())?; + let storage_token = new_receiver + .persist(&mut persister) + .map_err(|e| anyhow!("Failed to persist receiver: {}", e))?; + let session = Receiver::load(storage_token, &persister) + .map_err(|e| anyhow!("Failed to load receiver: {}", e))?; self.spawn_payjoin_receiver(session, Some(amount)).await } diff --git a/payjoin-cli/src/db/error.rs b/payjoin-cli/src/db/error.rs index 0e2281b66..ac76f7462 100644 --- a/payjoin-cli/src/db/error.rs +++ b/payjoin-cli/src/db/error.rs @@ -13,6 +13,8 @@ pub(crate) enum Error { Serialize(serde_json::Error), #[cfg(feature = "v2")] Deserialize(serde_json::Error), + #[cfg(feature = "v2")] + NotFound(String), } impl fmt::Display for Error { @@ -23,6 +25,8 @@ impl fmt::Display for Error { Error::Serialize(e) => write!(f, "Serialization failed: {}", e), #[cfg(feature = "v2")] Error::Deserialize(e) => write!(f, "Deserialization failed: {}", e), + #[cfg(feature = "v2")] + Error::NotFound(key) => write!(f, "Key not found: {}", key), } } } diff --git a/payjoin-cli/src/db/mod.rs b/payjoin-cli/src/db/mod.rs index 42f74e3d4..c65f67e44 100644 --- a/payjoin-cli/src/db/mod.rs +++ b/payjoin-cli/src/db/mod.rs @@ -27,4 +27,4 @@ impl Database { } #[cfg(feature = "v2")] -mod v2; +pub(crate) mod v2; diff --git a/payjoin-cli/src/db/v2.rs b/payjoin-cli/src/db/v2.rs index 136c8894e..a140d58bb 100644 --- a/payjoin-cli/src/db/v2.rs +++ b/payjoin-cli/src/db/v2.rs @@ -1,21 +1,38 @@ +use std::sync::Arc; + use bitcoincore_rpc::jsonrpc::serde_json; -use payjoin::receive::v2::Receiver; +use payjoin::persist::{Persister, Value}; +use payjoin::receive::v2::{Receiver, ReceiverToken}; use payjoin::send::v2::Sender; -use sled::{IVec, Tree}; +use sled::Tree; use url::Url; use super::*; -impl Database { - pub(crate) fn insert_recv_session(&self, session: Receiver) -> Result<()> { - let recv_tree = self.0.open_tree("recv_sessions")?; - let key = &session.id(); - let value = serde_json::to_string(&session).map_err(Error::Serialize)?; - recv_tree.insert(key.as_slice(), IVec::from(value.as_str()))?; +pub(crate) struct ReceiverPersister(Arc); +impl ReceiverPersister { + pub fn new(db: Arc) -> Self { Self(db) } +} + +impl Persister for ReceiverPersister { + type Token = ReceiverToken; + type Error = crate::db::error::Error; + fn save(&mut self, value: Receiver) -> std::result::Result { + let recv_tree = self.0 .0.open_tree("recv_sessions")?; + let key = value.key(); + let value = serde_json::to_vec(&value).map_err(Error::Serialize)?; + recv_tree.insert(key.clone(), value.as_slice())?; recv_tree.flush()?; - Ok(()) + Ok(key) + } + fn load(&self, key: ReceiverToken) -> std::result::Result { + let recv_tree = self.0 .0.open_tree("recv_sessions")?; + let value = recv_tree.get(key.as_ref())?.ok_or(Error::NotFound(key.to_string()))?; + serde_json::from_slice(&value).map_err(Error::Deserialize) } +} +impl Database { pub(crate) fn get_recv_sessions(&self) -> Result> { let recv_tree = self.0.open_tree("recv_sessions")?; let mut sessions = Vec::new(); diff --git a/payjoin/src/receive/v2/mod.rs b/payjoin/src/receive/v2/mod.rs index 6544e6004..6b2409534 100644 --- a/payjoin/src/receive/v2/mod.rs +++ b/payjoin/src/receive/v2/mod.rs @@ -1,4 +1,5 @@ //! Receive BIP 77 Payjoin v2 +use std::fmt::{self, Display}; use std::str::FromStr; use std::time::{Duration, SystemTime}; @@ -19,6 +20,7 @@ use super::{ use crate::hpke::{decrypt_message_a, encrypt_message_b, HpkeKeyPair, HpkePublicKey}; use crate::ohttp::{ohttp_decapsulate, ohttp_encapsulate, OhttpEncapsulationError, OhttpKeys}; use crate::output_substitution::OutputSubstitution; +use crate::persist::{self, Persister}; use crate::receive::{parse_payload, InputPair}; use crate::uri::ShortId; use crate::{IntoUrl, IntoUrlError, Request}; @@ -71,12 +73,12 @@ fn subdir_path_from_pubkey(pubkey: &HpkePublicKey) -> ShortId { /// A payjoin V2 receiver, allowing for polled requests to the /// payjoin directory and response processing. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct Receiver { +#[derive(Debug)] +pub struct NewReceiver { context: SessionContext, } -impl Receiver { +impl NewReceiver { /// Creates a new `Receiver` with the provided parameters. /// /// # Parameters @@ -96,7 +98,7 @@ impl Receiver { ohttp_keys: OhttpKeys, expire_after: Option, ) -> Result { - Ok(Self { + let receiver = Self { context: SessionContext { address, directory: directory.into_url()?, @@ -107,9 +109,53 @@ impl Receiver { s: HpkeKeyPair::gen_keypair(), e: None, }, - }) + }; + Ok(receiver) + } + + pub fn persist>( + &self, + persister: &mut P, + ) -> Result { + let receiver = Receiver { context: self.context.clone() }; + Ok(persister.save(receiver)?) } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Receiver { + context: SessionContext, +} +/// Opaque key type for the receiver +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ReceiverToken(ShortId); + +impl Display for ReceiverToken { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", self.0) } +} + +impl From for ReceiverToken { + fn from(receiver: Receiver) -> Self { ReceiverToken(id(&receiver.context.s)) } +} + +impl AsRef<[u8]> for ReceiverToken { + fn as_ref(&self) -> &[u8] { self.0.as_bytes() } +} + +impl persist::Value for Receiver { + type Key = ReceiverToken; + + fn key(&self) -> Self::Key { ReceiverToken(id(&self.context.s)) } +} + +impl Receiver { + pub fn load>( + token: P::Token, + persister: &P, + ) -> Result { + persister.load(token).map_err(ImplementationError::from) + } /// Extract an OHTTP Encapsulated HTTP GET request for the Original PSBT pub fn extract_req( &mut self, diff --git a/payjoin/tests/integration.rs b/payjoin/tests/integration.rs index fc3456d68..c8bde3568 100644 --- a/payjoin/tests/integration.rs +++ b/payjoin/tests/integration.rs @@ -171,7 +171,8 @@ mod integration { use bitcoin::Address; use http::StatusCode; - use payjoin::receive::v2::{PayjoinProposal, Receiver, UncheckedProposal}; + use payjoin::persist::NoopPersister; + use payjoin::receive::v2::{NewReceiver, PayjoinProposal, Receiver, UncheckedProposal}; use payjoin::send::v2::SenderBuilder; use payjoin::{OhttpKeys, PjUri, UriExt}; use payjoin_test_utils::{BoxSendSyncError, TestServices}; @@ -205,8 +206,9 @@ mod integration { let ohttp_relay = services.ohttp_relay_url(); let mock_address = Address::from_str("tb1q6d3a2w975yny0asuvd9a67ner4nks58ff0q8g4")? .assume_checked(); - let mut bad_initializer = - Receiver::new(mock_address, directory, bad_ohttp_keys, None)?; + let new_receiver = NewReceiver::new(mock_address, directory, bad_ohttp_keys, None)?; + let storage_token = new_receiver.persist(&mut NoopPersister)?; + let mut bad_initializer = Receiver::load(storage_token, &NoopPersister)?; let (req, _ctx) = bad_initializer.extract_req(&ohttp_relay)?; agent .post(req.url) @@ -242,12 +244,16 @@ mod integration { // Inside the Receiver: let address = receiver.get_new_address(None, None)?.assume_checked(); // test session with expiry in the past - let mut expired_receiver = Receiver::new( + let new_receiver = NewReceiver::new( address.clone(), directory.clone(), ohttp_keys.clone(), Some(Duration::from_secs(0)), )?; + let storage_token = + new_receiver.persist(&mut NoopPersister).map_err(|e| e.to_string())?; + let mut expired_receiver = + Receiver::load(storage_token, &NoopPersister).map_err(|e| e.to_string())?; match expired_receiver.extract_req(&ohttp_relay) { // Internal error types are private, so check against a string Err(err) => assert!(err.to_string().contains("expired")), @@ -294,8 +300,12 @@ mod integration { let address = receiver.get_new_address(None, None)?.assume_checked(); // test session with expiry in the future + let new_receiver = + NewReceiver::new(address.clone(), directory.clone(), ohttp_keys.clone(), None)?; + let storage_token = + new_receiver.persist(&mut NoopPersister).map_err(|e| e.to_string())?; let mut session = - Receiver::new(address.clone(), directory.clone(), ohttp_keys.clone(), None)?; + Receiver::load(storage_token, &NoopPersister).map_err(|e| e.to_string())?; println!("session: {:#?}", &session); // Poll receive request let ohttp_relay = services.ohttp_relay_url(); @@ -465,9 +475,12 @@ mod integration { let directory = services.directory_url(); let ohttp_keys = services.fetch_ohttp_keys().await?; let address = receiver.get_new_address(None, None)?.assume_checked(); - + let new_receiver = + NewReceiver::new(address, directory.clone(), ohttp_keys.clone(), None)?; + let storage_token = + new_receiver.persist(&mut NoopPersister).map_err(|e| e.to_string())?; let mut session = - Receiver::new(address, directory.clone(), ohttp_keys.clone(), None)?; + Receiver::load(storage_token, &NoopPersister).map_err(|e| e.to_string())?; // ********************** // Inside the V1 Sender: @@ -690,7 +703,8 @@ mod integration { #[cfg(feature = "_multiparty")] mod multiparty { use bitcoin::ScriptBuf; - use payjoin::receive::v2::Receiver; + use payjoin::persist::NoopPersister; + use payjoin::receive::v2::{NewReceiver, Receiver}; use payjoin::send::multiparty::{ GetContext as MultiPartyGetContext, SenderBuilder as MultiPartySenderBuilder, }; @@ -738,12 +752,16 @@ mod integration { // Senders will generate a sweep psbt and send PSBT to receiver subdir for sender in senders.iter() { let address = receiver.get_new_address(None, None)?.assume_checked(); - let receiver_session = Receiver::new( + let new_receiver = NewReceiver::new( address.clone(), directory.clone(), ohttp_keys.clone(), None, )?; + let storage_token = + new_receiver.persist(&mut NoopPersister).map_err(|e| e.to_string())?; + let receiver_session = + Receiver::load(storage_token, &NoopPersister).map_err(|e| e.to_string())?; let pj_uri = receiver_session.pj_uri(); let psbt = build_sweep_psbt(sender, &pj_uri)?; let sender_ctx = MultiPartySenderBuilder::new(psbt.clone(), pj_uri.clone()) From 9e137e39b6473f5fc270645da911c907f4422bab Mon Sep 17 00:00:00 2001 From: Armin Sabouri Date: Wed, 5 Mar 2025 13:27:19 -0500 Subject: [PATCH 3/3] Persist sender when initialized MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit introduces a persistence flow for constructing `v2::Sender`. We enforce that senders must be persisted before initiating their type state. To support this, callers will first create a `NewSender`, which must be persisted. Once persisted, a storage token is returned that can be passed to `v2::Sender::load(...)` to construct the full `v2::Sender`. For use cases that do not require persistence—such as testing— can use the `NoopPersister` provided in `persist/mod.rs`. Issue: https://github.com/payjoin/rust-payjoin/issues/336 --- payjoin-cli/src/app/v2.rs | 12 +++-- payjoin-cli/src/db/v2.rs | 38 +++++++++++----- payjoin/src/send/multiparty/mod.rs | 55 ++++++++++++++++++++--- payjoin/src/send/v2/error.rs | 1 + payjoin/src/send/v2/mod.rs | 70 +++++++++++++++++++++++++----- payjoin/tests/integration.rs | 52 ++++++++++++++-------- 6 files changed, 180 insertions(+), 48 deletions(-) diff --git a/payjoin-cli/src/app/v2.rs b/payjoin-cli/src/app/v2.rs index fd050d2fe..58415fea3 100644 --- a/payjoin-cli/src/app/v2.rs +++ b/payjoin-cli/src/app/v2.rs @@ -14,7 +14,7 @@ use super::config::Config; use super::wallet::BitcoindWallet; use super::App as AppTrait; use crate::app::{handle_interrupt, http_agent}; -use crate::db::v2::ReceiverPersister; +use crate::db::v2::{ReceiverPersister, SenderPersister}; use crate::db::Database; #[derive(Clone)] @@ -53,11 +53,15 @@ impl AppTrait for App { Some(send_session) => send_session, None => { let psbt = self.create_original_psbt(&uri, fee_rate)?; - let mut req_ctx = SenderBuilder::new(psbt, uri.clone()) + let mut persister = SenderPersister::new(self.db.clone()); + let new_sender = SenderBuilder::new(psbt, uri.clone()) .build_recommended(fee_rate) .with_context(|| "Failed to build payjoin request")?; - self.db.insert_send_session(&mut req_ctx, url)?; - req_ctx + let storage_token = new_sender + .persist(&mut persister) + .map_err(|e| anyhow!("Failed to persist sender: {}", e))?; + Sender::load(storage_token, &persister) + .map_err(|e| anyhow!("Failed to load sender: {}", e))? } }; self.spawn_payjoin_sender(req_ctx).await diff --git a/payjoin-cli/src/db/v2.rs b/payjoin-cli/src/db/v2.rs index a140d58bb..f64d387c3 100644 --- a/payjoin-cli/src/db/v2.rs +++ b/payjoin-cli/src/db/v2.rs @@ -3,12 +3,36 @@ use std::sync::Arc; use bitcoincore_rpc::jsonrpc::serde_json; use payjoin::persist::{Persister, Value}; use payjoin::receive::v2::{Receiver, ReceiverToken}; -use payjoin::send::v2::Sender; +use payjoin::send::v2::{Sender, SenderToken}; use sled::Tree; use url::Url; use super::*; +pub(crate) struct SenderPersister(Arc); +impl SenderPersister { + pub fn new(db: Arc) -> Self { Self(db) } +} + +impl Persister for SenderPersister { + type Token = SenderToken; + type Error = crate::db::error::Error; + fn save(&mut self, value: Sender) -> std::result::Result { + let send_tree = self.0 .0.open_tree("send_sessions")?; + let key = value.key(); + let value = serde_json::to_vec(&value).map_err(Error::Serialize)?; + send_tree.insert(key.clone(), value.as_slice())?; + send_tree.flush()?; + Ok(key) + } + + fn load(&self, key: SenderToken) -> std::result::Result { + let send_tree = self.0 .0.open_tree("send_sessions")?; + let value = send_tree.get(key.as_ref())?.ok_or(Error::NotFound(key.to_string()))?; + serde_json::from_slice(&value).map_err(Error::Deserialize) + } +} + pub(crate) struct ReceiverPersister(Arc); impl ReceiverPersister { pub fn new(db: Arc) -> Self { Self(db) } @@ -51,14 +75,6 @@ impl Database { Ok(()) } - pub(crate) fn insert_send_session(&self, session: &mut Sender, pj_url: &Url) -> Result<()> { - let send_tree: Tree = self.0.open_tree("send_sessions")?; - let value = serde_json::to_string(session).map_err(Error::Serialize)?; - send_tree.insert(pj_url.to_string(), IVec::from(value.as_str()))?; - send_tree.flush()?; - Ok(()) - } - pub(crate) fn get_send_sessions(&self) -> Result> { let send_tree: Tree = self.0.open_tree("send_sessions")?; let mut sessions = Vec::new(); @@ -72,7 +88,7 @@ impl Database { pub(crate) fn get_send_session(&self, pj_url: &Url) -> Result> { let send_tree = self.0.open_tree("send_sessions")?; - if let Some(val) = send_tree.get(pj_url.to_string())? { + if let Some(val) = send_tree.get(pj_url.as_str())? { let session: Sender = serde_json::from_slice(&val).map_err(Error::Deserialize)?; Ok(Some(session)) } else { @@ -82,7 +98,7 @@ impl Database { pub(crate) fn clear_send_session(&self, pj_url: &Url) -> Result<()> { let send_tree: Tree = self.0.open_tree("send_sessions")?; - send_tree.remove(pj_url.to_string())?; + send_tree.remove(pj_url.as_str())?; send_tree.flush()?; Ok(()) } diff --git a/payjoin/src/send/multiparty/mod.rs b/payjoin/src/send/multiparty/mod.rs index e2b5b30b7..33305e8ad 100644 --- a/payjoin/src/send/multiparty/mod.rs +++ b/payjoin/src/send/multiparty/mod.rs @@ -1,3 +1,5 @@ +use std::fmt::{self, Display}; + use bitcoin::{FeeRate, Psbt}; use error::{ CreateRequestError, FinalizeResponseError, FinalizedError, InternalCreateRequestError, @@ -11,8 +13,8 @@ use super::{serialize_url, AdditionalFeeContribution, BuildSenderError, Internal use crate::hpke::decrypt_message_b; use crate::ohttp::ohttp_decapsulate; use crate::output_substitution::OutputSubstitution; -use crate::receive::ImplementationError; -use crate::send::v2::V2PostContext; +use crate::persist::{self, Persister}; +use crate::send::v2::{ImplementationError, V2PostContext}; use crate::uri::UrlExt; use crate::{PjUri, Request}; @@ -23,17 +25,58 @@ pub struct SenderBuilder<'a>(v2::SenderBuilder<'a>); impl<'a> SenderBuilder<'a> { pub fn new(psbt: Psbt, uri: PjUri<'a>) -> Self { Self(v2::SenderBuilder::new(psbt, uri)) } - pub fn build_recommended(self, min_fee_rate: FeeRate) -> Result { - let v2 = v2::SenderBuilder::new(self.0 .0.psbt, self.0 .0.uri) - .build_recommended(min_fee_rate)?; - Ok(Sender(v2)) + + pub fn build_recommended(self, min_fee_rate: FeeRate) -> Result { + let sender = self.0.build_recommended(min_fee_rate)?; + Ok(NewSender(sender)) } } +pub struct NewSender(v2::NewSender); + +impl NewSender { + pub fn persist>( + &self, + persister: &mut P, + ) -> Result { + let sender = + Sender(v2::Sender { v1: self.0.v1.clone(), reply_key: self.0.reply_key.clone() }); + persister.save(sender).map_err(ImplementationError::from) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SenderToken(Url); + +impl Display for SenderToken { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", self.0) } +} + +impl From for SenderToken { + fn from(sender: Sender) -> Self { SenderToken(sender.0.endpoint().clone()) } +} + +impl AsRef<[u8]> for SenderToken { + fn as_ref(&self) -> &[u8] { self.0.as_str().as_bytes() } +} + #[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Sender(v2::Sender); +impl persist::Value for Sender { + type Key = SenderToken; + + fn key(&self) -> Self::Key { SenderToken(self.0.endpoint().clone()) } +} + impl Sender { + pub fn load>( + token: P::Token, + persister: &P, + ) -> Result { + let sender = persister.load(token).map_err(ImplementationError::from)?; + Ok(sender) + } pub fn extract_v2( &self, ohttp_relay: Url, diff --git a/payjoin/src/send/v2/error.rs b/payjoin/src/send/v2/error.rs index 2a5a105ee..8e52d0e6b 100644 --- a/payjoin/src/send/v2/error.rs +++ b/payjoin/src/send/v2/error.rs @@ -2,6 +2,7 @@ use core::fmt; use crate::uri::url_ext::ParseReceiverPubkeyParamError; +pub type ImplementationError = Box; /// Error returned when request could not be created. /// /// This error can currently only happen due to programmer mistake. diff --git a/payjoin/src/send/v2/mod.rs b/payjoin/src/send/v2/mod.rs index 09656daaf..494b9edce 100644 --- a/payjoin/src/send/v2/mod.rs +++ b/payjoin/src/send/v2/mod.rs @@ -21,8 +21,10 @@ //! [`bitmask-core`](https://github.com/diba-io/bitmask-core) BDK integration. Bring your own //! wallet and http client. +use std::fmt::{self, Display}; + use bitcoin::hashes::{sha256, Hash}; -pub use error::{CreateRequestError, EncapsulationError}; +pub use error::{CreateRequestError, EncapsulationError, ImplementationError}; use error::{InternalCreateRequestError, InternalEncapsulationError}; use ohttp::ClientResponse; use serde::{Deserialize, Serialize}; @@ -32,6 +34,7 @@ use super::error::BuildSenderError; use super::*; use crate::hpke::{decrypt_message_b, encrypt_message_a, HpkeSecretKey}; use crate::ohttp::{ohttp_decapsulate, ohttp_encapsulate}; +use crate::persist::{Persister, Value}; use crate::send::v1; use crate::uri::{ShortId, UrlExt}; use crate::{HpkeKeyPair, HpkePublicKey, IntoUrl, OhttpKeys, PjUri, Request}; @@ -64,11 +67,12 @@ impl<'a> SenderBuilder<'a> { // The minfeerate parameter is set if the contribution is available in change. // // This method fails if no recommendation can be made or if the PSBT is malformed. - pub fn build_recommended(self, min_fee_rate: FeeRate) -> Result { - Ok(Sender { + pub fn build_recommended(self, min_fee_rate: FeeRate) -> Result { + let sender = NewSender { v1: self.0.build_recommended(min_fee_rate)?, reply_key: HpkeKeyPair::gen_keypair().0, - }) + }; + Ok(sender) } /// Offer the receiver contribution to pay for his input. @@ -90,8 +94,8 @@ impl<'a> SenderBuilder<'a> { change_index: Option, min_fee_rate: FeeRate, clamp_fee_contribution: bool, - ) -> Result { - Ok(Sender { + ) -> Result { + let sender = NewSender { v1: self.0.build_with_additional_fee( max_fee_contribution, change_index, @@ -99,7 +103,8 @@ impl<'a> SenderBuilder<'a> { clamp_fee_contribution, )?, reply_key: HpkeKeyPair::gen_keypair().0, - }) + }; + Ok(sender) } /// Perform Payjoin without incentivizing the payee to cooperate. @@ -109,11 +114,28 @@ impl<'a> SenderBuilder<'a> { pub fn build_non_incentivizing( self, min_fee_rate: FeeRate, - ) -> Result { - Ok(Sender { + ) -> Result { + let sender = NewSender { v1: self.0.build_non_incentivizing(min_fee_rate)?, reply_key: HpkeKeyPair::gen_keypair().0, - }) + }; + Ok(sender) + } +} + +#[derive(Debug)] +pub struct NewSender { + pub(crate) v1: v1::Sender, + pub(crate) reply_key: HpkeSecretKey, +} + +impl NewSender { + pub fn persist>( + &self, + persister: &mut P, + ) -> Result { + let sender = Sender { v1: self.v1.clone(), reply_key: self.reply_key.clone() }; + Ok(persister.save(sender)?) } } @@ -125,7 +147,35 @@ pub struct Sender { pub(crate) reply_key: HpkeSecretKey, } +/// Opaque key type for the sender +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SenderToken(Url); + +impl Display for SenderToken { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", self.0) } +} + +impl From for SenderToken { + fn from(sender: Sender) -> Self { SenderToken(sender.endpoint().clone()) } +} + +impl AsRef<[u8]> for SenderToken { + fn as_ref(&self) -> &[u8] { self.0.as_str().as_bytes() } +} + +impl Value for Sender { + type Key = SenderToken; + + fn key(&self) -> Self::Key { SenderToken(self.endpoint().clone()) } +} + impl Sender { + pub fn load>( + token: P::Token, + persister: &P, + ) -> Result { + persister.load(token).map_err(ImplementationError::from) + } /// Extract serialized V1 Request and Context from a Payjoin Proposal pub fn extract_v1(&self) -> (Request, v1::V1Context) { self.v1.extract_v1() } diff --git a/payjoin/tests/integration.rs b/payjoin/tests/integration.rs index c8bde3568..02c194798 100644 --- a/payjoin/tests/integration.rs +++ b/payjoin/tests/integration.rs @@ -173,7 +173,7 @@ mod integration { use http::StatusCode; use payjoin::persist::NoopPersister; use payjoin::receive::v2::{NewReceiver, PayjoinProposal, Receiver, UncheckedProposal}; - use payjoin::send::v2::SenderBuilder; + use payjoin::send::v2::{Sender, SenderBuilder}; use payjoin::{OhttpKeys, PjUri, UriExt}; use payjoin_test_utils::{BoxSendSyncError, TestServices}; use reqwest::{Client, Response}; @@ -264,8 +264,13 @@ mod integration { // Inside the Sender: let psbt = build_original_psbt(&sender, &expired_receiver.pj_uri())?; // Test that an expired pj_url errors - let expired_req_ctx = SenderBuilder::new(psbt, expired_receiver.pj_uri()) + let new_sender = SenderBuilder::new(psbt, expired_receiver.pj_uri()) .build_non_incentivizing(FeeRate::BROADCAST_MIN)?; + let storage_token = + new_sender.persist(&mut NoopPersister).map_err(|e| e.to_string())?; + let expired_req_ctx = + Sender::load(storage_token, &NoopPersister).map_err(|e| e.to_string())?; + match expired_req_ctx.extract_v2(ohttp_relay) { // Internal error types are private, so check against a string Err(err) => assert!(err.to_string().contains("expired")), @@ -331,8 +336,12 @@ mod integration { .check_pj_supported() .map_err(|e| e.to_string())?; let psbt = build_sweep_psbt(&sender, &pj_uri)?; - let req_ctx = SenderBuilder::new(psbt.clone(), pj_uri.clone()) - .build_recommended(FeeRate::BROADCAST_MIN)?; + let new_sender = + SenderBuilder::new(psbt, pj_uri).build_recommended(FeeRate::BROADCAST_MIN)?; + let storage_token = + new_sender.persist(&mut NoopPersister).map_err(|e| e.to_string())?; + let req_ctx = + Sender::load(storage_token, &NoopPersister).map_err(|e| e.to_string())?; let (Request { url, body, content_type, .. }, send_ctx) = req_ctx.extract_v2(ohttp_relay.to_owned())?; let response = agent @@ -426,8 +435,11 @@ mod integration { .check_pj_supported() .map_err(|e| e.to_string())?; let psbt = build_original_psbt(&sender, &pj_uri)?; - let req_ctx = SenderBuilder::new(psbt.clone(), pj_uri.clone()) - .build_recommended(FeeRate::BROADCAST_MIN)?; + let new_sender = + SenderBuilder::new(psbt, pj_uri).build_recommended(FeeRate::BROADCAST_MIN)?; + let storage_token = + new_sender.persist(&mut NoopPersister).map_err(|e| e.to_string())?; + let req_ctx = Sender::load(storage_token, &NoopPersister).map_err(|e| e.to_string())?; let (req, ctx) = req_ctx.extract_v1(); let headers = HeaderMock::new(&req.body, req.content_type); @@ -491,15 +503,17 @@ mod integration { .check_pj_supported() .map_err(|e| e.to_string())?; let psbt = build_original_psbt(&sender, &pj_uri)?; - let (Request { url, body, content_type, .. }, send_ctx) = - SenderBuilder::new(psbt, pj_uri) - .build_with_additional_fee( - Amount::from_sat(10000), - None, - FeeRate::ZERO, - false, - )? - .extract_v1(); + let new_sender = SenderBuilder::new(psbt, pj_uri).build_with_additional_fee( + Amount::from_sat(10000), + None, + FeeRate::ZERO, + false, + )?; + let storage_token = + new_sender.persist(&mut NoopPersister).map_err(|e| e.to_string())?; + let req_ctx = + Sender::load(storage_token, &NoopPersister).map_err(|e| e.to_string())?; + let (Request { url, body, content_type, .. }, send_ctx) = req_ctx.extract_v1(); log::info!("send fallback v1 to offline receiver fail"); let res = agent .post(url.clone()) @@ -706,7 +720,7 @@ mod integration { use payjoin::persist::NoopPersister; use payjoin::receive::v2::{NewReceiver, Receiver}; use payjoin::send::multiparty::{ - GetContext as MultiPartyGetContext, SenderBuilder as MultiPartySenderBuilder, + GetContext as MultiPartyGetContext, Sender, SenderBuilder as MultiPartySenderBuilder, }; use payjoin_test_utils::{ init_bitcoind_multi_sender_single_reciever, BoxSendSyncError, TestServices, @@ -766,8 +780,12 @@ mod integration { let psbt = build_sweep_psbt(sender, &pj_uri)?; let sender_ctx = MultiPartySenderBuilder::new(psbt.clone(), pj_uri.clone()) .build_recommended(FeeRate::BROADCAST_MIN)?; + let storage_token = + sender_ctx.persist(&mut NoopPersister).map_err(|e| e.to_string())?; + let req_ctx = + Sender::load(storage_token, &NoopPersister).map_err(|e| e.to_string())?; let (Request { url, body, content_type, .. }, send_post_ctx) = - sender_ctx.extract_v2(ohttp_relay.to_owned())?; + req_ctx.extract_v2(ohttp_relay.to_owned())?; let response = agent .post(url.clone()) .header("Content-Type", content_type)