diff --git a/payjoin-ffi/javascript/test/integration.test.ts b/payjoin-ffi/javascript/test/integration.test.ts index fb607b83f..e60c18ae3 100644 --- a/payjoin-ffi/javascript/test/integration.test.ts +++ b/payjoin-ffi/javascript/test/integration.test.ts @@ -504,18 +504,33 @@ function testFfiValidation(payjoin: PayjoinModule): void { assert.strictEqual(inner.tag, "WeightOutOfRange"); } - const pjUri = payjoin.Uri.parse( + const v1PjUri = payjoin.Uri.parse( "bitcoin:12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX?amount=1&pj=https://example.com", ).checkPjSupported(); + const v2PjUri = payjoin.Uri.parse( + "bitcoin:2N47mmrWXsNBvQR6k78hWJoTji57zXwNcU7?pjos=0&pj=HTTPS://PAYJO.IN/TXJCGKTKXLUUZ%23EX1WKV8CEC-OH1QYPM59NK2LXXS4890SUAXXYT25Z2VAPHP0X7YEYCJXGWAG6UG9ZU6NQ-RK1Q0DJS3VVDXWQQTLQ8022QGXSX7ML9PHZ6EDSF6AKEWQG758JPS2EV", + ).checkPjSupported(); const psbt = testUtils.originalPsbt(); - assert.throws(() => { - new payjoin.SenderBuilder(psbt, pjUri).buildRecommended( + try { + new payjoin.SenderBuilder(psbt, v2PjUri).buildRecommended( 18446744073709551615n, ); - }, /RuntimeError/); + assert.fail("Expected FeeRateOutOfRange error"); + } catch (e) { + const [inner] = payjoin.SenderInputError.FfiValidation.getInner(e); + assert.strictEqual(inner.tag, "FeeRateOutOfRange"); + } + + // A BIP 78 (v1) endpoint must surface as a typed error the caller can handle. + try { + new payjoin.SenderBuilder(psbt, v1PjUri); + assert.fail("Expected UnsupportedPjVersion error"); + } catch (e) { + assert.ok(payjoin.SenderInputError.UnsupportedPjVersion.instanceOf(e)); + } assert.throws(() => { - pjUri.setAmountSats(tooLargeAmount); + v1PjUri.setAmountSats(tooLargeAmount); }, /AmountOutOfRange/); } diff --git a/payjoin-ffi/src/send/error.rs b/payjoin-ffi/src/send/error.rs index 0685a34b4..52e2599a2 100644 --- a/payjoin-ffi/src/send/error.rs +++ b/payjoin-ffi/src/send/error.rs @@ -44,6 +44,10 @@ pub enum SenderInputError { Build(Arc), #[error(transparent)] FfiValidation(FfiValidationError), + /// The URI's `pj` endpoint is BIP 78 (v1) only. This sender speaks BIP 77 + /// (v2) and cannot pay a v1 receiver, so fall back to a plain transaction. + #[error("The payjoin URI is a BIP 78 (v1) endpoint, which this sender does not support")] + UnsupportedPjVersion, } impl From for SenderInputError { diff --git a/payjoin-ffi/src/send/mod.rs b/payjoin-ffi/src/send/mod.rs index bccbead8b..cae799714 100644 --- a/payjoin-ffi/src/send/mod.rs +++ b/payjoin-ffi/src/send/mod.rs @@ -351,12 +351,25 @@ impl SenderBuilder { /// /// Call [`SenderBuilder::build_recommended()`] or other `build` methods /// to create a [`WithReplyKey`] + /// + /// Only BIP 77 (v2) payjoin URIs are supported. A URI whose `pj` endpoint is + /// BIP 78 (v1) only fails with [`SenderInputError::UnsupportedPjVersion`]. #[uniffi::constructor] pub fn new(psbt: String, uri: Arc) -> Result { let psbt = payjoin::bitcoin::psbt::Psbt::from_str(psbt.as_str()) .map_err(PsbtParseError::from) .map_err(SenderInputError::Psbt)?; - let builder = payjoin::send::v2::SenderBuilder::new(psbt, Arc::unwrap_or_clone(uri).into()); + let uri: payjoin::PjUri = Arc::unwrap_or_clone(uri).into(); + // These bindings expose no v1 sender, so a BIP 78 endpoint is refused up front. + let builder = match uri.extras().pj_param() { + payjoin::PjParam::V2(pj_param) => payjoin::send::v2::SenderBuilder::from_parts( + psbt, + pj_param, + uri.address(), + uri.amount(), + ), + _ => return Err(SenderInputError::UnsupportedPjVersion), + }; Ok(builder.into()) } @@ -835,3 +848,38 @@ impl payjoin::persist::AsyncSessionPersister for AsyncCallbackPersisterAdapter { async move { persister.close().await } } } + +#[cfg(all(test, feature = "_test-utils"))] +mod tests { + use payjoin_test_utils::ORIGINAL_PSBT; + + use super::*; + use crate::uri::Uri; + + const V1_PJ_URI: &str = + "bitcoin:12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX?amount=1&pj=https://example.com"; + const V2_PJ_URI: &str = "bitcoin:2N47mmrWXsNBvQR6k78hWJoTji57zXwNcU7?pjos=0&pj=HTTPS://PAYJO.IN/TXJCGKTKXLUUZ%23EX1WKV8CEC-OH1QYPM59NK2LXXS4890SUAXXYT25Z2VAPHP0X7YEYCJXGWAG6UG9ZU6NQ-RK1Q0DJS3VVDXWQQTLQ8022QGXSX7ML9PHZ6EDSF6AKEWQG758JPS2EV"; + + // check_pj_supported accepts BIP 78 and BIP 77 endpoints alike, so a v1 URI + // reaches SenderBuilder::new and the version check has to happen there. + fn pj_uri(uri: &str) -> Arc { + Uri::parse(uri.to_string()) + .expect("valid URI") + .check_pj_supported() + .expect("payjoin to be supported") + } + + #[test] + fn v1_uri_is_rejected_with_typed_error() { + let err = SenderBuilder::new(ORIGINAL_PSBT.to_string(), pj_uri(V1_PJ_URI)) + .err() + .expect("v1 URI must be rejected"); + assert!(matches!(err, SenderInputError::UnsupportedPjVersion), "got {err:?}"); + } + + #[test] + fn v2_uri_is_accepted() { + SenderBuilder::new(ORIGINAL_PSBT.to_string(), pj_uri(V2_PJ_URI)) + .expect("v2 URI must be accepted"); + } +} diff --git a/payjoin/src/core/send/v2/mod.rs b/payjoin/src/core/send/v2/mod.rs index 982565a25..5b0695d35 100644 --- a/payjoin/src/core/send/v2/mod.rs +++ b/payjoin/src/core/send/v2/mod.rs @@ -71,6 +71,18 @@ impl SenderBuilder { /// /// Call [`SenderBuilder::build_recommended()`] or other `build` methods /// to create a [`Sender`] + /// + /// # Panics + /// + /// Panics if the URI's `pj` endpoint is BIP 78 (v1) only. A v2 sender cannot + /// pay a v1 receiver, and a URI scanned from a QR code may well be one, so + /// match on [`PjUri::extras`]`().pj_param()` and call + /// [`SenderBuilder::from_parts`] with the [`crate::PjParam::V2`] contents + /// instead. That leaves the v1 case for the caller to handle. + #[deprecated( + since = "1.0.1", + note = "panics on v1 payjoin URIs; match on the URI's `PjParam` and use `SenderBuilder::from_parts`" + )] pub fn new(psbt: Psbt, uri: PjUri) -> Self { match uri.extras().pj_param() { #[cfg(feature = "v1")] @@ -80,6 +92,16 @@ impl SenderBuilder { } } + /// Prepare the context from which to make Sender requests from the parts of a + /// [`PjUri`]: its BIP 77 `pj` parameter, the receiver's address, and the + /// requested amount, if any. + /// + /// Obtain `pj_param` by matching [`crate::PjParam::V2`] out of + /// [`PjUri::extras`]`().pj_param()`. A BIP 78 (v1) endpoint cannot be paid + /// by this sender; the `v1` module's `SenderBuilder` handles those. + /// + /// Call [`SenderBuilder::build_recommended()`] or other `build` methods + /// to create a [`Sender`] pub fn from_parts( psbt: Psbt, pj_param: &PjParam, @@ -722,6 +744,7 @@ mod test { } #[test] + #[allow(deprecated)] fn test_v2_sender_builder() { let address = Address::from_str("2N47mmrWXsNBvQR6k78hWJoTji57zXwNcU7") .expect("valid address") diff --git a/payjoin/src/core/send/v2/session.rs b/payjoin/src/core/send/v2/session.rs index 78126bced..35f970648 100644 --- a/payjoin/src/core/send/v2/session.rs +++ b/payjoin/src/core/send/v2/session.rs @@ -195,6 +195,20 @@ mod tests { /// Expired V2 Payjoin URI without Amount inspired by BIP 77 test vector const PJ_URI: &str = "bitcoin:2N47mmrWXsNBvQR6k78hWJoTji57zXwNcU7?pjos=0&pj=HTTPS://PAYJO.IN/TXJCGKTKXLUUZ%23EX1WKV8CEC-OH1QYPM59NK2LXXS4890SUAXXYT25Z2VAPHP0X7YEYCJXGWAG6UG9ZU6NQ-RK1Q0DJS3VVDXWQQTLQ8022QGXSX7ML9PHZ6EDSF6AKEWQG758JPS2EV"; + fn sender_builder(psbt: bitcoin::Psbt) -> SenderBuilder { + let uri = Uri::try_from(PJ_URI) + .expect("Valid uri") + .assume_checked() + .check_pj_supported() + .expect("Payjoin to be supported"); + match uri.extras().pj_param() { + crate::PjParam::V2(pj_param) => + SenderBuilder::from_parts(psbt, pj_param, uri.address(), uri.amount()), + #[cfg(feature = "v1")] + crate::PjParam::V1(_) => panic!("PJ_URI is a v2 URI"), + } + } + #[test] fn test_sender_session_event_serialization_roundtrip() { let keypair = HpkeKeyPair::gen_keypair(); @@ -312,18 +326,11 @@ mod tests { #[tokio::test] async fn test_sender_session_history_with_expired_session() { let psbt = PARSED_ORIGINAL_PSBT.clone(); - let sender = SenderBuilder::new( - psbt.clone(), - Uri::try_from(PJ_URI) - .expect("Valid uri") - .assume_checked() - .check_pj_supported() - .expect("Payjoin to be supported"), - ) - .build_recommended(FeeRate::BROADCAST_MIN) - .unwrap() - .save(&InMemoryPersister::default()) - .unwrap(); + let sender = sender_builder(psbt.clone()) + .build_recommended(FeeRate::BROADCAST_MIN) + .unwrap() + .save(&InMemoryPersister::default()) + .unwrap(); let test = SessionHistoryTest { events: vec![SessionEvent::Created(Box::new(sender.session_context.clone()))], expected_session_history: SessionHistoryExpectedOutcome { @@ -346,18 +353,11 @@ mod tests { #[tokio::test] async fn test_sender_session_history_with_reply_key_event() { let psbt = PARSED_ORIGINAL_PSBT.clone(); - let mut sender = SenderBuilder::new( - psbt.clone(), - Uri::try_from(PJ_URI) - .expect("Valid uri") - .assume_checked() - .check_pj_supported() - .expect("Payjoin to be supported"), - ) - .build_recommended(FeeRate::BROADCAST_MIN) - .unwrap() - .save(&InMemoryPersister::default()) - .unwrap(); + let mut sender = sender_builder(psbt.clone()) + .build_recommended(FeeRate::BROADCAST_MIN) + .unwrap() + .save(&InMemoryPersister::default()) + .unwrap(); sender.session_context.pj_param.expiration = Time::from_now(std::time::Duration::from_secs(60)).unwrap(); let test = SessionHistoryTest { @@ -382,18 +382,11 @@ mod tests { #[test] fn status_is_completed_for_closed_success() { let psbt = PARSED_ORIGINAL_PSBT.clone(); - let sender = SenderBuilder::new( - psbt.clone(), - Uri::try_from(PJ_URI) - .expect("Valid uri") - .assume_checked() - .check_pj_supported() - .expect("Payjoin to be supported"), - ) - .build_recommended(FeeRate::BROADCAST_MIN) - .unwrap() - .save(&InMemoryPersister::default()) - .unwrap(); + let sender = sender_builder(psbt.clone()) + .build_recommended(FeeRate::BROADCAST_MIN) + .unwrap() + .save(&InMemoryPersister::default()) + .unwrap(); let reply_key = HpkeKeyPair::gen_keypair(); let endpoint = Url::parse(&sender.endpoint()).expect("Could not parse url"); @@ -429,18 +422,11 @@ mod tests { #[test] fn status_prefers_closed_outcome_over_expired() { let psbt = PARSED_ORIGINAL_PSBT.clone(); - let mut sender = SenderBuilder::new( - psbt, - Uri::try_from(PJ_URI) - .expect("Valid uri") - .assume_checked() - .check_pj_supported() - .expect("Payjoin to be supported"), - ) - .build_recommended(FeeRate::BROADCAST_MIN) - .unwrap() - .save(&InMemoryPersister::default()) - .unwrap(); + let mut sender = sender_builder(psbt) + .build_recommended(FeeRate::BROADCAST_MIN) + .unwrap() + .save(&InMemoryPersister::default()) + .unwrap(); sender.session_context.pj_param.expiration = Time::try_from(SystemTime::now() - Duration::from_secs(1)) .expect("expiration in the past"); @@ -471,18 +457,11 @@ mod tests { #[tokio::test] async fn test_replaying_closed_sender_session_past_expiration_is_not_expired() { let psbt = PARSED_ORIGINAL_PSBT.clone(); - let sender = SenderBuilder::new( - psbt, - Uri::try_from(PJ_URI) - .expect("Valid uri") - .assume_checked() - .check_pj_supported() - .expect("Payjoin to be supported"), - ) - .build_recommended(FeeRate::BROADCAST_MIN) - .unwrap() - .save(&InMemoryPersister::default()) - .unwrap(); + let sender = sender_builder(psbt) + .build_recommended(FeeRate::BROADCAST_MIN) + .unwrap() + .save(&InMemoryPersister::default()) + .unwrap(); let persister = InMemoryPersister::::default(); persister diff --git a/payjoin/tests/integration.rs b/payjoin/tests/integration.rs index 0c6633da0..2b67a4ce9 100644 --- a/payjoin/tests/integration.rs +++ b/payjoin/tests/integration.rs @@ -210,7 +210,7 @@ mod integration { }; use payjoin::send::v2::{replay_event_log as replay_sender_event_log, SenderBuilder}; use payjoin::send::ResponseError; - use payjoin::{OhttpKeys, PjUri}; + use payjoin::{OhttpKeys, PjParam, PjUri}; use payjoin_test_utils::{ BoxSendSyncError, InMemoryPersister, SessionPersister, TestServices, }; @@ -220,6 +220,16 @@ mod integration { use super::*; + /// Build a v2 sender the way a wallet does: match the URI's `pj` parameter and + /// hand the v2 parts to `from_parts`. + fn sender_builder(psbt: Psbt, pj_uri: &PjUri) -> SenderBuilder { + match pj_uri.extras().pj_param() { + PjParam::V2(pj_param) => + SenderBuilder::from_parts(psbt, pj_param, pj_uri.address(), pj_uri.amount()), + _ => panic!("v2 receiver issued a v1 payjoin URI"), + } + } + /// Possible actions the sender can take after receiving the Payjoin proposal from the /// receiver. /// @@ -317,7 +327,7 @@ 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 expired_req_ctx = sender_builder(psbt, &expired_receiver.pj_uri()) .build_non_incentivizing(FeeRate::BROADCAST_MIN)? .save(&send_persister)?; @@ -390,7 +400,7 @@ 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, pj_uri) + let req_ctx = sender_builder(psbt, &pj_uri) .build_recommended(FeeRate::BROADCAST_MIN)? .save(&sender_persister)?; let (Request { url, body, content_type, .. }, send_ctx) = @@ -842,7 +852,7 @@ 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, pj_uri) + let req_ctx = sender_builder(psbt, &pj_uri) .build_recommended(FeeRate::BROADCAST_MIN)? .save(send_persister)?; let (Request { url, body, content_type, .. }, send_ctx) =