Skip to content

Commit 6992a2a

Browse files
committed
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.
1 parent 27cc8a1 commit 6992a2a

6 files changed

Lines changed: 194 additions & 2 deletions

File tree

payjoin-ffi/dart/test/test_payjoin_unit_test.dart

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,5 +268,35 @@ void main() {
268268
throwsA(isA<payjoin.SenderInputException>()),
269269
);
270270
});
271+
272+
test("Validation sender builder exposes invalid original input index", () {
273+
final receiverPersister = InMemoryReceiverPersister("1");
274+
final receiver = payjoin.ReceiverBuilder(
275+
address: "2MuyMrZHkbHbfjudmKUy45dU4P17pjG2szK",
276+
directory: "https://example.com",
277+
ohttpKeys: payjoin.OhttpKeys.decode(
278+
bytes: Uint8List.fromList(
279+
hex.decode(
280+
"01001604ba48c49c3d4a92a3ad00ecc63a024da10ced02180c73ec12d8a7ad2cc91bb483824fe2bee8d28bfe2eb2fc6453bc4d31cd851e8a6540e86c5382af588d370957000400010003",
281+
),
282+
),
283+
),
284+
).build().save(persister: receiverPersister);
285+
final uri = receiver.pjUri();
286+
287+
try {
288+
payjoin.SenderBuilder(
289+
psbt: payjoin.invalidOriginalInputPsbt(),
290+
uri: uri,
291+
).buildNonIncentivizing(minFeeRate: 1000);
292+
fail("expected sender build error");
293+
} on payjoin.BuildSenderInputException catch (e) {
294+
expect(e.v0.invalidOriginalInputIndex(), 0);
295+
expect(
296+
e.v0.invalidOriginalInputMessage(),
297+
"invalid previous transaction output",
298+
);
299+
}
300+
});
271301
});
272302
}

payjoin-ffi/python/test/test_payjoin_unit_test.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,35 @@ def test_sender_builder_rejects_bad_psbt(self):
229229
with self.assertRaises(payjoin.SenderInputError):
230230
payjoin.SenderBuilder("not-a-psbt", uri)
231231

232+
def test_sender_builder_exposes_invalid_original_input_index(self):
233+
receiver_persister = InMemoryReceiverPersister(1)
234+
receiver = (
235+
payjoin.ReceiverBuilder(
236+
"2MuyMrZHkbHbfjudmKUy45dU4P17pjG2szK",
237+
"https://example.com",
238+
payjoin.OhttpKeys.decode(
239+
bytes.fromhex(
240+
"01001604ba48c49c3d4a92a3ad00ecc63a024da10ced02180c73ec12d8a7ad2cc91bb483824fe2bee8d28bfe2eb2fc6453bc4d31cd851e8a6540e86c5382af588d370957000400010003"
241+
)
242+
),
243+
)
244+
.build()
245+
.save(receiver_persister)
246+
)
247+
uri = receiver.pj_uri()
248+
249+
with self.assertRaises(payjoin.SenderInputError.Build) as ctx:
250+
payjoin.SenderBuilder(payjoin.invalid_original_input_psbt(), uri).build_non_incentivizing(
251+
1000
252+
)
253+
254+
error = ctx.exception[0]
255+
self.assertEqual(error.invalid_original_input_index(), 0)
256+
self.assertEqual(
257+
error.invalid_original_input_message(),
258+
"invalid previous transaction output",
259+
)
260+
232261

233262
if __name__ == "__main__":
234263
unittest.main()

payjoin-ffi/src/send/error.rs

Lines changed: 80 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,41 @@ use crate::error::{FfiValidationError, ImplementationError};
1212
#[error("Error initializing the sender: {msg}")]
1313
pub struct BuildSenderError {
1414
msg: String,
15+
invalid_original_input_index: Option<u64>,
16+
invalid_original_input_message: Option<String>,
1517
}
1618

