diff --git a/payjoin-cli/src/app/v2/mod.rs b/payjoin-cli/src/app/v2/mod.rs index 4337bf84b..d51266958 100644 --- a/payjoin-cli/src/app/v2/mod.rs +++ b/payjoin-cli/src/app/v2/mod.rs @@ -7,7 +7,8 @@ use payjoin::persist::OptionalTransitionOutcome; use payjoin::receive::v2::{ process_err_res, replay_event_log as replay_receiver_event_log, Initialized, MaybeInputsOwned, MaybeInputsSeen, OutputsUnknown, PayjoinProposal, ProvisionalProposal, ReceiveSession, - Receiver, SessionHistory, UncheckedProposal, WantsFeeRange, WantsInputs, WantsOutputs, + Receiver, ReceiverBuilder, SessionHistory, UncheckedProposal, WantsFeeRange, WantsInputs, + WantsOutputs, }; use payjoin::send::v2::{ replay_event_log as replay_sender_event_log, SendSession, Sender, SenderBuilder, V2GetContext, @@ -158,14 +159,12 @@ impl AppTrait for App { .await? .ohttp_keys; let persister = ReceiverPersister::new(self.db.clone())?; - let session = Receiver::create_session( - address, - self.config.v2()?.pj_directory.clone(), - ohttp_keys, - None, - Some(amount), - )? - .save(&persister)?; + let session = + ReceiverBuilder::new(address, self.config.v2()?.pj_directory.clone(), ohttp_keys)? + .with_amount(amount) + .build() + .save(&persister)?; + println!("Receive session established"); let pj_uri = session.pj_uri(); println!("Request Payjoin by sharing this Payjoin Uri:"); @@ -352,7 +351,7 @@ impl App { self.finalize_proposal(proposal, persister).await, ReceiveSession::PayjoinProposal(proposal) => self.send_payjoin_proposal(proposal, persister).await, - ReceiveSession::Uninitialized(_) => + ReceiveSession::Uninitialized => return Err(anyhow!("Uninitialized receiver session")), ReceiveSession::TerminalFailure => return Err(anyhow!("Terminal receiver session")), diff --git a/payjoin-ffi/dart/test/test_payjoin_integration_test.dart b/payjoin-ffi/dart/test/test_payjoin_integration_test.dart index 79fcb9b4b..ad86d24c7 100644 --- a/payjoin-ffi/dart/test/test_payjoin_integration_test.dart +++ b/payjoin-ffi/dart/test/test_payjoin_integration_test.dart @@ -134,8 +134,8 @@ payjoin.Initialized create_receiver_context( String directory, payjoin.OhttpKeys ohttp_keys, InMemoryReceiverPersister persister) { - var receiver = payjoin.UninitializedReceiver() - .createSession(address, directory, ohttp_keys, null, null) + var receiver = payjoin.ReceiverBuilder(address, directory, ohttp_keys) + .build() .save(persister); return receiver; } diff --git a/payjoin-ffi/dart/test/test_payjoin_unit_test.dart b/payjoin-ffi/dart/test/test_payjoin_unit_test.dart index c6fe0dad2..bbfc0a600 100644 --- a/payjoin-ffi/dart/test/test_payjoin_unit_test.dart +++ b/payjoin-ffi/dart/test/test_payjoin_unit_test.dart @@ -103,15 +103,12 @@ void main() { var persister = InMemoryReceiverPersister("1"); var address = bitcoin.Address( "tb1q6d3a2w975yny0asuvd9a67ner4nks58ff0q8g4", bitcoin.Network.signet); - payjoin.UninitializedReceiver() - .createSession( - address, - "https://example.com", - payjoin.OhttpKeys.fromString( - "OH1QYPM5JXYNS754Y4R45QWE336QFX6ZR8DQGVQCULVZTV20TFVEYDMFQC"), - null, - null) - .save(persister); + payjoin.ReceiverBuilder( + address, + "https://example.com", + payjoin.OhttpKeys.fromString( + "OH1QYPM5JXYNS754Y4R45QWE336QFX6ZR8DQGVQCULVZTV20TFVEYDMFQC"), + ).build().save(persister); final result = payjoin.replayReceiverEventLog(persister); expect(result, isA(), reason: "persistence should return a replay result"); @@ -121,15 +118,14 @@ void main() { var receiver_persister = InMemoryReceiverPersister("1"); var address = bitcoin.Address( "2MuyMrZHkbHbfjudmKUy45dU4P17pjG2szK", bitcoin.Network.testnet); - var receiver = payjoin.UninitializedReceiver() - .createSession( + var receiver = payjoin.ReceiverBuilder( address, "https://example.com", payjoin.OhttpKeys.fromString( "OH1QYPM5JXYNS754Y4R45QWE336QFX6ZR8DQGVQCULVZTV20TFVEYDMFQC"), - null, - null) - .save(receiver_persister); + ) + .build() + .save(receiver_persister); var uri = receiver.pjUri(); var sender_persister = InMemorySenderPersister("1"); diff --git a/payjoin-ffi/python/test/test_payjoin_integration_test.py b/payjoin-ffi/python/test/test_payjoin_integration_test.py index 36cec8b1b..c5bfb4128 100644 --- a/payjoin-ffi/python/test/test_payjoin_integration_test.py +++ b/payjoin-ffi/python/test/test_payjoin_integration_test.py @@ -84,7 +84,7 @@ async def process_receiver_proposal(self, receiver: ReceiveSession, recv_persist raise Exception(f"Unknown receiver state: {receiver}") def create_receiver_context(self, receiver_address: bitcoinffi.Address, directory: Url, ohttp_keys: OhttpKeys, recv_persister: InMemoryReceiverSessionEventLog) -> Initialized: - receiver = UninitializedReceiver().create_session(address=receiver_address, directory=directory.as_string(), ohttp_keys=ohttp_keys, expire_after=None, amount=None).save(recv_persister) + receiver = ReceiverBuilder(address=receiver_address, directory=directory.as_string(), ohttp_keys=ohttp_keys).build().save(recv_persister) return receiver async def retrieve_receiver_proposal(self, receiver: Initialized, recv_persister: InMemoryReceiverSessionEventLog, ohttp_relay: Url): diff --git a/payjoin-ffi/python/test/test_payjoin_unit_test.py b/payjoin-ffi/python/test/test_payjoin_unit_test.py index 63d5f06f4..4f6248073 100644 --- a/payjoin-ffi/python/test/test_payjoin_unit_test.py +++ b/payjoin-ffi/python/test/test_payjoin_unit_test.py @@ -50,13 +50,11 @@ class TestReceiverPersistence(unittest.TestCase): def test_receiver_persistence(self): persister = InMemoryReceiverPersister(1) address = payjoin.bitcoin.Address("tb1q6d3a2w975yny0asuvd9a67ner4nks58ff0q8g4", payjoin.bitcoin.Network.SIGNET) - payjoin.payjoin_ffi.UninitializedReceiver().create_session( + payjoin.payjoin_ffi.ReceiverBuilder( address, "https://example.com", payjoin.OhttpKeys.from_string("OH1QYPM5JXYNS754Y4R45QWE336QFX6ZR8DQGVQCULVZTV20TFVEYDMFQC"), - None, - None - ).save(persister) + ).build().save(persister) result = payjoin.payjoin_ffi.replay_receiver_event_log(persister) self.assertTrue(result.state().is_INITIALIZED()) @@ -80,13 +78,11 @@ def test_sender_persistence(self): # Create a receiver to just get the pj uri persister = InMemoryReceiverPersister(1) address = payjoin.bitcoin.Address("2MuyMrZHkbHbfjudmKUy45dU4P17pjG2szK", payjoin.bitcoin.Network.TESTNET) - receiver = payjoin.payjoin_ffi.UninitializedReceiver().create_session( + receiver = payjoin.payjoin_ffi.ReceiverBuilder( address, "https://example.com", payjoin.OhttpKeys.from_string("OH1QYPM5JXYNS754Y4R45QWE336QFX6ZR8DQGVQCULVZTV20TFVEYDMFQC"), - None, - None - ).save(persister) + ).build().save(persister) uri = receiver.pj_uri() persister = InMemorySenderPersister(1) diff --git a/payjoin-ffi/src/receive/mod.rs b/payjoin-ffi/src/receive/mod.rs index 48a31540a..5c2b0563d 100644 --- a/payjoin-ffi/src/receive/mod.rs +++ b/payjoin-ffi/src/receive/mod.rs @@ -7,7 +7,7 @@ pub use error::{ ReplyableError, SelectionError, SessionError, }; use payjoin::bitcoin::psbt::Psbt; -use payjoin::bitcoin::FeeRate; +use payjoin::bitcoin::{Amount, FeeRate}; use payjoin::persist::{MaybeFatalTransition, NextStateTransition}; use crate::bitcoin_ffi::{Address, OutPoint, Script, TxOut}; @@ -93,7 +93,7 @@ impl From for ReceiveSession { fn from(value: payjoin::receive::v2::ReceiveSession) -> Self { use payjoin::receive::v2::ReceiveSession; match value { - ReceiveSession::Uninitialized(_) => Self::Uninitialized, + ReceiveSession::Uninitialized => Self::Uninitialized, ReceiveSession::Initialized(inner) => Self::Initialized { inner: Arc::new(inner.into()) }, ReceiveSession::UncheckedProposal(inner) => @@ -236,47 +236,55 @@ impl InitialReceiveTransition { } } -#[derive(uniffi::Object)] -pub struct UninitializedReceiver {} +#[derive(Clone, Debug, uniffi::Object)] +pub struct ReceiverBuilder(payjoin::receive::v2::ReceiverBuilder); #[uniffi::export] -impl UninitializedReceiver { - #[allow(clippy::new_without_default)] - #[uniffi::constructor] - // TODO: no need for this constructor. `create_session` is the only way to create a receiver. - pub fn new() -> Self { Self {} } - +impl ReceiverBuilder { /// Creates a new [`Initialized`] with the provided parameters. /// /// # Parameters /// - `address`: The Bitcoin address for the payjoin session. /// - `directory`: The URL of the store-and-forward payjoin directory. /// - `ohttp_keys`: The OHTTP keys used for encrypting and decrypting HTTP requests and responses. - /// - `expire_after`: The duration after which the session expires. - /// - /// # Returns - /// A new instance of [`Initialized`]. /// /// # References /// - [BIP 77: Payjoin Version 2: Serverless Payjoin](https://github.com/bitcoin/bips/blob/master/bip-0077.md) - pub fn create_session( - &self, + #[uniffi::constructor] + pub fn new( address: Arc
, directory: String, ohttp_keys: Arc, - expire_after: Option, - amount: Option, - ) -> Result { - payjoin::receive::v2::Receiver::create_session( - Arc::unwrap_or_clone(address).into(), - directory, - Arc::unwrap_or_clone(ohttp_keys).into(), - expire_after.map(Duration::from_secs), - amount.map(payjoin::bitcoin::Amount::from_sat), - ) - .map(|receiver| InitialReceiveTransition(Arc::new(RwLock::new(Some(receiver))))) - .map_err(IntoUrlError::from) + ) -> Result { + Ok(Self( + payjoin::receive::v2::ReceiverBuilder::new( + Arc::unwrap_or_clone(address).into(), + directory, + Arc::unwrap_or_clone(ohttp_keys).into(), + ) + .map_err(IntoUrlError::from)?, + )) } + + pub fn with_amount(&self, amount_sats: u64) -> Self { + Self(self.0.clone().with_amount(Amount::from_sat(amount_sats))) + } + + pub fn with_expiry(&self, expiry: u64) -> Self { + Self(self.0.clone().with_expiry(Duration::from_secs(expiry))) + } + + pub fn build(&self) -> InitialReceiveTransition { + InitialReceiveTransition(Arc::new(RwLock::new(Some(self.0.clone().build())))) + } +} + +impl From for ReceiverBuilder { + fn from(value: payjoin::receive::v2::ReceiverBuilder) -> Self { Self(value) } +} + +impl From for payjoin::receive::v2::ReceiverBuilder { + fn from(value: ReceiverBuilder) -> Self { value.0 } } #[derive(Clone, Debug, serde::Serialize, serde::Deserialize, uniffi::Object)] diff --git a/payjoin/src/core/receive/v2/mod.rs b/payjoin/src/core/receive/v2/mod.rs index dbed3c20f..57837e813 100644 --- a/payjoin/src/core/receive/v2/mod.rs +++ b/payjoin/src/core/receive/v2/mod.rs @@ -115,7 +115,7 @@ fn short_id_from_pubkey(pubkey: &HpkePublicKey) -> ShortId { /// and the state to be updated with the next event over a uniform interface. #[derive(Debug, Clone, PartialEq)] pub enum ReceiveSession { - Uninitialized(Receiver), + Uninitialized, Initialized(Receiver), UncheckedProposal(Receiver), MaybeInputsOwned(Receiver), @@ -132,7 +132,7 @@ pub enum ReceiveSession { impl ReceiveSession { fn process_event(self, event: SessionEvent) -> Result { match (self, event) { - (ReceiveSession::Uninitialized(_), SessionEvent::Created(context)) => + (ReceiveSession::Uninitialized, SessionEvent::Created(context)) => Ok(ReceiveSession::Initialized(Receiver { state: Initialized { context } })), ( @@ -243,14 +243,11 @@ pub fn process_err_res(body: &[u8], context: ohttp::ClientResponse) -> Result<() process_post_res(body, context).map_err(|e| InternalSessionError::DirectoryResponse(e).into()) } -#[derive(Debug, Clone, PartialEq)] -/// The receiver is not initialized yet, no session context is available yet -pub struct UninitializedReceiver {} - -impl State for UninitializedReceiver {} +#[derive(Debug, Clone)] +pub struct ReceiverBuilder(SessionContext); -impl Receiver { - /// Creates a new [`Receiver`] with the provided parameters. +impl ReceiverBuilder { + /// Creates a new [`ReceiverBuilder`] with the provided parameters. /// /// This is the beginning of the receiver protocol in Payjoin v2. It uses the passed address, /// store-and-forward Payjoin directory URL, and the OHTTP keys to encrypt and decrypt HTTP @@ -261,28 +258,42 @@ impl Receiver { /// /// See [BIP 77: Payjoin Version 2: Serverless Payjoin](https://github.com/bitcoin/bips/blob/master/bip-0077.md) /// for more information on the purpose of each parameter for secure Payjoin v2 functionality. - pub fn create_session( + pub fn new( address: Address, directory: impl IntoUrl, ohttp_keys: OhttpKeys, - expire_after: Option, - amount: Option, - ) -> Result>, IntoUrlError> { + ) -> Result { let directory = directory.into_url()?; let session_context = SessionContext { address, directory, - mailbox: None, ohttp_keys, - expiry: SystemTime::now() + expire_after.unwrap_or(TWENTY_FOUR_HOURS_DEFAULT_EXPIRY), s: HpkeKeyPair::gen_keypair(), + expiry: SystemTime::now() + TWENTY_FOUR_HOURS_DEFAULT_EXPIRY, + amount: None, + mailbox: None, e: None, - amount, }; - Ok(NextStateTransition::success( - SessionEvent::Created(session_context.clone()), - Receiver { state: Initialized { context: session_context } }, - )) + Ok(Self(session_context)) + } + + pub fn with_expiry(self, expiry: Duration) -> Self { + Self(SessionContext { expiry: SystemTime::now() + expiry, ..self.0 }) + } + + pub fn with_amount(self, amount: Amount) -> Self { + Self(SessionContext { amount: Some(amount), ..self.0 }) + } + + pub fn with_mailbox(self, mailbox: impl IntoUrl) -> Result { + Ok(Self(SessionContext { mailbox: Some(mailbox.into_url()?), ..self.0 })) + } + + pub fn build(self) -> NextStateTransition> { + NextStateTransition::success( + SessionEvent::Created(self.0.clone()), + Receiver { state: Initialized { context: self.0 } }, + ) } } @@ -1346,14 +1357,13 @@ pub mod test { let now = SystemTime::now(); let noop_persister = NoopSessionPersister::default(); - let session = Receiver::create_session( + let session = ReceiverBuilder::new( SHARED_CONTEXT.address.clone(), SHARED_CONTEXT.directory.clone(), SHARED_CONTEXT.ohttp_keys.clone(), - None, - None, ) .expect("constructor on test vector should not fail") + .build() .save(&noop_persister) .expect("Noop persister shouldn't fail"); let session_expiry = session.context.expiry.duration_since(now).unwrap().as_secs(); @@ -1364,6 +1374,27 @@ pub mod test { } } + #[test] + fn build_receiver_with_non_default_expiry() { + let now = SystemTime::now(); + let expiry = Duration::from_secs(60); + let noop_persister = NoopSessionPersister::default(); + let receiver = ReceiverBuilder::new( + SHARED_CONTEXT.address.clone(), + SHARED_CONTEXT.directory.clone(), + SHARED_CONTEXT.ohttp_keys.clone(), + ) + .expect("constructor on test vector should not fail") + .with_expiry(expiry) + .build() + .save(&noop_persister) + .expect("Noop persister shouldn't fail"); + assert_eq!( + receiver.context.expiry.duration_since(now).unwrap().as_secs(), + expiry.as_secs() + ); + } + #[test] fn test_v2_pj_uri() { let uri = Receiver { state: Initialized { context: SHARED_CONTEXT.clone() } }.pj_uri(); diff --git a/payjoin/src/core/receive/v2/session.rs b/payjoin/src/core/receive/v2/session.rs index 5f3b7c44d..d6d093127 100644 --- a/payjoin/src/core/receive/v2/session.rs +++ b/payjoin/src/core/receive/v2/session.rs @@ -2,7 +2,7 @@ use std::time::SystemTime; use serde::{Deserialize, Serialize}; -use super::{ReceiveSession, Receiver, SessionContext, UninitializedReceiver}; +use super::{ReceiveSession, SessionContext}; use crate::output_substitution::OutputSubstitution; use crate::persist::SessionPersister; use crate::receive::v2::{extract_err_req, SessionError}; @@ -52,7 +52,7 @@ where let logs = persister .load() .map_err(|e| InternalReplayError::PersistenceFailure(ImplementationError::new(e)))?; - let mut receiver = ReceiveSession::Uninitialized(Receiver { state: UninitializedReceiver {} }); + let mut receiver = ReceiveSession::Uninitialized; let mut history = SessionHistory::default(); for event in logs { @@ -182,7 +182,8 @@ mod tests { use crate::receive::tests::original_from_test_vector; use crate::receive::v2::test::SHARED_CONTEXT; use crate::receive::v2::{ - Initialized, MaybeInputsOwned, PayjoinProposal, ProvisionalProposal, UncheckedProposal, + Initialized, MaybeInputsOwned, PayjoinProposal, ProvisionalProposal, Receiver, + UncheckedProposal, }; fn unchecked_receiver_from_test_vector() -> Receiver { diff --git a/payjoin/src/core/send/v2/mod.rs b/payjoin/src/core/send/v2/mod.rs index b3c2a71ba..74df8fc68 100644 --- a/payjoin/src/core/send/v2/mod.rs +++ b/payjoin/src/core/send/v2/mod.rs @@ -511,7 +511,7 @@ mod test { use super::*; use crate::persist::NoopSessionPersister; - use crate::receive::v2::Receiver; + use crate::receive::v2::ReceiverBuilder; use crate::OhttpKeys; const SERIALIZED_BODY_V2: &str = "63484e696450384241484d43414141414159386e757447674a647959475857694245623435486f65396c5747626b78682f36624e694f4a6443447544414141414141442b2f2f2f2f41747956754155414141414146366b554865684a38476e536442554f4f7636756a584c72576d734a5244434867495165414141414141415871525233514a62627a30686e513849765130667074476e2b766f746e656f66544141414141414542494b6762317755414141414146366b55336b34656b47484b57524e6241317256357452356b455644564e4348415163584667415578347046636c4e56676f31575741644e3153594e583874706854414243477343527a424541694238512b41366465702b527a393276687932366c5430416a5a6e3450524c6938426639716f422f434d6b30774967502f526a3250575a3367456a556b546c6844524e415130675877544f3774396e2b563134705a366f6c6a554249514d566d7341616f4e5748564d5330324c6654536530653338384c4e697450613155515a794f6968592b464667414241425941464562324769753663344b4f35595730706677336c4770396a4d55554141413d0a763d32"; @@ -601,8 +601,9 @@ mod test { let ohttp_keys = OhttpKeys( ohttp::KeyConfig::new(KEY_ID, KEM, Vec::from(SYMMETRIC)).expect("valid key config"), ); - let pj_uri = Receiver::create_session(address.clone(), directory, ohttp_keys, None, None) + let pj_uri = ReceiverBuilder::new(address.clone(), directory, ohttp_keys) .expect("constructor on test vector should not fail") + .build() .save(&NoopSessionPersister::default()) .expect("receiver should succeed") .pj_uri(); diff --git a/payjoin/tests/integration.rs b/payjoin/tests/integration.rs index ecc36fd50..3094e077f 100644 --- a/payjoin/tests/integration.rs +++ b/payjoin/tests/integration.rs @@ -173,7 +173,7 @@ mod integration { use payjoin::persist::NoopSessionPersister; use payjoin::receive::v2::{ replay_event_log as replay_receiver_event_log, PayjoinProposal, Receiver, - UncheckedProposal, + ReceiverBuilder, UncheckedProposal, }; use payjoin::send::v2::SenderBuilder; use payjoin::{OhttpKeys, PjUri, UriExt}; @@ -210,7 +210,8 @@ mod integration { .assume_checked(); let noop_persister = NoopSessionPersister::default(); let mut bad_initializer = - Receiver::create_session(mock_address, directory, bad_ohttp_keys, None, None)? + ReceiverBuilder::new(mock_address, directory, bad_ohttp_keys)? + .build() .save(&noop_persister)?; let (req, _ctx) = bad_initializer.create_poll_request(&ohttp_relay)?; agent @@ -249,14 +250,10 @@ 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::create_session( - address, - directory, - ohttp_keys, - Some(Duration::from_secs(0)), - None, - )? - .save(&recv_noop_persister)?; + let mut expired_receiver = ReceiverBuilder::new(address, directory, ohttp_keys)? + .with_expiry(Duration::from_secs(0)) + .build() + .save(&recv_noop_persister)?; match expired_receiver.create_poll_request(&ohttp_relay) { // Internal error types are private, so check against a string Err(err) => assert!(err.to_string().contains("expired")), @@ -306,9 +303,9 @@ mod integration { // Inside the Receiver: let address = receiver.get_new_address(None, None)?.assume_checked(); - let mut session = - Receiver::create_session(address, directory, ohttp_keys, None, None)? - .save(&persister)?; + let mut session = ReceiverBuilder::new(address, directory, ohttp_keys)? + .build() + .save(&persister)?; println!("session: {:#?}", &session); // Poll receive request let ohttp_relay = services.ohttp_relay_url(); @@ -424,9 +421,9 @@ mod integration { let address = receiver.get_new_address(None, None)?.assume_checked(); // test session with expiry in the future - let mut session = - Receiver::create_session(address, directory, ohttp_keys, None, None)? - .save(&recv_persister)?; + let mut session = ReceiverBuilder::new(address, directory, ohttp_keys)? + .build() + .save(&recv_persister)?; println!("session: {:#?}", &session); // Poll receive request let ohttp_relay = services.ohttp_relay_url(); @@ -604,14 +601,10 @@ mod integration { let ohttp_keys = services.fetch_ohttp_keys().await?; let recv_persister = NoopSessionPersister::default(); let address = receiver.get_new_address(None, None)?.assume_checked(); - let mut session = Receiver::create_session( - address, - directory.clone(), - ohttp_keys.clone(), - None, - None, - )? - .save(&recv_persister)?; + let mut session = + ReceiverBuilder::new(address, directory.clone(), ohttp_keys.clone())? + .build() + .save(&recv_persister)?; // ********************** // Inside the V1 Sender: