Skip to content

Commit e412f6e

Browse files
committed
Expose Receiver Modification Errors
Add stable kind accessors for receiver output substitution and input contribution failures and preserve the duplicate input outpoint across the FFI boundary. Bindings currently only see opaque error wrappers for these paths, which forces callers to branch on display strings even though core already distinguishes actionable failure classes. This keeps the internal enums private while making receiver integrations able to react to invalid drain scripts, disabled output substitutions, duplicate inputs, and value-too-low contributions.
1 parent 27cc8a1 commit e412f6e

File tree

3 files changed

+347
-10
lines changed

3 files changed

+347
-10
lines changed

payjoin-ffi/src/receive/error.rs

Lines changed: 263 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ use std::sync::Arc;
22

33
use payjoin::receive;
44

5+
use super::PlainOutPoint;
56
use crate::error::{FfiValidationError, ImplementationError};
67
use crate::uri::error::IntoUrlError;
78

@@ -168,10 +169,66 @@ impl From<ProtocolError> for JsonReply {
168169
#[error(transparent)]
169170
pub struct SessionError(#[from] receive::v2::SessionError);
170171

172+
#[derive(Clone, Copy, Debug, PartialEq, Eq, uniffi::Enum)]
173+
pub enum OutputSubstitutionErrorKind {
174+
DecreasedValueWhenDisabled,
175+
ScriptPubKeyChangedWhenDisabled,
176+
NotEnoughOutputs,
177+
InvalidDrainScript,
178+
Other,
179+
}
180+
181+
impl From<receive::OutputSubstitutionErrorKind> for OutputSubstitutionErrorKind {
182+
fn from(value: receive::OutputSubstitutionErrorKind) -> Self {
183+
match value {
184+
receive::OutputSubstitutionErrorKind::DecreasedValueWhenDisabled =>
185+
Self::DecreasedValueWhenDisabled,
186+
receive::OutputSubstitutionErrorKind::ScriptPubKeyChangedWhenDisabled =>
187+
Self::ScriptPubKeyChangedWhenDisabled,
188+
receive::OutputSubstitutionErrorKind::NotEnoughOutputs => Self::NotEnoughOutputs,
189+
receive::OutputSubstitutionErrorKind::InvalidDrainScript => Self::InvalidDrainScript,
190+
_ => Self::Other,
191+
}
192+
}
193+
}
194+
195+
#[derive(Clone, Copy, Debug, PartialEq, Eq, uniffi::Enum)]
196+
pub enum InputContributionErrorKind {
197+
ValueTooLow,
198+
DuplicateInput,
199+
Other,
200+
}
201+
202+
impl From<receive::InputContributionErrorKind> for InputContributionErrorKind {
203+
fn from(value: receive::InputContributionErrorKind) -> Self {
204+
match value {
205+
receive::InputContributionErrorKind::ValueTooLow => Self::ValueTooLow,
206+
receive::InputContributionErrorKind::DuplicateInput => Self::DuplicateInput,
207+
_ => Self::Other,
208+
}
209+
}
210+
}
211+
171212
/// Protocol error raised during output substitution.
172213
#[derive(Debug, thiserror::Error, uniffi::Object)]
173-
#[error(transparent)]
174-
pub struct OutputSubstitutionProtocolError(#[from] receive::OutputSubstitutionError);
214+
#[error("{message}")]
215+
pub struct OutputSubstitutionProtocolError {
216+
kind: OutputSubstitutionErrorKind,
217+
message: String,
218+
}
219+
220+
impl From<receive::OutputSubstitutionError> for OutputSubstitutionProtocolError {
221+
fn from(value: receive::OutputSubstitutionError) -> Self {
222+
Self { kind: value.kind().into(), message: value.to_string() }
223+
}
224+
}
225+
226+
#[uniffi::export]
227+
impl OutputSubstitutionProtocolError {
228+
pub fn kind(&self) -> OutputSubstitutionErrorKind { self.kind }
229+
230+
pub fn message(&self) -> String { self.message.clone() }
231+
}
175232

176233
/// Error that may occur when output substitution fails.
177234
#[derive(Debug, thiserror::Error, uniffi::Error)]
@@ -199,8 +256,33 @@ pub struct SelectionError(#[from] receive::SelectionError);
199256

200257
/// Error that may occur when input contribution fails.
201258
#[derive(Debug, thiserror::Error, uniffi::Object)]
202-
#[error(transparent)]
203-
pub struct InputContributionError(#[from] receive::InputContributionError);
259+
#[error("{message}")]
260+
pub struct InputContributionError {
261+
kind: InputContributionErrorKind,
262+
message: String,
263+
duplicate_input_outpoint: Option<PlainOutPoint>,
264+
}
265+
266+
impl From<receive::InputContributionError> for InputContributionError {
267+
fn from(value: receive::InputContributionError) -> Self {
268+
Self {
269+
kind: value.kind().into(),
270+
message: value.to_string(),
271+
duplicate_input_outpoint: value.duplicate_input_outpoint().map(Into::into),
272+
}
273+
}
274+
}
275+
276+
#[uniffi::export]
277+
impl InputContributionError {
278+
pub fn kind(&self) -> InputContributionErrorKind { self.kind }
279+
280+
pub fn message(&self) -> String { self.message.clone() }
281+
282+
pub fn duplicate_input_outpoint(&self) -> Option<PlainOutPoint> {
283+
self.duplicate_input_outpoint.clone()
284+
}
285+
}
204286

205287
/// Error validating a PSBT Input
206288
#[derive(Debug, thiserror::Error, uniffi::Object)]
@@ -237,3 +319,180 @@ impl From<FfiValidationError> for InputPairError {
237319
pub struct ReceiverReplayError(
238320
#[from] payjoin::error::ReplayError<receive::v2::ReceiveSession, receive::v2::SessionEvent>,
239321
);
322+
323+
#[cfg(all(test, feature = "_test-utils"))]
324+
mod tests {
325+
use std::str::FromStr;
326+
327+
use payjoin::bitcoin::{Address, Amount, Network, Psbt, ScriptBuf, TxOut};
328+
use payjoin::receive::v1::{Headers, UncheckedOriginalPayload};
329+
use payjoin_test_utils::{ORIGINAL_PSBT, QUERY_PARAMS, RECEIVER_INPUT_CONTRIBUTION};
330+
331+
use super::*;
332+
333+
struct TestHeaders {
334+
content_type: Option<&'static str>,
335+
content_length: String,
336+
}
337+
338+
impl Headers for TestHeaders {
339+
fn get_header(&self, key: &str) -> Option<&str> {
340+
match key {
341+
"content-type" => self.content_type,
342+
"content-length" => Some(self.content_length.as_str()),
343+
_ => None,
344+
}
345+
}
346+
}
347+
348+
fn wants_outputs_from_test_vector() -> payjoin::receive::v1::WantsOutputs {
349+
let body = ORIGINAL_PSBT.as_bytes();
350+
let headers = TestHeaders {
351+
content_type: Some("text/plain"),
352+
content_length: body.len().to_string(),
353+
};
354+
let receiver_address = Address::from_str("3CZZi7aWFugaCdUCS15dgrUUViupmB8bVM")
355+
.expect("known address should parse")
356+
.require_network(Network::Bitcoin)
357+
.expect("known address should match network");
358+
359+
UncheckedOriginalPayload::from_request(body, QUERY_PARAMS, headers)
360+
.expect("test vector should parse")
361+
.assume_interactive_receiver()
362+
.check_inputs_not_owned(&mut |_| Ok(false))
363+
.expect("proposal should not spend receiver inputs")
364+
.check_no_inputs_seen_before(&mut |_| Ok(false))
365+
.expect("proposal should not contain seen inputs")
366+
.identify_receiver_outputs(&mut |script| {
367+
Ok(Address::from_script(script, Network::Bitcoin)
368+
.expect("known script should decode")
369+
== receiver_address)
370+
})
371+
.expect("receiver output should be identified")
372+
}
373+
374+
fn wants_inputs_from_test_vector() -> payjoin::receive::v1::WantsInputs {
375+
wants_outputs_from_test_vector().commit_outputs()
376+
}
377+
378+
fn receiver_output_from_test_vector() -> TxOut {
379+
let receiver_script = Address::from_str("3CZZi7aWFugaCdUCS15dgrUUViupmB8bVM")
380+
.expect("known address should parse")
381+
.require_network(Network::Bitcoin)
382+
.expect("known address should match network")
383+
.script_pubkey();
384+
let original = Psbt::from_str(ORIGINAL_PSBT).expect("known PSBT should parse");
385+
386+
original
387+
.unsigned_tx
388+
.output
389+
.iter()
390+
.find(|output| output.script_pubkey == receiver_script)
391+
.cloned()
392+
.expect("test vector should pay the receiver")
393+
}
394+
395+
fn receiver_input_pair() -> payjoin::receive::InputPair {
396+
let proposal_psbt =
397+
Psbt::from_str(RECEIVER_INPUT_CONTRIBUTION).expect("known PSBT should parse");
398+
payjoin::receive::InputPair::new(
399+
proposal_psbt.unsigned_tx.input[1].clone(),
400+
proposal_psbt.inputs[1].clone(),
401+
None,
402+
)
403+
.expect("test vector input should be valid")
404+
}
405+
406+
fn receiver_input_outpoint() -> PlainOutPoint {
407+
let proposal_psbt =
408+
Psbt::from_str(RECEIVER_INPUT_CONTRIBUTION).expect("known PSBT should parse");
409+
PlainOutPoint::from(proposal_psbt.unsigned_tx.input[1].previous_output)
410+
}
411+
412+
fn wants_inputs_with_minimum_contribution(
413+
required_delta: Amount,
414+
) -> payjoin::receive::v1::WantsInputs {
415+
let mut receiver_output = receiver_output_from_test_vector();
416+
let drain_script = receiver_output.script_pubkey.clone();
417+
receiver_output.value += required_delta;
418+
419+
wants_outputs_from_test_vector()
420+
.replace_receiver_outputs(vec![receiver_output], &drain_script)
421+
.expect("higher receiver output should be accepted")
422+
.commit_outputs()
423+
}
424+
425+
fn low_value_input_pair() -> payjoin::receive::InputPair {
426+
let proposal_psbt =
427+
Psbt::from_str(RECEIVER_INPUT_CONTRIBUTION).expect("known PSBT should parse");
428+
let mut psbt_input = proposal_psbt.inputs[1].clone();
429+
let mut witness_utxo =
430+
psbt_input.witness_utxo.clone().expect("test vector input should include witness UTXO");
431+
witness_utxo.value = Amount::from_sat(123);
432+
psbt_input.witness_utxo = Some(witness_utxo);
433+
434+
payjoin::receive::InputPair::new(
435+
proposal_psbt.unsigned_tx.input[1].clone(),
436+
psbt_input,
437+
None,
438+
)
439+
.expect("low-value test input should remain structurally valid")
440+
}
441+
442+
#[test]
443+
fn test_output_substitution_error_exposes_kind() {
444+
let receiver_output = receiver_output_from_test_vector();
445+
let missing_drain_script = ScriptBuf::new();
446+
let error = wants_outputs_from_test_vector()
447+
.replace_receiver_outputs(vec![receiver_output], &missing_drain_script)
448+
.expect_err("missing drain script should fail");
449+
let OutputSubstitutionError::Protocol(protocol) = OutputSubstitutionError::from(error)
450+
else {
451+
panic!("expected protocol substitution error");
452+
};
453+
454+
assert_eq!(protocol.kind(), OutputSubstitutionErrorKind::InvalidDrainScript);
455+
assert_eq!(
456+
protocol.message(),
457+
"The provided drain script could not be identified in the provided replacement outputs"
458+
);
459+
}
460+
461+
#[test]
462+
fn test_input_contribution_error_exposes_duplicate_outpoint() {
463+
let input = receiver_input_pair();
464+
let contributed = wants_inputs_from_test_vector()
465+
.contribute_inputs(vec![input.clone()])
466+
.expect("first contribution should succeed");
467+
let error = contributed
468+
.contribute_inputs(vec![input])
469+
.expect_err("duplicate contribution should fail");
470+
let error = InputContributionError::from(error);
471+
let expected_outpoint = receiver_input_outpoint();
472+
473+
assert_eq!(error.kind(), InputContributionErrorKind::DuplicateInput);
474+
let outpoint =
475+
error.duplicate_input_outpoint().expect("duplicate outpoint should be present");
476+
assert_eq!(outpoint.txid, expected_outpoint.txid);
477+
assert_eq!(outpoint.vout, expected_outpoint.vout);
478+
assert_eq!(
479+
error.message(),
480+
format!("Duplicate input detected: {}:{}", outpoint.txid, outpoint.vout)
481+
);
482+
}
483+
484+
#[test]
485+
fn test_input_contribution_error_exposes_value_too_low_kind() {
486+
let error = wants_inputs_with_minimum_contribution(Amount::from_sat(1_000))
487+
.contribute_inputs(vec![low_value_input_pair()])
488+
.expect_err("low value contribution should fail");
489+
let error = InputContributionError::from(error);
490+
491+
assert_eq!(error.kind(), InputContributionErrorKind::ValueTooLow);
492+
assert!(error.duplicate_input_outpoint().is_none());
493+
assert_eq!(
494+
error.message(),
495+
"Total input value is not enough to cover additional output value"
496+
);
497+
}
498+
}

0 commit comments

Comments
 (0)