1719
impl From<PsbtParseError> for BuildSenderError {
18-
fn from(value: PsbtParseError) -> Self { BuildSenderError { msg: value.to_string() } }
20+
fn from(value: PsbtParseError) -> Self {
21+
BuildSenderError {
22+
msg: value.to_string(),
23+
invalid_original_input_index: None,
24+
invalid_original_input_message: None,
25+
}
26+
}
1927
}
2028

2129
impl From<send::BuildSenderError> for BuildSenderError {
22-
fn from(value: send::BuildSenderError) -> Self { BuildSenderError { msg: value.to_string() } }
30+
fn from(value: send::BuildSenderError) -> Self {
31+
BuildSenderError {
32+
msg: value.to_string(),
33+
invalid_original_input_index: value
34+
.invalid_original_input_index()
35+
.map(|index| index as u64),
36+
invalid_original_input_message: value.invalid_original_input_message(),
37+
}
38+
}
39+
}
40+
41+
#[uniffi::export]
42+
impl BuildSenderError {
43+
pub fn message(&self) -> String { self.msg.clone() }
44+
45+
pub fn invalid_original_input_index(&self) -> Option<u64> { self.invalid_original_input_index }
46+
47+
pub fn invalid_original_input_message(&self) -> Option<String> {
48+
self.invalid_original_input_message.clone()
49+
}
2350
}
2451

2552
/// FFI-visible PSBT parsing error surfaced at the sender boundary.
@@ -195,3 +222,54 @@ where
195222
SenderPersistedError::Unexpected
196223
}
197224
}
225+
226+
#[cfg(all(test, feature = "_test-utils"))]
227+
mod tests {
228+
use std::str::FromStr;
229+
use std::sync::Arc;
230+
231+
use payjoin::bitcoin::hex::FromHex;
232+
233+
use crate::send::{SenderBuilder, SenderInputError};
234+
use crate::test_utils::invalid_original_input_psbt;
235+
use crate::uri::PjUri;
236+
237+
#[test]
238+
fn test_build_sender_error_exposes_invalid_input_index() {
239+
let address =
240+
payjoin::bitcoin::Address::from_str("tb1q6d3a2w975yny0asuvd9a67ner4nks58ff0q8g4")
241+
.expect("address should parse")
242+
.assume_checked();
243+
let ohttp_keys = payjoin::OhttpKeys::decode(
244+
&<Vec<u8> as FromHex>::from_hex(
245+
"01001604ba48c49c3d4a92a3ad00ecc63a024da10ced02180c73ec12d8a7ad2cc91bb483824fe2bee8d28bfe2eb2fc6453bc4d31cd851e8a6540e86c5382af588d370957000400010003",
246+
)
247+
.expect("hex fixture should decode"),
248+
)
249+
.expect("OHTTP keys should decode");
250+
let receiver = payjoin::receive::v2::ReceiverBuilder::new(
251+
address,
252+
"https://example.com".to_string(),
253+
ohttp_keys,
254+
)
255+
.expect("receiver builder should succeed")
256+
.build()
257+
.save(&payjoin::persist::NoopSessionPersister::default())
258+
.expect("no-op persister should not fail");
259+
let uri = Arc::new(PjUri::from(receiver.pj_uri()));
260+
261+
let error = SenderBuilder::new(invalid_original_input_psbt(), uri)
262+
.expect("PSBT should parse")
263+
.build_non_incentivizing(1000);
264+
265+
let Err(SenderInputError::Build(error)) = error else {
266+
panic!("expected sender build error");
267+
};
268+
269+
assert_eq!(error.invalid_original_input_index(), Some(0));
270+
assert_eq!(
271+
error.invalid_original_input_message(),
272+
Some("invalid previous transaction output".to_string())
273+
);
274+
}
275+
}

payjoin-ffi/src/test_utils.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
use std::io;
2+
use std::str::FromStr;
23
use std::sync::Arc;
34

