@@ -284,6 +284,140 @@ def test_verify_multisig_output(self):
284284
285285
286286
287+ def test_multisig_no_descriptor_all_outputs_are_spend (self ):
288+ """
289+ When a multisig PSBT output is missing its witness_script / redeem_script,
290+ PSBTParser cannot reconstruct the multisig policy from that output scope alone.
291+
292+ Root cause: _get_policy() requires witness_script (P2WSH, P2SH-P2WSH) or
293+ redeem_script (legacy P2SH) to extract m, n, and cosigners. Without those
294+ fields the output policy dict contains no multisig keys and does not match
295+ the input policy, so _parse_outputs() never sets is_change = True for that
296+ output.
297+
298+ Expected behaviour: every such output is classified as a destination (spend),
299+ num_change_outputs == 0, and change_amount == 0.
300+
301+ Pins the current conservative default so that any future refactor that
302+ accidentally starts trusting bare scriptPubKey matches cannot silently
303+ reclassify unverifiable multisig outputs as change.
304+ """
305+ cases = [
306+ (PSBTTestData .MULTISIG_NATIVE_SEGWIT_1_INPUT , PSBTTestData .MULTISIG_NATIVE_SEGWIT_CHANGE ),
307+ (PSBTTestData .MULTISIG_NESTED_SEGWIT_1_INPUT , PSBTTestData .MULTISIG_NESTED_SEGWIT_CHANGE ),
308+ (PSBTTestData .MULTISIG_LEGACY_P2SH_1_INPUT , PSBTTestData .MULTISIG_LEGACY_P2SH_CHANGE ),
309+ ]
310+
311+ fee_amount = 5_000
312+ for psbt_base64 , change_data in cases :
313+ psbt : PSBT = PSBT .parse (a2b_base64 (psbt_base64 ))
314+ input_amount = sum (inp .utxo .value for inp in psbt .inputs )
315+ output_amount = input_amount - fee_amount
316+
317+ stripped = create_output (change_data , output_amount )
318+ stripped .witness_script = None
319+ stripped .redeem_script = None
320+ psbt .outputs .append (stripped )
321+
322+ psbt_parser = PSBTParser (p = psbt , seed = self .seed , network = SettingsConstants .REGTEST )
323+
324+ assert psbt_parser .num_change_outputs == 0 , \
325+ "Without witness_script/redeem_script the output policy cannot match; no change should be detected"
326+ assert psbt_parser .num_destinations == 1 , \
327+ "The unverifiable output must fall through to destination_addresses"
328+ assert psbt_parser .spend_amount == output_amount , \
329+ "The full output value is treated as a spend when the multisig script is absent"
330+ assert psbt_parser .change_amount == 0 , \
331+ "No output can be verified as change without the multisig script"
332+ assert psbt_parser .fee_amount == fee_amount
333+ assert psbt_parser .input_amount == psbt_parser .spend_amount + psbt_parser .change_amount + psbt_parser .fee_amount
334+
335+
336+ def test_consolidation_all_outputs_internal (self ):
337+ """
338+ A consolidation PSBT where every output belongs to the signing wallet:
339+ one output on the change branch (BIP32 derivation index 1/*) and one on
340+ the receive / self-transfer branch (index 0/*).
341+
342+ PSBTParser does not restrict the change classification to derivation index
343+ 1/* only. When full PSBT metadata is present (witness_script for multisig,
344+ bip32_derivations for single-sig) and the derived key matches the output
345+ scriptPubKey, both outputs are classified as internal regardless of branch.
346+
347+ Expected behaviour:
348+ - num_destinations == 0 (no external recipients)
349+ - num_change_outputs == 2 (both branches recognised as internal)
350+ - spend_amount == 0 (nothing leaves the wallet)
351+ - change_amount == input_amount - fee_amount
352+ - input_amount == change_amount + fee_amount (conservation property)
353+
354+ Covers all seven supported script types.
355+ """
356+ cases = [
357+ (
358+ PSBTTestData .SINGLE_SIG_NATIVE_SEGWIT_1_INPUT ,
359+ PSBTTestData .SINGLE_SIG_NATIVE_SEGWIT_CHANGE ,
360+ PSBTTestData .SINGLE_SIG_NATIVE_SEGWIT_SELF_TRANSFER ,
361+ ),
362+ (
363+ PSBTTestData .SINGLE_SIG_NESTED_SEGWIT_1_INPUT ,
364+ PSBTTestData .SINGLE_SIG_NESTED_SEGWIT_CHANGE ,
365+ PSBTTestData .SINGLE_SIG_NESTED_SEGWIT_SELF_TRANSFER ,
366+ ),
367+ (
368+ PSBTTestData .SINGLE_SIG_TAPROOT_1_INPUT ,
369+ PSBTTestData .SINGLE_SIG_TAPROOT_CHANGE ,
370+ PSBTTestData .SINGLE_SIG_TAPROOT_SELF_TRANSFER ,
371+ ),
372+ (
373+ PSBTTestData .SINGLE_SIG_LEGACY_P2PKH_1_INPUT ,
374+ PSBTTestData .SINGLE_SIG_LEGACY_P2PKH_CHANGE ,
375+ PSBTTestData .SINGLE_SIG_LEGACY_P2PKH_SELF_TRANSFER ,
376+ ),
377+ (
378+ PSBTTestData .MULTISIG_NATIVE_SEGWIT_1_INPUT ,
379+ PSBTTestData .MULTISIG_NATIVE_SEGWIT_CHANGE ,
380+ PSBTTestData .MULTISIG_NATIVE_SEGWIT_SELF_TRANSFER ,
381+ ),
382+ (
383+ PSBTTestData .MULTISIG_NESTED_SEGWIT_1_INPUT ,
384+ PSBTTestData .MULTISIG_NESTED_SEGWIT_CHANGE ,
385+ PSBTTestData .MULTISIG_NESTED_SEGWIT_SELF_TRANSFER ,
386+ ),
387+ (
388+ PSBTTestData .MULTISIG_LEGACY_P2SH_1_INPUT ,
389+ PSBTTestData .MULTISIG_LEGACY_P2SH_CHANGE ,
390+ PSBTTestData .MULTISIG_LEGACY_P2SH_SELF_TRANSFER ,
391+ ),
392+ ]
393+
394+ fee_amount = 5_000
395+ for psbt_base64 , change_data , self_transfer_data in cases :
396+ psbt : PSBT = PSBT .parse (a2b_base64 (psbt_base64 ))
397+ input_amount = sum (inp .utxo .value for inp in psbt .inputs )
398+ total_output = input_amount - fee_amount
399+ # Exact split does not matter; both outputs must be recognised as internal
400+ change_output_amount = total_output // 2
401+ self_transfer_amount = total_output - change_output_amount
402+
403+ psbt .outputs .append (create_output (change_data , change_output_amount ))
404+ psbt .outputs .append (create_output (self_transfer_data , self_transfer_amount ))
405+
406+ psbt_parser = PSBTParser (p = psbt , seed = self .seed , network = SettingsConstants .REGTEST )
407+
408+ assert psbt_parser .num_destinations == 0 , \
409+ "All outputs belong to the signing wallet; no external recipients expected"
410+ assert psbt_parser .num_change_outputs == 2 , \
411+ "Both change-branch (1/*) and receive-branch (0/*) outputs must be classified as internal"
412+ assert psbt_parser .spend_amount == 0 , \
413+ "No value is sent to an external address in a full consolidation"
414+ assert psbt_parser .change_amount == total_output , \
415+ "All output value (minus fee) must be retained internally"
416+ assert psbt_parser .fee_amount == fee_amount
417+ assert psbt_parser .input_amount == psbt_parser .spend_amount + psbt_parser .change_amount + psbt_parser .fee_amount
418+
419+
420+
287421# TODO: Refactor all tests to be in the TestPSBTParser class(?)
288422def test_p2tr_change_detection ():
289423 """ Should successfully detect change in a p2tr to p2tr psbt spend
0 commit comments