From ebb53dfaf044d35ba24e79bc4ebd3e2d4e207c14 Mon Sep 17 00:00:00 2001 From: Benalleng Date: Mon, 21 Sep 2026 16:29:30 -0400 Subject: [PATCH 1/3] Add payjoin-ffi mutation tests --- payjoin-ffi/src/receive/mod.rs | 234 +++++++++++++++++++++++++++++++++ payjoin-ffi/src/send/mod.rs | 140 ++++++++++++++++++++ payjoin-ffi/src/uri/mod.rs | 57 ++++++++ payjoin-ffi/src/validation.rs | 87 ++++++++++++ 4 files changed, 518 insertions(+) diff --git a/payjoin-ffi/src/receive/mod.rs b/payjoin-ffi/src/receive/mod.rs index a0e81f90f..a039b7df8 100644 --- a/payjoin-ffi/src/receive/mod.rs +++ b/payjoin-ffi/src/receive/mod.rs @@ -1833,3 +1833,237 @@ 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::*; + + const OHTTP_KEYS_HEX: &str = "01001604ba48c49c3d4a92a3ad00ecc63a024da10ced02180c73ec12d8a7ad2cc91bb483824fe2bee8d28bfe2eb2fc6453bc4d31cd851e8a6540e86c5382af588d370957000400010003"; + const ADDRESS: &str = "tb1q6d3a2w975yny0asuvd9a67ner4nks58ff0q8g4"; + + fn decode_hex(s: &str) -> Vec { + (0..s.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&s[i..i + 2], 16).expect("valid hex")) + .collect() + } + + #[derive(Default)] + struct InMemoryReceiverPersister { + events: std::sync::Mutex>, + } + + impl InMemoryReceiverPersister { + fn push_event(&self, event: String) { + self.events.lock().expect("lock not poisoned").push(event); + } + } + + impl JsonReceiverSessionPersister for InMemoryReceiverPersister { + fn save(&self, event: String) -> Result<(), ForeignError> { + self.push_event(event); + Ok(()) + } + + fn load(&self) -> Result, ForeignError> { + Ok(self.events.lock().expect("lock not poisoned").clone()) + } + + fn close(&self) -> Result<(), ForeignError> { Ok(()) } + } + + fn saved_initialized(persister: Arc) -> Initialized { + ReceiverBuilder::new( + ADDRESS.to_string(), + "https://example.com".to_string(), + Arc::new(OhttpKeys::decode(decode_hex(OHTTP_KEYS_HEX)).expect("valid ohttp keys")), + ) + .expect("valid receiver builder") + .build() + .save(persister) + .expect("receiver session should save") + } + + fn retrieved_original_payload_event() -> String { + let psbt = Psbt::from_str(ORIGINAL_PSBT).expect("valid psbt"); + serde_json::json!({ + "RetrievedOriginalPayload": { + "original": { + "psbt": psbt, + "params": { + "v": 1, + "output_substitution": "Enabled", + "additional_fee_contribution": [182, 0], + "min_fee_rate": 250 + } + }, + "reply_key": null + } + }) + .to_string() + } + + #[test] + fn replayed_active_session_has_no_fallback_tx() { + let persister = Arc::new(InMemoryReceiverPersister::default()); + let _initialized = saved_initialized(persister.clone()); + let result = replay_receiver_event_log(persister).expect("replay should succeed"); + assert!(matches!(result.state(), ReceiveSession::Initialized { .. })); + assert_eq!(result.session_history().fallback_tx(), None); + } + + #[test] + fn replayed_original_payload_exposes_fallback_tx() { + let persister = Arc::new(InMemoryReceiverPersister::default()); + let _initialized = saved_initialized(persister.clone()); + let psbt = Psbt::from_str(ORIGINAL_PSBT).expect("valid psbt"); + let retrieved = retrieved_original_payload_event(); + persister.push_event(retrieved); + persister.push_event(r#"{"CheckedBroadcastSuitability":[]}"#.to_string()); + + let result = replay_receiver_event_log(persister).expect("replay should succeed"); + let expected = + payjoin::bitcoin::consensus::encode::serialize(&psbt.extract_tx_unchecked_fee_rate()); + match result.state() { + ReceiveSession::MaybeInputsOwned { inner } => { + assert_eq!(inner.extract_tx_to_schedule_broadcast(), expected) + } + _ => panic!("expected MaybeInputsOwned"), + } + assert_eq!(result.session_history().fallback_tx(), Some(expected)); + } + + #[test] + fn cancelled_session_yields_pending_fallback_tx() { + let persister = Arc::new(InMemoryReceiverPersister::default()); + let _initialized = saved_initialized(persister.clone()); + let psbt = Psbt::from_str(ORIGINAL_PSBT).expect("valid psbt"); + let retrieved = retrieved_original_payload_event(); + persister.push_event(retrieved); + persister.push_event(r#"{"CheckedBroadcastSuitability":[]}"#.to_string()); + persister.push_event(r#""Cancelled""#.to_string()); + + let result = replay_receiver_event_log(persister).expect("replay should succeed"); + let expected = + payjoin::bitcoin::consensus::encode::serialize(&psbt.extract_tx_unchecked_fee_rate()); + match result.state() { + ReceiveSession::ReceiverPendingFallback { inner } => { + assert_eq!(inner.fallback_tx(), expected) + } + _ => panic!("expected ReceiverPendingFallback"), + } + } + + struct NotOwnedInput; + impl IsInputOwned for NotOwnedInput { + fn callback(&self, _outpoint: OutPoint) -> Result { Ok(false) } + } + + struct NotSeenOutput; + impl IsOutputKnown for NotSeenOutput { + fn callback(&self, _outpoint: OutPoint) -> Result { Ok(false) } + } + + struct OwnsEveryScript; + impl IsScriptOwned for OwnsEveryScript { + fn callback(&self, _script: Vec) -> Result { Ok(true) } + } + + struct IdentitySigner; + impl ProcessPsbt for IdentitySigner { + fn callback(&self, psbt: String) -> Result { Ok(psbt) } + } + + fn contributed_p2wsh_input() -> Arc { + let witness_script = vec![0x51]; + use payjoin::bitcoin::hashes::Hash as _; + let mut hash_engine = payjoin::bitcoin::hashes::sha256::Hash::engine(); + payjoin::bitcoin::hashes::HashEngine::input(&mut hash_engine, &witness_script); + let witness_script_hash = payjoin::bitcoin::WScriptHash::from( + payjoin::bitcoin::hashes::sha256::Hash::from_engine(hash_engine), + ); + let script_pubkey = + payjoin::bitcoin::ScriptBuf::new_p2wsh(&witness_script_hash).into_bytes(); + let txin = TxIn { + previous_output: OutPoint { + txid: "0000000000000000000000000000000000000000000000000000000000000000" + .to_string(), + vout: 0, + }, + script_sig: Vec::new(), + sequence: 0xFFFFFFFF, + witness: vec![witness_script.clone()], + }; + let psbtin = PsbtInput { + witness_utxo: Some(TxOut { value_sat: 49_999, script_pubkey }), + redeem_script: Some(vec![0x51]), + witness_script: Some(witness_script), + }; + Arc::new(InputPair::new(txin, psbtin, None).expect("valid input pair")) + } + + #[test] + fn proposal_psbt_preserves_contributed_input_fields() { + let persister = Arc::new(InMemoryReceiverPersister::default()); + let _initialized = saved_initialized(persister.clone()); + let retrieved = retrieved_original_payload_event(); + persister.push_event(retrieved); + persister.push_event(r#"{"CheckedBroadcastSuitability":[]}"#.to_string()); + + let state = replay_receiver_event_log(persister.clone()).expect("replay should succeed"); + let maybe_inputs_owned = match state.state() { + ReceiveSession::MaybeInputsOwned { inner } => inner, + _ => panic!("expected MaybeInputsOwned"), + }; + let maybe_inputs_seen = maybe_inputs_owned + .check_inputs_not_owned(Arc::new(NotOwnedInput)) + .save(persister.clone()) + .expect("inputs should not be owned"); + let outputs_unknown = maybe_inputs_seen + .check_no_inputs_seen_before(Arc::new(NotSeenOutput)) + .save(persister.clone()) + .expect("inputs should not be seen before"); + let wants_outputs = outputs_unknown + .identify_receiver_outputs(Arc::new(OwnsEveryScript)) + .save(persister.clone()) + .expect("outputs should be identified"); + let wants_inputs = + wants_outputs.commit_outputs().save(persister.clone()).expect("outputs should commit"); + let wants_inputs = wants_inputs + .contribute_inputs(vec![contributed_p2wsh_input()]) + .expect("inputs should contribute"); + let wants_fee_range = + wants_inputs.commit_inputs().save(persister.clone()).expect("inputs should commit"); + let provisional = wants_fee_range + .apply_fee_range(None, None) + .expect("fee range should apply") + .save(persister.clone()) + .expect("proposal should save"); + + let psbt_to_sign = Psbt::from_str(&provisional.psbt_to_sign()) + .expect("psbt_to_sign should be a valid psbt"); + let contributed = psbt_to_sign + .inputs + .iter() + .find(|input| input.witness_script.is_some()) + .expect("contributed input should keep its witness script"); + assert_eq!( + contributed.witness_script.as_ref().expect("witness script").as_bytes(), + &[0x51] + ); + assert_eq!(contributed.redeem_script.as_ref().expect("redeem script").as_bytes(), &[0x51]); + assert_eq!(contributed.witness_utxo.as_ref().expect("witness utxo").value.to_sat(), 49_999); + + let proposal = provisional + .finalize_proposal(Arc::new(IdentitySigner)) + .save(persister) + .expect("proposal should finalize"); + let finalized = Psbt::from_str(&proposal.psbt()).expect("psbt should be a valid psbt"); + assert_eq!( + finalized.extract_tx_unchecked_fee_rate().compute_txid(), + psbt_to_sign.extract_tx_unchecked_fee_rate().compute_txid() + ); + } +} diff --git a/payjoin-ffi/src/send/mod.rs b/payjoin-ffi/src/send/mod.rs index cae799714..f2dbd9b7b 100644 --- a/payjoin-ffi/src/send/mod.rs +++ b/payjoin-ffi/src/send/mod.rs @@ -882,4 +882,144 @@ mod tests { SenderBuilder::new(ORIGINAL_PSBT.to_string(), pj_uri(V2_PJ_URI)) .expect("v2 URI must be accepted"); } + + const OHTTP_KEYS_HEX: &str = "01001604ba48c49c3d4a92a3ad00ecc63a024da10ced02180c73ec12d8a7ad2cc91bb483824fe2bee8d28bfe2eb2fc6453bc4d31cd851e8a6540e86c5382af588d370957000400010003"; + + fn decode_hex(s: &str) -> Vec { + (0..s.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&s[i..i + 2], 16).expect("valid hex")) + .collect() + } + + #[derive(Default)] + struct InMemorySenderPersister { + events: std::sync::Mutex>, + } + + impl InMemorySenderPersister { + fn push_event(&self, event: String) { + self.events.lock().expect("lock not poisoned").push(event); + } + } + + impl JsonSenderSessionPersister for InMemorySenderPersister { + fn save(&self, event: String) -> Result<(), ForeignError> { + self.push_event(event); + Ok(()) + } + + fn load(&self) -> Result, ForeignError> { + Ok(self.events.lock().expect("lock not poisoned").clone()) + } + + fn close(&self) -> Result<(), ForeignError> { Ok(()) } + } + + fn receiver_pj_uri() -> Arc { + #[derive(Default)] + struct InMemoryReceiverPersister { + events: std::sync::Mutex>, + } + + impl crate::receive::JsonReceiverSessionPersister for InMemoryReceiverPersister { + fn save(&self, event: String) -> Result<(), ForeignError> { + self.events.lock().expect("lock not poisoned").push(event); + Ok(()) + } + + fn load(&self) -> Result, ForeignError> { + Ok(self.events.lock().expect("lock not poisoned").clone()) + } + + fn close(&self) -> Result<(), ForeignError> { Ok(()) } + } + + let persister = Arc::new(InMemoryReceiverPersister::default()); + let initialized = crate::receive::ReceiverBuilder::new( + "2MuyMrZHkbHbfjudmKUy45dU4P17pjG2szK".to_string(), + "https://example.com".to_string(), + Arc::new( + crate::ohttp::OhttpKeys::decode(decode_hex(OHTTP_KEYS_HEX)) + .expect("valid ohttp keys"), + ), + ) + .expect("valid receiver builder") + .build() + .save(persister) + .expect("receiver session should save"); + Arc::new(initialized.pj_uri()) + } + + fn saved_sender(persister: Arc) -> WithReplyKey { + SenderBuilder::new(ORIGINAL_PSBT.to_string(), receiver_pj_uri()) + .expect("valid sender builder") + .build_recommended(1000) + .expect("buildable sender") + .save(persister) + .expect("sender session should save") + } + + fn expected_fallback_tx() -> Vec { + let psbt = payjoin::bitcoin::psbt::Psbt::from_str(ORIGINAL_PSBT).expect("valid psbt"); + payjoin::bitcoin::consensus::encode::serialize(&psbt.extract_tx_unchecked_fee_rate()) + } + + #[test] + fn cancelled_sender_session_exposes_fallback_tx() { + let persister = Arc::new(InMemorySenderPersister::default()); + let pending_fallback = + saved_sender(persister.clone()).cancel().save(persister.clone()).expect("cancel"); + assert_eq!(pending_fallback.fallback_tx(), expected_fallback_tx()); + + let result = replay_sender_event_log(persister).expect("replay should succeed"); + match result.state() { + SendSession::SenderPendingFallback { inner } => { + assert_eq!(inner.fallback_tx(), expected_fallback_tx()) + } + _ => panic!("expected SenderPendingFallback"), + } + assert_eq!(result.session_history().fallback_tx(), expected_fallback_tx()); + assert_eq!(result.session_history().pj_param().receiver_pubkey().len(), 33); + } + + #[test] + fn closed_sender_session_reports_aborted_outcome() { + let persister = Arc::new(InMemorySenderPersister::default()); + let pending_fallback = + saved_sender(persister.clone()).cancel().save(persister.clone()).expect("cancel"); + pending_fallback.close().save(persister.clone()).expect("close should save"); + + let result = replay_sender_event_log(persister).expect("replay should succeed"); + match result.state() { + SendSession::Closed { inner } => { + assert!(!inner.is_success()); + assert!(inner.is_aborted()); + assert_eq!(inner.success_psbt_base64(), None); + } + _ => panic!("expected Closed"), + } + } + + #[test] + fn successful_sender_session_reports_psbt() { + let persister = Arc::new(InMemorySenderPersister::default()); + let _ = saved_sender(persister.clone()); + let psbt = payjoin::bitcoin::psbt::Psbt::from_str(ORIGINAL_PSBT).expect("valid psbt"); + let success_event = serde_json::to_string(&payjoin::send::v2::SessionEvent::Closed( + payjoin::send::v2::SessionOutcome::Success(psbt.clone()), + )) + .expect("serializable event"); + persister.push_event(success_event); + + let result = replay_sender_event_log(persister).expect("replay should succeed"); + match result.state() { + SendSession::Closed { inner } => { + assert!(inner.is_success()); + assert!(!inner.is_aborted()); + assert_eq!(inner.success_psbt_base64(), Some(psbt.to_string())); + } + _ => panic!("expected Closed"), + } + } } diff --git a/payjoin-ffi/src/uri/mod.rs b/payjoin-ffi/src/uri/mod.rs index 0e6d8c439..a69ba4b87 100644 --- a/payjoin-ffi/src/uri/mod.rs +++ b/payjoin-ffi/src/uri/mod.rs @@ -91,3 +91,60 @@ impl Url { pub fn query(&self) -> Option { self.0.query().map(|x| x.to_string()) } pub fn as_string(&self) -> String { self.0.to_string() } } + +#[cfg(test)] +mod tests { + use super::*; + + const ADDRESS: &str = "tb1q6d3a2w975yny0asuvd9a67ner4nks58ff0q8g4"; + const URI: &str = "bitcoin:tb1q6d3a2w975yny0asuvd9a67ner4nks58ff0q8g4?amount=0.00000001&label=hello&message=world&pj=https://example.com/pj"; + + #[test] + fn uri_accessors_expose_parsed_fields() { + let uri = Uri::parse(URI.to_string()).expect("valid uri"); + assert_eq!(uri.address(), ADDRESS); + assert_eq!(uri.amount_sats(), Some(1)); + assert_eq!(uri.label(), Some("hello".to_string())); + assert_eq!(uri.message(), Some("world".to_string())); + assert!(uri.as_string().contains("amount=0.00000001")); + } + + #[test] + fn uri_without_optional_fields_returns_none() { + let uri = Uri::parse(format!("bitcoin:{ADDRESS}")).expect("valid uri"); + assert_eq!(uri.amount_sats(), None); + assert_eq!(uri.label(), None); + assert_eq!(uri.message(), None); + } + + #[test] + fn pj_uri_accessors_expose_parsed_fields() { + let pj_uri = + Uri::parse(URI.to_string()).expect("valid uri").check_pj_supported().expect("pj uri"); + assert_eq!(pj_uri.address(), ADDRESS); + assert_eq!(pj_uri.amount_sats(), Some(1)); + assert_eq!(pj_uri.pj_endpoint(), "https://example.com/pj"); + assert!(pj_uri.as_string().contains("pj=")); + } + + #[test] + fn pj_uri_set_amount_sats_updates_amount() { + let pj_uri = + Uri::parse(URI.to_string()).expect("valid uri").check_pj_supported().expect("pj uri"); + let updated = pj_uri.set_amount_sats(1000).expect("valid amount"); + assert_eq!(updated.amount_sats(), Some(1000)); + } + + #[test] + fn url_accessors_expose_parsed_fields() { + let url = Url::parse("https://example.com/pj?a=b&c=d".to_string()).expect("valid url"); + assert_eq!(url.query(), Some("a=b&c=d".to_string())); + assert_eq!(url.as_string(), "https://example.com/pj?a=b&c=d"); + } + + #[test] + fn url_without_query_returns_none() { + let url = Url::parse("https://example.com/pj".to_string()).expect("valid url"); + assert_eq!(url.query(), None); + } +} diff --git a/payjoin-ffi/src/validation.rs b/payjoin-ffi/src/validation.rs index dc0d04181..23da21297 100644 --- a/payjoin-ffi/src/validation.rs +++ b/payjoin-ffi/src/validation.rs @@ -132,3 +132,90 @@ pub(crate) fn validate_expiration_secs(seconds: u64) -> Result(), MAX_WITNESS_BYTES); + assert!(validate_witness_stack(&at_limit).is_ok()); + let over_limit = vec![vec![0; MAX_SCRIPT_BYTES]; MAX_WITNESS_BYTES / MAX_SCRIPT_BYTES + 1]; + let err = validate_witness_stack(&over_limit).expect_err("should exceed total size"); + assert!(matches!(err, FfiValidationError::WitnessTooLarge { .. })); + } + + #[test] + fn weight_units_rejects_zero_and_above_max_block() { + assert!(validate_weight_units(0).is_err()); + let max_wu = Weight::MAX_BLOCK.to_wu(); + assert_eq!(validate_weight_units(1).expect("valid weight"), Weight::from_wu(1)); + assert!(validate_weight_units(max_wu).is_ok()); + let err = validate_weight_units(max_wu + 1).expect_err("should exceed max block weight"); + assert!(matches!(err, FfiValidationError::WeightOutOfRange { .. })); + } + + #[test] + fn expiration_secs_accepts_u32_range_only() { + assert_eq!( + validate_expiration_secs(u32::MAX as u64).expect("valid expiration"), + Duration::from_secs(u32::MAX as u64) + ); + assert!(validate_expiration_secs(0).is_ok()); + let err = validate_expiration_secs(u32::MAX as u64 + 1) + .expect_err("should exceed max expiration"); + assert!(matches!(err, FfiValidationError::ExpirationOutOfRange { .. })); + } +} From d411b4383df01f67e0f3c976568cc767c31774ce Mon Sep 17 00:00:00 2001 From: Benalleng Date: Mon, 21 Sep 2026 16:30:32 -0400 Subject: [PATCH 2/3] Add payjoin-ffi to mutants search area --- .cargo/mutants.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.cargo/mutants.toml b/.cargo/mutants.toml index bd443b75a..8fa5bac0a 100644 --- a/.cargo/mutants.toml +++ b/.cargo/mutants.toml @@ -1,7 +1,7 @@ additional_cargo_args = ["--all-features"] gitignore = true -examine_globs = ["payjoin/src/**/*.rs"] -exclude_globs = [] +examine_globs = ["payjoin/src/**/*.rs", "payjoin-ffi/src/**/*.rs"] +exclude_globs = ["payjoin-ffi/src/test_utils.rs"] exclude_re = [ "impl\\s+(std::fmt::|core::fmt::|fmt::)?(Display|Debug)", "deserialize", From d7dad43fc1d791282834b791536d0e1c63adb34f Mon Sep 17 00:00:00 2001 From: Benalleng Date: Mon, 21 Sep 2026 16:30:56 -0400 Subject: [PATCH 3/3] Add payjoin ffi test coverage tracking --- contrib/coverage.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contrib/coverage.sh b/contrib/coverage.sh index ecd02af03..ab9ca416f 100755 --- a/contrib/coverage.sh +++ b/contrib/coverage.sh @@ -8,7 +8,7 @@ use_lockfile Cargo-recent.lock # remove artifacts that may affect the coverage results cargo llvm-cov clean --workspace # exclude payjoin-ffi because bindings are tested in their native language and fuzz because these tests are not coverage worthy -cargo llvm-cov --locked --no-report --workspace --all-features --exclude payjoin-ffi --exclude payjoin-fuzz +cargo llvm-cov --locked --no-report --workspace --all-features --exclude payjoin-fuzz # Explicitly run payjoin-cli v1 e2e tests cargo llvm-cov --locked --no-report --package payjoin-cli --no-default-features --features=v1,_manual-tls # generate report without tests