45
use lazy_static::lazy_static;
6+
use payjoin::bitcoin::Psbt;
57
use payjoin_test_utils::corepc_node::AddressType;
68
use payjoin_test_utils::{
79
corepc_node, EXAMPLE_URL, INVALID_PSBT, ORIGINAL_PSBT, PAYJOIN_PROPOSAL,
@@ -204,6 +206,14 @@ pub fn original_psbt() -> String { ORIGINAL_PSBT.to_string() }
204206
#[uniffi::export]
205207
pub fn invalid_psbt() -> String { INVALID_PSBT.to_string() }
206208

209+
#[uniffi::export]
210+
pub fn invalid_original_input_psbt() -> String {
211+
let mut psbt = Psbt::from_str(ORIGINAL_PSBT).expect("original PSBT fixture should parse");
212+
psbt.inputs[0].witness_utxo = None;
213+
psbt.inputs[0].non_witness_utxo = None;
214+
psbt.to_string()
215+
}
216+
207217
#[uniffi::export]
208218
pub fn payjoin_proposal() -> String { PAYJOIN_PROPOSAL.to_string() }
209219

payjoin/src/core/psbt/mod.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -347,6 +347,12 @@ pub struct PsbtInputsError {
347347
error: InternalPsbtInputError,
348348
}
349349

350+
impl PsbtInputsError {
351+
pub(crate) fn index(&self) -> usize { self.index }
352+
353+
pub(crate) fn error_message(&self) -> String { self.error.to_string() }
354+
}
355+
350356
impl fmt::Display for PsbtInputsError {
351357
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
352358
write!(f, "invalid PSBT input #{}", self.index)

payjoin/src/core/send/error.rs

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,26 @@ impl From<crate::psbt::AddressTypeError> for BuildSenderError {
4040
}
4141
}
4242

43+
impl BuildSenderError {
44+
/// Returns the original PSBT input index when sender construction failed because one input
45+
/// was malformed.
46+
pub fn invalid_original_input_index(&self) -> Option<usize> {
47+
match &self.0 {
48+
InternalBuildSenderError::InvalidOriginalInput(error) => Some(error.index()),
49+
_ => None,
50+
}
51+
}
52+
53+
/// Returns the nested invalid-input message when sender construction failed because one input
54+
/// was malformed.
55+
pub fn invalid_original_input_message(&self) -> Option<String> {
56+
match &self.0 {
57+
InternalBuildSenderError::InvalidOriginalInput(error) => Some(error.error_message()),
58+
_ => None,
59+
}
60+
}
61+
}
62+
4363
impl fmt::Display for BuildSenderError {
4464
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
4565
use InternalBuildSenderError::*;
@@ -403,9 +423,28 @@ impl WellKnownError {
403423

404424
#[cfg(test)]
405425
mod tests {
426+
use payjoin_test_utils::PARSED_ORIGINAL_PSBT;
406427
use serde_json::json;
407428

408429
use super::*;
430+
use crate::psbt::PsbtExt;
431+
432+
#[test]
433+
fn test_build_sender_error_invalid_original_input_accessors() {
434+
let mut psbt = PARSED_ORIGINAL_PSBT.clone();
435+
psbt.inputs[0].witness_utxo = None;
436+
psbt.inputs[0].non_witness_utxo = None;
437+
438+
let invalid_input = psbt.validate_input_utxos().expect_err("PSBT should be invalid");
439+
let error =
440+
BuildSenderError::from(InternalBuildSenderError::InvalidOriginalInput(invalid_input));
441+
442+
assert_eq!(error.invalid_original_input_index(), Some(0));
443+
assert_eq!(
444+
error.invalid_original_input_message(),
445+
Some("invalid previous transaction output".to_string())
446+
);
447+
}
409448

410449
#[test]
411450
fn test_parse_json() {

0 commit comments

Comments
 (0)