Skip to content

Commit b91a825

Browse files
committed
tests: pin multisig no-descriptor and consolidation behaviour
When a coordinator strips witness_script/redeem_script from multisig output scopes, _get_policy() returns a bare policy dict with no m/n/cosigners, which never matches the input policy — so no output is classified as change and all value shows as spend_amount. This was the correct conservative behaviour but had no test pinning it. Also adds a consolidation test: a PSBT where both a change-branch (1/*) and a receive-branch (0/*) output belong to the signing wallet. PSBTParser classifies both as internal regardless of branch index, giving spend_amount == 0. Covers all three multisig types for test #1, all seven script types for test #4.
1 parent 600d9a5 commit b91a825

1 file changed

Lines changed: 134 additions & 0 deletions

File tree

tests/test_psbt_parser.py

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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(?)
288422
def test_p2tr_change_detection():
289423
""" Should successfully detect change in a p2tr to p2tr psbt spend

0 commit comments

Comments
 (0)