Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 20 additions & 5 deletions payjoin-ffi/javascript/test/integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/);
}

Expand Down
4 changes: 4 additions & 0 deletions payjoin-ffi/src/send/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ pub enum SenderInputError {
Build(Arc<SenderBuilderError>),
#[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<FfiValidationError> for SenderInputError {
Expand Down
50 changes: 49 additions & 1 deletion payjoin-ffi/src/send/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<PjUri>) -> Result<Self, SenderInputError> {
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())
}

Expand Down Expand Up @@ -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<PjUri> {
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");
}
}
23 changes: 23 additions & 0 deletions payjoin/src/core/send/v2/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand All @@ -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,
Expand Down Expand Up @@ -722,6 +744,7 @@ mod test {
}

#[test]
#[allow(deprecated)]
fn test_v2_sender_builder() {
let address = Address::from_str("2N47mmrWXsNBvQR6k78hWJoTji57zXwNcU7")
.expect("valid address")
Expand Down
99 changes: 39 additions & 60 deletions payjoin/src/core/send/v2/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand All @@ -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");
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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::<SessionEvent>::default();
persister
Expand Down
18 changes: 14 additions & 4 deletions payjoin/tests/integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand All @@ -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.
///
Expand Down Expand Up @@ -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)?;

Expand Down Expand Up @@ -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) =
Expand Down Expand Up @@ -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) =
Expand Down
Loading