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() {