Skip to content

Commit 145d1cf

Browse files
authored
Fix pjos handling in v2 receiver and sender (payjoin#847)
Fix payjoin#843 and ensure the desired behavior is tested explicitly.
2 parents c8b4f36 + dee6890 commit 145d1cf

File tree

3 files changed

+106
-53
lines changed

3 files changed

+106
-53
lines changed

payjoin/src/core/receive/v2/mod.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -424,7 +424,7 @@ impl Receiver<Initialized> {
424424

425425
/// Build a V2 Payjoin URI from the receiver's context
426426
pub fn pj_uri<'a>(&self) -> crate::PjUri<'a> {
427-
pj_uri(&self.context, OutputSubstitution::Enabled)
427+
pj_uri(&self.context, OutputSubstitution::Disabled)
428428
}
429429

430430
pub(crate) fn apply_unchecked_from_payload(
@@ -1245,6 +1245,6 @@ pub mod test {
12451245
fn test_v2_pj_uri() {
12461246
let uri = Receiver { state: Initialized { context: SHARED_CONTEXT.clone() } }.pj_uri();
12471247
assert_ne!(uri.extras.endpoint, EXAMPLE_URL.clone());
1248-
assert_eq!(uri.extras.output_substitution, OutputSubstitution::Enabled);
1248+
assert_eq!(uri.extras.output_substitution, OutputSubstitution::Disabled);
12491249
}
12501250
}

payjoin/src/core/send/v1.rs

Lines changed: 37 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,14 @@ mod test {
305305
const PJ_URI: &str =
306306
"bitcoin:2N47mmrWXsNBvQR6k78hWJoTji57zXwNcU7?amount=0.02&pjos=0&pj=HTTPS://EXAMPLE.COM/";
307307

308+
fn pj_uri<'a>() -> PjUri<'a> {
309+
Uri::try_from(PJ_URI)
310+
.expect("uri should succeed")
311+
.assume_checked()
312+
.check_pj_supported()
313+
.expect("uri should support payjoin")
314+
}
315+
308316
fn create_v1_context() -> super::V1Context {
309317
let psbt_context = create_psbt_context().expect("failed to create context");
310318
super::V1Context { psbt_context }
@@ -381,40 +389,39 @@ mod test {
381389
}
382390

383391
#[test]
384-
fn test_build_recommended_fee_contribution() -> Result<(), BoxError> {
392+
fn test_build_recommended_max_fee_contribution() {
385393
let psbt = PARSED_ORIGINAL_PSBT.clone();
386-
let sender = SenderBuilder::new(
387-
psbt.clone(),
388-
Uri::try_from(PJ_URI)
389-
.map_err(|e| format!("{e}"))?
390-
.assume_checked()
391-
.check_pj_supported()
392-
.map_err(|e| format!("{e}"))?,
393-
)
394-
.build_recommended(FeeRate::from_sat_per_vb(2000000).expect("Could not determine feerate"));
395-
assert!(sender.is_ok(), "{:#?}", sender.err());
396-
assert_eq!(
397-
sender.unwrap().fee_contribution.unwrap().max_amount,
398-
psbt.unsigned_tx.output[0].value
399-
);
400-
Ok(())
394+
let sender = SenderBuilder::new(psbt.clone(), pj_uri())
395+
.build_recommended(
396+
FeeRate::from_sat_per_vb(2000000).expect("Could not determine feerate"),
397+
)
398+
.expect("sender should succeed");
399+
assert_eq!(sender.output_substitution, OutputSubstitution::Disabled);
400+
assert_eq!(&sender.payee, &pj_uri().address.script_pubkey());
401+
let fee_contribution = sender.fee_contribution.expect("sender should contribute fees");
402+
assert_eq!(fee_contribution.max_amount, psbt.unsigned_tx.output[0].value);
403+
assert_eq!(fee_contribution.vout, 0);
404+
assert_eq!(sender.min_fee_rate, FeeRate::from_sat_per_kwu(500000000));
401405
}
402406

403407
#[test]
404-
fn test_build_recommended() -> Result<(), BoxError> {
405-
let sender = SenderBuilder::new(
406-
PARSED_ORIGINAL_PSBT.clone(),
407-
Uri::try_from(PJ_URI)
408-
.map_err(|e| format!("{e}"))?
409-
.assume_checked()
410-
.check_pj_supported()
411-
.map_err(|e| format!("{e}"))?,
412-
)
413-
.build_recommended(FeeRate::MIN);
414-
assert!(sender.is_ok(), "{:#?}", sender.err());
415-
assert_eq!(NON_WITNESS_INPUT_WEIGHT, bitcoin::Weight::from_wu(160));
416-
assert_eq!(sender.unwrap().fee_contribution.unwrap().max_amount, Amount::from_sat(0));
417-
Ok(())
408+
fn test_build_recommended() {
409+
let sender = SenderBuilder::new(PARSED_ORIGINAL_PSBT.clone(), pj_uri())
410+
.build_recommended(FeeRate::BROADCAST_MIN)
411+
.expect("sender should succeed");
412+
assert_eq!(sender.output_substitution, OutputSubstitution::Disabled);
413+
assert_eq!(&sender.payee, &pj_uri().address.script_pubkey());
414+
let fee_contribution = sender.fee_contribution.expect("sender should contribute fees");
415+
assert_eq!(fee_contribution.max_amount, Amount::from_sat(91));
416+
assert_eq!(fee_contribution.vout, 0);
417+
assert_eq!(sender.min_fee_rate, FeeRate::from_sat_per_kwu(250));
418+
// Ensure the receiver's output substitution preference is respected either way
419+
let mut pj_uri = pj_uri();
420+
pj_uri.extras.output_substitution = OutputSubstitution::Enabled;
421+
let sender = SenderBuilder::new(PARSED_ORIGINAL_PSBT.clone(), pj_uri)
422+
.build_recommended(FeeRate::from_sat_per_vb_unchecked(1))
423+
.expect("sender should succeed");
424+
assert_eq!(sender.output_substitution, OutputSubstitution::Enabled);
418425
}
419426

420427
#[test]

payjoin/src/core/send/v2/mod.rs

Lines changed: 67 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -82,15 +82,7 @@ impl<'a> SenderBuilder<'a> {
8282
self,
8383
min_fee_rate: FeeRate,
8484
) -> MaybeBadInitInputsTransition<SessionEvent, Sender<WithReplyKey>, BuildSenderError> {
85-
let v1 = match self.0.build_recommended(min_fee_rate) {
86-
Ok(inner) => inner,
87-
Err(e) => return MaybeBadInitInputsTransition::bad_init_inputs(e),
88-
};
89-
let with_reply_key = WithReplyKey { v1, reply_key: HpkeKeyPair::gen_keypair().0 };
90-
MaybeBadInitInputsTransition::success(
91-
SessionEvent::CreatedReplyKey(with_reply_key.clone()),
92-
Sender { state: with_reply_key },
93-
)
85+
self.v2_sender_from_v1(self.0.clone().build_recommended(min_fee_rate))
9486
}
9587

9688
/// Offer the receiver contribution to pay for his input.
@@ -113,21 +105,12 @@ impl<'a> SenderBuilder<'a> {
113105
min_fee_rate: FeeRate,
114106
clamp_fee_contribution: bool,
115107
) -> MaybeBadInitInputsTransition<SessionEvent, Sender<WithReplyKey>, BuildSenderError> {
116-
let v1 = match self.0.build_with_additional_fee(
108+
self.v2_sender_from_v1(self.0.clone().build_with_additional_fee(
117109
max_fee_contribution,
118110
change_index,
119111
min_fee_rate,
120112
clamp_fee_contribution,
121-
) {
122-
Ok(inner) => inner,
123-
Err(e) => return MaybeBadInitInputsTransition::bad_init_inputs(e),
124-
};
125-
126-
let with_reply_key = WithReplyKey { v1, reply_key: HpkeKeyPair::gen_keypair().0 };
127-
MaybeBadInitInputsTransition::success(
128-
SessionEvent::CreatedReplyKey(with_reply_key.clone()),
129-
Sender { state: with_reply_key },
130-
)
113+
))
131114
}
132115

133116
/// Perform Payjoin without incentivizing the payee to cooperate.
@@ -138,11 +121,26 @@ impl<'a> SenderBuilder<'a> {
138121
self,
139122
min_fee_rate: FeeRate,
140123
) -> MaybeBadInitInputsTransition<SessionEvent, Sender<WithReplyKey>, BuildSenderError> {
141-
let v1 = match self.0.build_non_incentivizing(min_fee_rate) {
124+
self.v2_sender_from_v1(self.0.clone().build_non_incentivizing(min_fee_rate))
125+
}
126+
127+
/// Helper function that takes a V1 sender build result and wraps it in a V2 Sender,
128+
/// returning the appropriate state transition.
129+
fn v2_sender_from_v1(
130+
&self,
131+
v1_result: Result<v1::Sender, BuildSenderError>,
132+
) -> MaybeBadInitInputsTransition<SessionEvent, Sender<WithReplyKey>, BuildSenderError> {
133+
let mut v1 = match v1_result {
142134
Ok(inner) => inner,
143135
Err(e) => return MaybeBadInitInputsTransition::bad_init_inputs(e),
144136
};
145137

138+
// V2 senders may always ignore the receiver's `pjos` output substitution preference,
139+
// because all communications with the receiver are end-to-end authenticated.
140+
if self.0.output_substitution == OutputSubstitution::Enabled {
141+
v1.output_substitution = OutputSubstitution::Enabled;
142+
}
143+
146144
let with_reply_key = WithReplyKey { v1, reply_key: HpkeKeyPair::gen_keypair().0 };
147145
MaybeBadInitInputsTransition::success(
148146
SessionEvent::CreatedReplyKey(with_reply_key.clone()),
@@ -507,9 +505,12 @@ mod test {
507505
use std::time::{Duration, SystemTime};
508506

509507
use bitcoin::hex::FromHex;
508+
use bitcoin::Address;
510509
use payjoin_test_utils::{BoxError, EXAMPLE_URL, KEM, KEY_ID, PARSED_ORIGINAL_PSBT, SYMMETRIC};
511510

512511
use super::*;
512+
use crate::persist::NoopSessionPersister;
513+
use crate::receive::v2::Receiver;
513514
use crate::OhttpKeys;
514515

515516
const SERIALIZED_BODY_V2: &str = "63484e696450384241484d43414141414159386e757447674a647959475857694245623435486f65396c5747626b78682f36624e694f4a6443447544414141414141442b2f2f2f2f41747956754155414141414146366b554865684a38476e536442554f4f7636756a584c72576d734a5244434867495165414141414141415871525233514a62627a30686e513849765130667074476e2b766f746e656f66544141414141414542494b6762317755414141414146366b55336b34656b47484b57524e6241317256357452356b455644564e4348415163584667415578347046636c4e56676f31575741644e3153594e583874706854414243477343527a424541694238512b41366465702b527a393276687932366c5430416a5a6e3450524c6938426639716f422f434d6b30774967502f526a3250575a3367456a556b546c6844524e415130675877544f3774396e2b563134705a366f6c6a554249514d566d7341616f4e5748564d5330324c6654536530653338384c4e697450613155515a794f6968592b464667414241425941464562324769753663344b4f35595730706677336c4770396a4d55554141413d0a763d32";
@@ -626,4 +627,49 @@ mod test {
626627
}
627628
Ok(())
628629
}
630+
631+
#[test]
632+
fn test_v2_sender_builder() {
633+
let address = Address::from_str("2N47mmrWXsNBvQR6k78hWJoTji57zXwNcU7")
634+
.expect("valid address")
635+
.assume_checked();
636+
let directory = EXAMPLE_URL.clone();
637+
let ohttp_keys = OhttpKeys(
638+
ohttp::KeyConfig::new(KEY_ID, KEM, Vec::from(SYMMETRIC)).expect("valid key config"),
639+
);
640+
let pj_uri = Receiver::create_session(address.clone(), directory, ohttp_keys, None)
641+
.save(&NoopSessionPersister::default())
642+
.expect("receiver should succeed")
643+
.pj_uri();
644+
let req_ctx = SenderBuilder::new(PARSED_ORIGINAL_PSBT.clone(), pj_uri.clone())
645+
.build_recommended(FeeRate::BROADCAST_MIN)
646+
.save(&NoopSessionPersister::default())
647+
.expect("sender should succeed");
648+
// v2 senders may always override the receiver's `pjos` parameter to enable output
649+
// substitution
650+
assert_eq!(req_ctx.v1.output_substitution, OutputSubstitution::Enabled);
651+
assert_eq!(&req_ctx.v1.payee, &address.script_pubkey());
652+
let fee_contribution = req_ctx.v1.fee_contribution.expect("sender should contribute fees");
653+
assert_eq!(fee_contribution.max_amount, Amount::from_sat(91));
654+
assert_eq!(fee_contribution.vout, 0);
655+
assert_eq!(req_ctx.v1.min_fee_rate, FeeRate::from_sat_per_kwu(250));
656+
// ensure that the other builder methods also enable output substitution
657+
let req_ctx = SenderBuilder::new(PARSED_ORIGINAL_PSBT.clone(), pj_uri.clone())
658+
.build_non_incentivizing(FeeRate::BROADCAST_MIN)
659+
.save(&NoopSessionPersister::default())
660+
.expect("sender should succeed");
661+
assert_eq!(req_ctx.v1.output_substitution, OutputSubstitution::Enabled);
662+
let req_ctx = SenderBuilder::new(PARSED_ORIGINAL_PSBT.clone(), pj_uri.clone())
663+
.build_with_additional_fee(Amount::ZERO, Some(0), FeeRate::BROADCAST_MIN, false)
664+
.save(&NoopSessionPersister::default())
665+
.expect("sender should succeed");
666+
assert_eq!(req_ctx.v1.output_substitution, OutputSubstitution::Enabled);
667+
// ensure that a v2 sender may still disable output substitution if they prefer.
668+
let req_ctx = SenderBuilder::new(PARSED_ORIGINAL_PSBT.clone(), pj_uri)
669+
.always_disable_output_substitution()
670+
.build_recommended(FeeRate::BROADCAST_MIN)
671+
.save(&NoopSessionPersister::default())
672+
.expect("sender should succeed");
673+
assert_eq!(req_ctx.v1.output_substitution, OutputSubstitution::Disabled);
674+
}
629675
}

0 commit comments

Comments
 (0)