From ff432aab0523d30bae763fe6e54615415ce93536 Mon Sep 17 00:00:00 2001 From: nkatha23 Date: Thu, 7 May 2026 14:15:14 +0300 Subject: [PATCH] tests: pin multisig no-descriptor and consolidation behaviour MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 the no-descriptor case, all seven script types for the consolidation case. Part of #931. --- tests/test_psbt_parser.py | 71 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/tests/test_psbt_parser.py b/tests/test_psbt_parser.py index a3c1ab913..6c7edf185 100644 --- a/tests/test_psbt_parser.py +++ b/tests/test_psbt_parser.py @@ -67,6 +67,24 @@ def run_basic_test(self, psbt_base64: str, change_data: str, self_transfer_data: assert psbt_parser.fee_amount == fee_amount assert psbt_parser.input_amount == psbt_parser.spend_amount + psbt_parser.change_amount + psbt_parser.fee_amount + # Consolidation: both a change-branch output (1/*) and a receive-branch output (0/*) back to this wallet + psbt.outputs.clear() + change_output_amount = (input_amount - fee_amount) // 2 + self_transfer_amount = input_amount - fee_amount - change_output_amount + psbt.outputs.append(create_output(change_data, change_output_amount)) + psbt.outputs.append(create_output(self_transfer_data, self_transfer_amount)) + + assert len(psbt.outputs) == 2 + psbt_parser = PSBTParser(p=psbt, seed=self.seed, network=SettingsConstants.REGTEST) + assert psbt_parser.num_inputs == len(psbt.inputs) + assert psbt_parser.input_amount == input_amount + assert psbt_parser.num_destinations == 0 # No external recipients + assert psbt_parser.num_change_outputs == 2 # Both branches classified as internal + assert psbt_parser.spend_amount == 0 + assert psbt_parser.change_amount == input_amount - fee_amount + assert psbt_parser.fee_amount == fee_amount + assert psbt_parser.input_amount == psbt_parser.spend_amount + psbt_parser.change_amount + psbt_parser.fee_amount + # Now do full spends with no change fee_amount = random.randint(5_000, 100_000) recipient_amount = input_amount - fee_amount @@ -272,6 +290,11 @@ def test_verify_multisig_output(self): psbt.outputs.append(create_output(self_transfer_outputs[i], 100_000)) psbt_parser = PSBTParser(p=psbt, seed=self.seed, network=SettingsConstants.REGTEST) + # Both change-branch and receive-branch outputs must be classified as internal + assert psbt_parser.num_destinations == 0 + assert psbt_parser.num_change_outputs == 2 + assert psbt_parser.spend_amount == 0 + # Attempt to verify the change & self-transfer outputs using the right and wrong descriptors for j, descriptor_str in enumerate(descriptors): descriptor = Descriptor.from_string(descriptor_str.replace("<0;1>", "{0,1}")) @@ -284,6 +307,54 @@ def test_verify_multisig_output(self): + def test_multisig_no_descriptor_all_outputs_are_spend(self): + """ + Some coordinators produce PSBTs where the output scopes contain only the bare + scriptPubKey — no witness_script and no redeem_script. The transaction is + structurally valid and the addresses are correct, but the PSBT fields that + PSBTParser needs to independently verify multisig ownership are absent. + + Without witness_script / redeem_script, _get_policy() cannot extract m, n, or + cosigners, so the output policy dict never matches the input policy. Every + output therefore falls through to destination_addresses and is treated as a + spend — the conservative safe default. + + Asserts: num_change_outputs == 0, spend_amount == full output value. + Covers all three multisig script types: P2WSH, P2SH-P2WSH, legacy P2SH. + """ + cases = [ + (PSBTTestData.MULTISIG_NATIVE_SEGWIT_1_INPUT, PSBTTestData.MULTISIG_NATIVE_SEGWIT_CHANGE), + (PSBTTestData.MULTISIG_NESTED_SEGWIT_1_INPUT, PSBTTestData.MULTISIG_NESTED_SEGWIT_CHANGE), + (PSBTTestData.MULTISIG_LEGACY_P2SH_1_INPUT, PSBTTestData.MULTISIG_LEGACY_P2SH_CHANGE), + ] + + fee_amount = 5_000 + for psbt_base64, change_data in cases: + psbt: PSBT = PSBT.parse(a2b_base64(psbt_base64)) + input_amount = sum(inp.utxo.value for inp in psbt.inputs) + output_amount = input_amount - fee_amount + + # Strip the multisig scripts to simulate a coordinator that only populated the scriptPubKey + stripped = create_output(change_data, output_amount) + stripped.witness_script = None + stripped.redeem_script = None + psbt.outputs.append(stripped) + + psbt_parser = PSBTParser(p=psbt, seed=self.seed, network=SettingsConstants.REGTEST) + + # Without witness_script/redeem_script the output policy cannot match; no change detected + assert psbt_parser.num_change_outputs == 0 + # The unverifiable output falls through to destination_addresses + assert psbt_parser.num_destinations == 1 + # Full output value is treated as a spend when the multisig script is absent + assert psbt_parser.spend_amount == output_amount + assert psbt_parser.change_amount == 0 + assert psbt_parser.fee_amount == fee_amount + assert psbt_parser.input_amount == psbt_parser.spend_amount + psbt_parser.change_amount + psbt_parser.fee_amount + + + + # TODO: Refactor all tests to be in the TestPSBTParser class(?) def test_p2tr_change_detection(): """ Should successfully detect change in a p2tr to p2tr psbt spend