@@ -2,6 +2,7 @@ use std::sync::Arc;
22
33use payjoin:: receive;
44
5+ use super :: PlainOutPoint ;
56use crate :: error:: { FfiValidationError , ImplementationError } ;
67use crate :: uri:: error:: IntoUrlError ;
78
@@ -168,10 +169,66 @@ impl From<ProtocolError> for JsonReply {
168169#[ error( transparent) ]
169170pub 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 {
237319pub 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