From 3cc1eb9f7d68af66ca285ec88b1cc05a486c4468 Mon Sep 17 00:00:00 2001 From: chavic Date: Fri, 20 Mar 2026 13:07:16 +0200 Subject: [PATCH] Expose Sender Input Index Preserve the invalid-original-input index when sender build\nfailures cross the FFI boundary.\n\nThe core sender already tracks which PSBT input failed UTXO\nvalidation, but BuildSenderError was flattened to a display\nstring in payjoin-ffi. That left bindings unable to point\ncallers at the offending input without parsing error text.\n\nThis adds small core accessors for the invalid input index and\nnested message, exposes matching getters on the FFI\nBuildSenderError object, and adds a malformed-PSBT test\nfixture plus Rust, Python, and Dart regressions for the\nnon-incentivizing sender path. --- .../dart/test/test_payjoin_unit_test.dart | 30 +++++++ .../python/test/test_payjoin_unit_test.py | 29 +++++++ payjoin-ffi/src/send/error.rs | 82 ++++++++++++++++++- payjoin-ffi/src/test_utils.rs | 10 +++ payjoin/src/core/psbt/mod.rs | 6 ++ payjoin/src/core/send/error.rs | 39 +++++++++ 6 files changed, 194 insertions(+), 2 deletions(-) diff --git a/payjoin-ffi/dart/test/test_payjoin_unit_test.dart b/payjoin-ffi/dart/test/test_payjoin_unit_test.dart index 3b56b729d..52e21e6cb 100644 --- a/payjoin-ffi/dart/test/test_payjoin_unit_test.dart +++ b/payjoin-ffi/dart/test/test_payjoin_unit_test.dart @@ -268,5 +268,35 @@ void main() { throwsA(isA()), ); }); + + test("Validation sender builder exposes invalid original input index", () { + final receiverPersister = InMemoryReceiverPersister("1"); + final receiver = payjoin.ReceiverBuilder( + address: "2MuyMrZHkbHbfjudmKUy45dU4P17pjG2szK", + directory: "https://example.com", + ohttpKeys: payjoin.OhttpKeys.decode( + bytes: Uint8List.fromList( + hex.decode( + "01001604ba48c49c3d4a92a3ad00ecc63a024da10ced02180c73ec12d8a7ad2cc91bb483824fe2bee8d28bfe2eb2fc6453bc4d31cd851e8a6540e86c5382af588d370957000400010003", + ), + ), + ), + ).build().save(persister: receiverPersister); + final uri = receiver.pjUri(); + + try { + payjoin.SenderBuilder( + psbt: payjoin.invalidOriginalInputPsbt(), + uri: uri, + ).buildNonIncentivizing(minFeeRate: 1000); + fail("expected sender build error"); + } on payjoin.BuildSenderInputException catch (e) { + expect(e.v0.invalidOriginalInputIndex(), 0); + expect( + e.v0.invalidOriginalInputMessage(), + "invalid previous transaction output", + ); + } + }); }); } diff --git a/payjoin-ffi/python/test/test_payjoin_unit_test.py b/payjoin-ffi/python/test/test_payjoin_unit_test.py index f4eab2a04..594f703a7 100644 --- a/payjoin-ffi/python/test/test_payjoin_unit_test.py +++ b/payjoin-ffi/python/test/test_payjoin_unit_test.py @@ -229,6 +229,35 @@ def test_sender_builder_rejects_bad_psbt(self): with self.assertRaises(payjoin.SenderInputError): payjoin.SenderBuilder("not-a-psbt", uri) + def test_sender_builder_exposes_invalid_original_input_index(self): + receiver_persister = InMemoryReceiverPersister(1) + receiver = ( + payjoin.ReceiverBuilder( + "2MuyMrZHkbHbfjudmKUy45dU4P17pjG2szK", + "https://example.com", + payjoin.OhttpKeys.decode( + bytes.fromhex( + "01001604ba48c49c3d4a92a3ad00ecc63a024da10ced02180c73ec12d8a7ad2cc91bb483824fe2bee8d28bfe2eb2fc6453bc4d31cd851e8a6540e86c5382af588d370957000400010003" + ) + ), + ) + .build() + .save(receiver_persister) + ) + uri = receiver.pj_uri() + + with self.assertRaises(payjoin.SenderInputError.Build) as ctx: + payjoin.SenderBuilder( + payjoin.invalid_original_input_psbt(), uri + ).build_non_incentivizing(1000) + + error = ctx.exception[0] + self.assertEqual(error.invalid_original_input_index(), 0) + self.assertEqual( + error.invalid_original_input_message(), + "invalid previous transaction output", + ) + if __name__ == "__main__": unittest.main() diff --git a/payjoin-ffi/src/send/error.rs b/payjoin-ffi/src/send/error.rs index ed5438cde..346d854d5 100644 --- a/payjoin-ffi/src/send/error.rs +++ b/payjoin-ffi/src/send/error.rs @@ -12,14 +12,41 @@ use crate::error::{FfiValidationError, ImplementationError}; #[error("Error initializing the sender: {msg}")] pub struct BuildSenderError { msg: String, + invalid_original_input_index: Option, + invalid_original_input_message: Option, } impl From for BuildSenderError { - fn from(value: PsbtParseError) -> Self { BuildSenderError { msg: value.to_string() } } + fn from(value: PsbtParseError) -> Self { + BuildSenderError { + msg: value.to_string(), + invalid_original_input_index: None, + invalid_original_input_message: None, + } + } } impl From for BuildSenderError { - fn from(value: send::BuildSenderError) -> Self { BuildSenderError { msg: value.to_string() } } + fn from(value: send::BuildSenderError) -> Self { + BuildSenderError { + msg: value.to_string(), + invalid_original_input_index: value + .invalid_original_input_index() + .map(|index| index as u64), + invalid_original_input_message: value.invalid_original_input_message(), + } + } +} + +#[uniffi::export] +impl BuildSenderError { + pub fn message(&self) -> String { self.msg.clone() } + + pub fn invalid_original_input_index(&self) -> Option { self.invalid_original_input_index } + + pub fn invalid_original_input_message(&self) -> Option { + self.invalid_original_input_message.clone() + } } /// FFI-visible PSBT parsing error surfaced at the sender boundary. @@ -195,3 +222,54 @@ where SenderPersistedError::Unexpected } } + +#[cfg(all(test, feature = "_test-utils"))] +mod tests { + use std::str::FromStr; + use std::sync::Arc; + + use payjoin::bitcoin::hex::FromHex; + + use crate::send::{SenderBuilder, SenderInputError}; + use crate::test_utils::invalid_original_input_psbt; + use crate::uri::PjUri; + + #[test] + fn test_build_sender_error_exposes_invalid_input_index() { + let address = + payjoin::bitcoin::Address::from_str("tb1q6d3a2w975yny0asuvd9a67ner4nks58ff0q8g4") + .expect("address should parse") + .assume_checked(); + let ohttp_keys = payjoin::OhttpKeys::decode( + & as FromHex>::from_hex( + "01001604ba48c49c3d4a92a3ad00ecc63a024da10ced02180c73ec12d8a7ad2cc91bb483824fe2bee8d28bfe2eb2fc6453bc4d31cd851e8a6540e86c5382af588d370957000400010003", + ) + .expect("hex fixture should decode"), + ) + .expect("OHTTP keys should decode"); + let receiver = payjoin::receive::v2::ReceiverBuilder::new( + address, + "https://example.com".to_string(), + ohttp_keys, + ) + .expect("receiver builder should succeed") + .build() + .save(&payjoin::persist::NoopSessionPersister::default()) + .expect("no-op persister should not fail"); + let uri = Arc::new(PjUri::from(receiver.pj_uri())); + + let error = SenderBuilder::new(invalid_original_input_psbt(), uri) + .expect("PSBT should parse") + .build_non_incentivizing(1000); + + let Err(SenderInputError::Build(error)) = error else { + panic!("expected sender build error"); + }; + + assert_eq!(error.invalid_original_input_index(), Some(0)); + assert_eq!( + error.invalid_original_input_message(), + Some("invalid previous transaction output".to_string()) + ); + } +} diff --git a/payjoin-ffi/src/test_utils.rs b/payjoin-ffi/src/test_utils.rs index 962d91b1d..a8e87d031 100644 --- a/payjoin-ffi/src/test_utils.rs +++ b/payjoin-ffi/src/test_utils.rs @@ -1,7 +1,9 @@ use std::io; +use std::str::FromStr; use std::sync::Arc; use lazy_static::lazy_static; +use payjoin::bitcoin::Psbt; use payjoin_test_utils::corepc_node::AddressType; use payjoin_test_utils::{ corepc_node, EXAMPLE_URL, INVALID_PSBT, ORIGINAL_PSBT, PAYJOIN_PROPOSAL, @@ -204,6 +206,14 @@ pub fn original_psbt() -> String { ORIGINAL_PSBT.to_string() } #[uniffi::export] pub fn invalid_psbt() -> String { INVALID_PSBT.to_string() } +#[uniffi::export] +pub fn invalid_original_input_psbt() -> String { + let mut psbt = Psbt::from_str(ORIGINAL_PSBT).expect("original PSBT fixture should parse"); + psbt.inputs[0].witness_utxo = None; + psbt.inputs[0].non_witness_utxo = None; + psbt.to_string() +} + #[uniffi::export] pub fn payjoin_proposal() -> String { PAYJOIN_PROPOSAL.to_string() } diff --git a/payjoin/src/core/psbt/mod.rs b/payjoin/src/core/psbt/mod.rs index c02eaef59..87d3cc606 100644 --- a/payjoin/src/core/psbt/mod.rs +++ b/payjoin/src/core/psbt/mod.rs @@ -347,6 +347,12 @@ pub struct PsbtInputsError { error: InternalPsbtInputError, } +impl PsbtInputsError { + pub(crate) fn index(&self) -> usize { self.index } + + pub(crate) fn error_message(&self) -> String { self.error.to_string() } +} + impl fmt::Display for PsbtInputsError { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "invalid PSBT input #{}", self.index) diff --git a/payjoin/src/core/send/error.rs b/payjoin/src/core/send/error.rs index 7e70286b5..560aae8af 100644 --- a/payjoin/src/core/send/error.rs +++ b/payjoin/src/core/send/error.rs @@ -40,6 +40,26 @@ impl From for BuildSenderError { } } +impl BuildSenderError { + /// Returns the original PSBT input index when sender construction failed because one input + /// was malformed. + pub fn invalid_original_input_index(&self) -> Option { + match &self.0 { + InternalBuildSenderError::InvalidOriginalInput(error) => Some(error.index()), + _ => None, + } + } + + /// Returns the nested invalid-input message when sender construction failed because one input + /// was malformed. + pub fn invalid_original_input_message(&self) -> Option { + match &self.0 { + InternalBuildSenderError::InvalidOriginalInput(error) => Some(error.error_message()), + _ => None, + } + } +} + impl fmt::Display for BuildSenderError { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { use InternalBuildSenderError::*; @@ -403,9 +423,28 @@ impl WellKnownError { #[cfg(test)] mod tests { + use payjoin_test_utils::PARSED_ORIGINAL_PSBT; use serde_json::json; use super::*; + use crate::psbt::PsbtExt; + + #[test] + fn test_build_sender_error_invalid_original_input_accessors() { + let mut psbt = PARSED_ORIGINAL_PSBT.clone(); + psbt.inputs[0].witness_utxo = None; + psbt.inputs[0].non_witness_utxo = None; + + let invalid_input = psbt.validate_input_utxos().expect_err("PSBT should be invalid"); + let error = + BuildSenderError::from(InternalBuildSenderError::InvalidOriginalInput(invalid_input)); + + assert_eq!(error.invalid_original_input_index(), Some(0)); + assert_eq!( + error.invalid_original_input_message(), + Some("invalid previous transaction output".to_string()) + ); + } #[test] fn test_parse_json() {