mirror of
https://github.com/SeedSigner/seedsigner.git
synced 2026-10-05 15:08:25 +00:00
A multisig output whose committed script holds this seed's key can
still belong to a different wallet: a 2-of-3 over {p1,p2,p4} when the
inputs spend {p1,p2,p3}. When the psbt supplies global xpubs and fully
annotates the output, _get_cosigners resolves a cosigner list for the
inputs and one for the output, and lists that differ mean the output
pays a quorum other than the one being spent from. That output is now
counted as an external spend. It stays presumed change when the lists
match or when either side's cosigners are unresolved.
This is hygiene, not a security control. The global xpubs are optional
and unauthenticated, so a coordinator that omits them skips the check,
and a mismatch demotes rather than raises because a transfer into a
second multisig this seed belongs to is legitimate. No surveyed
coordinator produces the shape the check fires on: every one except
Bitcoin Core annotates only its own wallet's outputs, and Core, which
annotates any output a descriptor in its wallet file can solve, writes
no global xpubs at all. Settling which wallet a multisig output pays
still needs the user's own descriptor.
The comparison sits after the ownership checks rather than at the
policy shape gate, so a psbt whose output cosigners fail to resolve
still faces every ownership check.
_get_cosigners' sorted() is what makes the two lists comparable: two of
the three multisig fixtures order the same wallet's keys differently on
the input and on its change output. Its docstring now separates what
is claim from what is checked and what the result does and does not
establish, its loop carries the procedure inline, and two early-outs
name the omitted-xpubs and omitted-derivations cases before the loop
(behaviour-neutral: both already raised from inside it). Tests pin the
sort, the different-quorum spend, and the no-xpubs presumed-change
fallback.
2098 lines
106 KiB
Python
2098 lines
106 KiB
Python
import pytest
|
|
import random
|
|
|
|
from binascii import a2b_base64
|
|
from copy import deepcopy
|
|
from unittest.mock import patch
|
|
from embit import bip32, script
|
|
from embit.ec import PublicKey
|
|
from embit.networks import NETWORKS
|
|
from embit.psbt import PSBT, DerivationPath, OutputScope
|
|
from embit.descriptor import Descriptor
|
|
|
|
from seedsigner.models.psbt_parser import (PSBTInputOwnershipClaimError,
|
|
PSBTMixedDerivationPathTypesError, PSBTOutputOwnershipClaimError,
|
|
PSBTOutputOwnershipContradictionError, PSBTParser, PSBTSeedCannotSignError,
|
|
PSBTSurplusDerivationPathsError)
|
|
from seedsigner.models.seed import Seed
|
|
from seedsigner.models.settings_definition import SettingsConstants
|
|
|
|
from psbt_testing_util import (NUMS_INTERNAL_KEY, PSBTTestData, claim_seed_owns_key,
|
|
create_output, foreign_public_key, p2tr_with_script_tree, root_for_seed, tapleaf_hash)
|
|
|
|
|
|
|
|
class TestPSBTParser:
|
|
"""
|
|
Exhaustively test all supported script input and output types.
|
|
"""
|
|
seed = PSBTTestData.seed
|
|
|
|
def run_basic_test(self, psbt_base64: str, change_data: str, self_transfer_data: str):
|
|
"""
|
|
Constructs a series of test psbts that use the specified `psbt_base64` for the input(s).
|
|
|
|
* A spend to each recipient type + specified `change_data`
|
|
* Self-transfer back to sender via the `self_transfer_data`
|
|
* A full spend (no change) to each recipient type
|
|
* 1 mega psbt with an output to each recipient type + specified `change_data`
|
|
"""
|
|
psbt: PSBT = PSBT.parse(a2b_base64(psbt_base64))
|
|
input_amount = sum([inp.utxo.value for inp in psbt.inputs])
|
|
recipient_amount = random.randint(200_000, 90_000_000)
|
|
fee_amount = 5_000
|
|
change_output = create_output(change_data, input_amount - recipient_amount - fee_amount)
|
|
|
|
# Spend the input(s) to each supported recipient type + change
|
|
for output in PSBTTestData.ALL_EXTERNAL_OUTPUTS:
|
|
psbt.outputs.clear()
|
|
psbt.outputs.append(create_output(output, recipient_amount))
|
|
psbt.outputs.append(change_output)
|
|
|
|
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 == 1
|
|
assert psbt_parser.num_change_outputs == 1
|
|
assert psbt_parser.spend_amount == recipient_amount
|
|
assert psbt_parser.change_amount == input_amount - recipient_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
|
|
|
|
# Internally cycle the input(s) back to sender via the `self_transfer_data`
|
|
psbt.outputs.clear()
|
|
psbt.outputs.append(create_output(self_transfer_data, input_amount - fee_amount))
|
|
|
|
assert len(psbt.outputs) == 1
|
|
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 == no destinations
|
|
assert psbt_parser.num_change_outputs == 1 # PSBTParser considers self-transfers == change
|
|
assert psbt_parser.spend_amount == 0 # No external recipients == nothing spent (ignores fee)
|
|
assert psbt_parser.change_amount == input_amount - fee_amount # PSBTParser considers self-transfers == change
|
|
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
|
|
|
|
for output in PSBTTestData.ALL_EXTERNAL_OUTPUTS:
|
|
psbt.outputs.clear()
|
|
psbt.outputs.append(create_output(output, recipient_amount))
|
|
|
|
assert len(psbt.outputs) == 1
|
|
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 == 1
|
|
assert psbt_parser.num_change_outputs == 0
|
|
assert psbt_parser.spend_amount == recipient_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
|
|
|
|
# Now try a single mega psbt with ALL the outputs at once
|
|
psbt.outputs.clear()
|
|
change_amount = input_amount - fee_amount
|
|
for output in PSBTTestData.ALL_EXTERNAL_OUTPUTS:
|
|
output_amount = random.randint(200_000, int(change_amount / 2))
|
|
psbt.outputs.append(create_output(output, output_amount))
|
|
change_amount -= output_amount
|
|
|
|
# Don't forget the change!
|
|
psbt.outputs.append(create_output(change_data, change_amount))
|
|
|
|
assert len(psbt.outputs) == len(PSBTTestData.ALL_EXTERNAL_OUTPUTS) + 1
|
|
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 == len(PSBTTestData.ALL_EXTERNAL_OUTPUTS)
|
|
assert psbt_parser.num_change_outputs == 1
|
|
assert psbt_parser.spend_amount == input_amount - change_amount - fee_amount
|
|
assert psbt_parser.change_amount == change_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
|
|
|
|
|
|
def test_singlesig_native_segwit(self):
|
|
self.run_basic_test(PSBTTestData.SINGLE_SIG_NATIVE_SEGWIT_1_INPUT, PSBTTestData.SINGLE_SIG_NATIVE_SEGWIT_CHANGE, PSBTTestData.SINGLE_SIG_NATIVE_SEGWIT_SELF_TRANSFER)
|
|
|
|
def test_singlesig_nested_segwit(self):
|
|
self.run_basic_test(PSBTTestData.SINGLE_SIG_NESTED_SEGWIT_1_INPUT, PSBTTestData.SINGLE_SIG_NESTED_SEGWIT_CHANGE, PSBTTestData.SINGLE_SIG_NESTED_SEGWIT_SELF_TRANSFER)
|
|
|
|
def test_singlesig_taproot(self):
|
|
self.run_basic_test(PSBTTestData.SINGLE_SIG_TAPROOT_1_INPUT, PSBTTestData.SINGLE_SIG_TAPROOT_CHANGE, PSBTTestData.SINGLE_SIG_TAPROOT_SELF_TRANSFER)
|
|
|
|
def test_singlesig_legacy_p2pkh(self):
|
|
self.run_basic_test(PSBTTestData.SINGLE_SIG_LEGACY_P2PKH_1_INPUT, PSBTTestData.SINGLE_SIG_LEGACY_P2PKH_CHANGE, PSBTTestData.SINGLE_SIG_LEGACY_P2PKH_SELF_TRANSFER)
|
|
|
|
def test_multisig_native_segwit(self):
|
|
self.run_basic_test(PSBTTestData.MULTISIG_NATIVE_SEGWIT_1_INPUT, PSBTTestData.MULTISIG_NATIVE_SEGWIT_CHANGE, PSBTTestData.MULTISIG_NATIVE_SEGWIT_SELF_TRANSFER)
|
|
|
|
def test_multisig_nested_segwit(self):
|
|
self.run_basic_test(PSBTTestData.MULTISIG_NESTED_SEGWIT_1_INPUT, PSBTTestData.MULTISIG_NESTED_SEGWIT_CHANGE, PSBTTestData.MULTISIG_NESTED_SEGWIT_SELF_TRANSFER)
|
|
|
|
def test_multisig_legacy_p2sh(self):
|
|
self.run_basic_test(PSBTTestData.MULTISIG_LEGACY_P2SH_1_INPUT, PSBTTestData.MULTISIG_LEGACY_P2SH_CHANGE, PSBTTestData.MULTISIG_LEGACY_P2SH_SELF_TRANSFER)
|
|
|
|
|
|
def test_has_matching_input_fingerprint(self):
|
|
"""
|
|
PSBTParser should correctly identify when a psbt contains an input that matches a
|
|
given Seed's fingerprint.
|
|
"""
|
|
wrong_seed = Seed(["bacon"] * 24)
|
|
for input in PSBTTestData.ALL_INPUTS:
|
|
psbt = PSBT.parse(a2b_base64(input))
|
|
assert PSBTParser.has_matching_input_fingerprint(psbt, PSBTTestData.seed)
|
|
assert PSBTParser.has_matching_input_fingerprint(psbt, wrong_seed) == False
|
|
|
|
# The other keys in the multisig inputs should also match
|
|
for input in PSBTTestData.MULTISIG_INPUTS:
|
|
psbt = PSBT.parse(a2b_base64(input))
|
|
assert PSBTParser.has_matching_input_fingerprint(psbt, PSBTTestData.multisig_key_2)
|
|
assert PSBTParser.has_matching_input_fingerprint(psbt, PSBTTestData.multisig_key_3)
|
|
|
|
|
|
def test_missing_fingerprint_handling(self):
|
|
"""
|
|
PSBTParser should correctly handle PSBTs with missing fingerprints (created from XPUB-only imports,
|
|
without derivation path) by matching public keys against the seed and filling in correct fingerprints.
|
|
"""
|
|
for input in PSBTTestData.ALL_INPUTS:
|
|
psbt = PSBT.parse(a2b_base64(input))
|
|
|
|
# Set fingerprints to zero to simulate XPUB-only import (missing fingerprint)
|
|
from embit.psbt import DerivationPath
|
|
for inp in psbt.inputs:
|
|
for pub, derivation in inp.bip32_derivations.items():
|
|
inp.bip32_derivations[pub] = DerivationPath(
|
|
fingerprint=b"\x00\x00\x00\x00",
|
|
derivation=derivation.derivation
|
|
)
|
|
|
|
for pub, (leaf_hashes, derivation) in inp.taproot_bip32_derivations.items():
|
|
inp.taproot_bip32_derivations[pub] = (leaf_hashes, DerivationPath(
|
|
fingerprint=b"\x00\x00\x00\x00",
|
|
derivation=derivation.derivation
|
|
))
|
|
|
|
# Test that has_matching_input_fingerprint can correctly identify that an input
|
|
# from the psbt does belong to the provided seed, even when the fingerprints
|
|
# (in the inputs' bip32 derivations) have been zeroed out.
|
|
assert PSBTParser.has_matching_input_fingerprint(psbt, PSBTTestData.seed, SettingsConstants.REGTEST)
|
|
|
|
# Test that it correctly rejects wrong seeds
|
|
wrong_seed = Seed(["bacon"] * 24)
|
|
assert not PSBTParser.has_matching_input_fingerprint(psbt, wrong_seed, SettingsConstants.REGTEST)
|
|
|
|
# Test the PSBTParser's ability to fill missing fingerprints during parsing
|
|
parser = PSBTParser(p=psbt, seed=PSBTTestData.seed, network=SettingsConstants.REGTEST)
|
|
|
|
# Verify fingerprints were correctly filled after parsing
|
|
seed_fingerprint = parser.seed.get_fingerprint(SettingsConstants.REGTEST)
|
|
|
|
for inp in parser.psbt.inputs:
|
|
for pub, derivation in inp.bip32_derivations.items():
|
|
from binascii import hexlify
|
|
fingerprint_hex = hexlify(derivation.fingerprint).decode()
|
|
|
|
# Check if this public key derives from the current seed
|
|
derived_key = parser.root.derive(derivation.derivation)
|
|
if derived_key.key.sec() == pub.sec():
|
|
# This pubkey derives from current seed, should have current seed's fingerprint
|
|
assert fingerprint_hex == seed_fingerprint, f"Expected {seed_fingerprint}, got {fingerprint_hex} for pubkey that derives from current seed"
|
|
else:
|
|
# This pubkey doesn't derive from current seed, should remain 00000000
|
|
assert fingerprint_hex == "00000000"
|
|
|
|
# Also check Taproot derivations
|
|
for pub, (leaf_hashes, derivation) in inp.taproot_bip32_derivations.items():
|
|
from binascii import hexlify
|
|
fingerprint_hex = hexlify(derivation.fingerprint).decode()
|
|
|
|
# Check if this public key derives from the current seed. A psbt
|
|
# carries a taproot key as its bare 32-byte x coordinate, and embit
|
|
# rebuilds a full key from it by just assuming even parity. The real
|
|
# derived key can be odd-parity, so a full-key compare would wrongly
|
|
# report a mismatch. Only the x coordinate is real data: compare
|
|
# x-only.
|
|
derived_key = parser.root.derive(derivation.derivation)
|
|
if derived_key.xonly() == pub.xonly():
|
|
# This pubkey derives from current seed, should have current seed's fingerprint
|
|
assert fingerprint_hex == seed_fingerprint, f"Expected {seed_fingerprint}, got {fingerprint_hex} for taproot pubkey that derives from current seed"
|
|
else:
|
|
# This pubkey doesn't derive from current seed, should remain 00000000
|
|
assert fingerprint_hex == "00000000"
|
|
|
|
# All of the above only proves the even-parity case. A psbt carries taproot keys
|
|
# as bare 32-byte x coordinates and embit rebuilds full keys from them by assuming
|
|
# even parity; that assumption happens to hold for the fixture's key at
|
|
# m/86h/1h/0h/0/0. Re-key the taproot input to a path whose key really derives
|
|
# with odd parity to prove the ownership fallback compares x-only rather than
|
|
# trusting embit's artificial parity.
|
|
root = root_for_seed(PSBTTestData.seed)
|
|
odd_parity_derivation_path = "m/86h/1h/0h/0/1"
|
|
odd_parity_public_key = root.derive(odd_parity_derivation_path).get_public_key()
|
|
assert odd_parity_public_key.sec()[0] == 0x03 # odd parity
|
|
|
|
psbt = PSBT.parse(a2b_base64(PSBTTestData.SINGLE_SIG_TAPROOT_1_INPUT))
|
|
taproot_input = psbt.inputs[0]
|
|
|
|
# Present the key the way embit's psbt parsing yields it: rebuilt from just the
|
|
# x coordinate, carrying the assumed even parity (wrong for this key)
|
|
x_only_public_key = PublicKey.from_xonly(odd_parity_public_key.xonly())
|
|
taproot_input.taproot_bip32_derivations.clear()
|
|
taproot_input.taproot_bip32_derivations[x_only_public_key] = ([], DerivationPath(
|
|
fingerprint=b"\x00\x00\x00\x00",
|
|
derivation=bip32.parse_path(odd_parity_derivation_path)
|
|
))
|
|
taproot_input.taproot_internal_key = x_only_public_key
|
|
taproot_input.witness_utxo.script_pubkey = script.p2tr(x_only_public_key)
|
|
|
|
# The zeroed-fingerprint fallback check must recognize this input as the seed's,
|
|
# even though embit's internal parity byte for the pubkey is wrong. Taproot
|
|
# pubkeys must be compared by their x-only representation.
|
|
assert PSBTParser.has_matching_input_fingerprint(psbt, PSBTTestData.seed, SettingsConstants.REGTEST)
|
|
|
|
# Comparing x-only looks less strict than the full-key comparison used for
|
|
# non-taproot keys, but nothing is actually given up: a psbt never carries a
|
|
# parity byte for a taproot key, so the x coordinate is all the key material
|
|
# there is to compare. A completely wrong seed will still fail to match.
|
|
wrong_seed = Seed(["bacon"] * 24)
|
|
assert not PSBTParser.has_matching_input_fingerprint(psbt, wrong_seed, SettingsConstants.REGTEST)
|
|
|
|
# Parsing should successfully fill the fingerprint and verify that the input
|
|
# belongs to the seed.
|
|
parser = PSBTParser(p=psbt, seed=PSBTTestData.seed, network=SettingsConstants.REGTEST)
|
|
(_, filled_derivation) = parser.psbt.inputs[0].taproot_bip32_derivations[x_only_public_key]
|
|
assert filled_derivation.fingerprint == parser.root.my_fingerprint
|
|
assert parser.verified_input_derivation_paths == [bip32.parse_path(odd_parity_derivation_path)]
|
|
|
|
|
|
def test_trim_and_sig_count(self):
|
|
"""
|
|
PSBTParser should correctly trim a psbt of all unnecessary data and count the number of
|
|
signatures in the psbt.
|
|
"""
|
|
output = create_output(PSBTTestData.SINGLE_SIG_NATIVE_SEGWIT_RECEIVE, 100_000)
|
|
for input in PSBTTestData.ALL_INPUTS:
|
|
psbt: PSBT = PSBT.parse(a2b_base64(input))
|
|
psbt.outputs.append(output)
|
|
psbt.sign_with(bip32.HDKey.from_seed(self.seed.seed_bytes))
|
|
assert PSBTParser.sig_count(psbt) == 1
|
|
|
|
# TODO: What can we test for before/after trimming?
|
|
PSBTParser.trim(psbt)
|
|
|
|
if input in PSBTTestData.MULTISIG_INPUTS:
|
|
psbt.sign_with(bip32.HDKey.from_seed(PSBTTestData.multisig_key_2.seed_bytes))
|
|
assert PSBTParser.sig_count(psbt) == 2
|
|
|
|
psbt.sign_with(bip32.HDKey.from_seed(PSBTTestData.multisig_key_3.seed_bytes))
|
|
assert PSBTParser.sig_count(psbt) == 3
|
|
|
|
|
|
def test_verify_multisig_output(self):
|
|
"""
|
|
PSBTParser should correctly verify multisig change and self-transfer outputs against the
|
|
provided descriptor or fail to verify if we provide the wrong descriptor.
|
|
"""
|
|
multisig_inputs = [
|
|
PSBTTestData.MULTISIG_NATIVE_SEGWIT_1_INPUT,
|
|
PSBTTestData.MULTISIG_NESTED_SEGWIT_1_INPUT,
|
|
PSBTTestData.MULTISIG_LEGACY_P2SH_1_INPUT
|
|
]
|
|
change_outputs = [
|
|
PSBTTestData.MULTISIG_NATIVE_SEGWIT_CHANGE,
|
|
PSBTTestData.MULTISIG_NESTED_SEGWIT_CHANGE,
|
|
PSBTTestData.MULTISIG_LEGACY_P2SH_CHANGE
|
|
]
|
|
self_transfer_outputs = [
|
|
PSBTTestData.MULTISIG_NATIVE_SEGWIT_SELF_TRANSFER,
|
|
PSBTTestData.MULTISIG_NESTED_SEGWIT_SELF_TRANSFER,
|
|
PSBTTestData.MULTISIG_LEGACY_P2SH_SELF_TRANSFER
|
|
]
|
|
descriptors = [
|
|
PSBTTestData.MULTISIG_NATIVE_SEGWIT_DESCRIPTOR,
|
|
PSBTTestData.MULTISIG_NESTED_SEGWIT_DESCRIPTOR,
|
|
PSBTTestData.MULTISIG_LEGACY_P2SH_DESCRIPTOR
|
|
]
|
|
|
|
for i, psbt_base64 in enumerate(multisig_inputs):
|
|
# Construct a psbt with change & self-transfer outputs of the same type as the input
|
|
psbt: PSBT = PSBT.parse(a2b_base64(psbt_base64))
|
|
psbt.outputs.append(create_output(change_outputs[i], 100_000))
|
|
psbt.outputs.append(create_output(self_transfer_outputs[i], 100_000))
|
|
psbt_parser = PSBTParser(p=psbt, seed=self.seed, network=SettingsConstants.REGTEST)
|
|
|
|
# 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}"))
|
|
if i == j:
|
|
assert psbt_parser.verify_multisig_output(descriptor, change_num=0) == True
|
|
assert psbt_parser.verify_multisig_output(descriptor, change_num=1) == True # self-transfer is considered change
|
|
else:
|
|
assert psbt_parser.verify_multisig_output(descriptor, change_num=0) == False
|
|
assert psbt_parser.verify_multisig_output(descriptor, change_num=1) == False
|
|
|
|
|
|
def test__is_change_branch__distinguishes_the_change_branch_from_the_receive_branch(self):
|
|
"""
|
|
A wallet keeps its change addresses on branch 1 and the addresses it hands out to
|
|
other people on branch 0. The next-to-last element of the derivation path is
|
|
therefore what separates change from a self-transfer back to our own receive addr.
|
|
"""
|
|
assert PSBTParser.is_change_branch(bip32.parse_path("m/84h/1h/0h/1/0")) is True
|
|
assert PSBTParser.is_change_branch(bip32.parse_path("m/84h/1h/0h/0/0")) is False
|
|
|
|
|
|
|
|
# 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 """
|
|
raw = a2b_base64(PSBTTestData.SINGLE_SIG_TAPROOT_WITH_CHANGE)
|
|
tx = PSBT.parse(raw)
|
|
|
|
pp = PSBTParser(p=tx, seed=PSBTTestData.two_input_seed, network=SettingsConstants.REGTEST)
|
|
|
|
assert pp.change_data == [
|
|
{
|
|
'output_index': 0,
|
|
'address': 'bcrt1prz4g6saush37epdwhvwpu78td3q7yfz3xxz37axlx7udck6wracq3rwq30',
|
|
'amount': 2871443918,
|
|
'verified_derivation_path': bip32.parse_path('m/86h/1h/0h/1/1')}
|
|
]
|
|
assert pp.spend_amount == 319049328
|
|
assert pp.change_amount == 2871443918
|
|
assert pp.destination_addresses == ['bcrt1p6p00wazu4nnqac29fvky6vhjnnhku5u2g9njss62rvy7e0yuperq86f5ek']
|
|
assert pp.destination_amounts == [319049328]
|
|
|
|
|
|
|
|
# TODO: Test no longer necessary now that we have exhaustive tests for all types above?
|
|
def test_p2sh_legacy_multisig():
|
|
"""
|
|
Should correctly parse a legacy multisig p2sh (m/45') psbt.
|
|
|
|
PSBT Tx, wallet, and keys
|
|
- Legacy 2-of-3 multisig p2sh; same format as Unchained
|
|
- Regtest xpubs:
|
|
- 0f889044 m/45' tpubD8NkS3Gngj7L4FJRYrwojKhsx2seBhrNrXVdvqaUyvtVe1YDCVcziZVa9g3KouXz7FN5CkGBkoC16nmNu2HcG9ubTdtCbSW8DEXSMHmmu62 (aka "Zoe" test seed)
|
|
- 03cd0a2b m/45' tpubD8HkLLgkdJkVitn1i9CN4HpFKJdom48iKm9PyiXYz5hivn1cGz6H3VeS6ncmCEgamvzQA2Qofu2YSTwWzvuaYWbJDEnvTUtj5R96vACdV6L (aka "Malcolm" test seed)
|
|
- 769f695c m/45' tpubD98hRDKvtATTM8hy5Vvt5ZrvDXwJvrUZm1p1mTKDmd7FqUHY9Wj2k4X1CvxjjtTf3JoChWqYbnWjfkRJ65GQnpVJKbbMfjnGzCwoBUXafyM (aka "Unchained" test seed)
|
|
|
|
- 2 Inputs
|
|
- 199,661 sats
|
|
- 3 Outputs
|
|
- 1 Output spend to another wallet: 50,000 sats to bcrt1q8q5uk9z7ta08h8hvknysd5n80w6f7kuvk5ey2m
|
|
- 1 Output internal self-cycle
|
|
- addresss 2N5eN5vUpgsLHAGzKm2VfmYyvNwXmCug5dH
|
|
- amount 90,000 sats
|
|
- receive address is index 0/5
|
|
- 1 Output change
|
|
- addresss 2NEnA5emHw9Q6vHXr912hGMSPtnrwAMReLz)
|
|
- amount 58,969 sats
|
|
- change addresses is index 1/0
|
|
- Fee 692 sats
|
|
|
|
"Malcolm": better gown govern speak spawn vendor exercise item uncle odor sound cat
|
|
"Zoe": sign sword lift deer ocean insect web lazy sick pencil start select
|
|
"Unchained": slight affair prefer tenant vacant below drill govern surface science affair nut
|
|
|
|
"""
|
|
descriptor = Descriptor.from_string("sh(sortedmulti(2,[0f889044/45h]tpubD8NkS3Gngj7L4FJRYrwojKhsx2seBhrNrXVdvqaUyvtVe1YDCVcziZVa9g3KouXz7FN5CkGBkoC16nmNu2HcG9ubTdtCbSW8DEXSMHmmu62/<0;1>/*,[03cd0a2b/45h]tpubD8HkLLgkdJkVitn1i9CN4HpFKJdom48iKm9PyiXYz5hivn1cGz6H3VeS6ncmCEgamvzQA2Qofu2YSTwWzvuaYWbJDEnvTUtj5R96vACdV6L/<0;1>/*,[769f695c/45h]tpubD98hRDKvtATTM8hy5Vvt5ZrvDXwJvrUZm1p1mTKDmd7FqUHY9Wj2k4X1CvxjjtTf3JoChWqYbnWjfkRJ65GQnpVJKbbMfjnGzCwoBUXafyM/<0;1>/*))#uardwtq4".replace("<0;1>", "{0,1}"))
|
|
psbt_base64 = "cHNidP8BALsCAAAAAk/6v0Yo0tvQSd45NaCoZQj0dS2RU35cF+KXp/RbBltsAAAAAAD9////HN9jZsT3CVXquPrSgGg7/H8DHsy18Ej8uCqaAo8UAsQAAAAAAP3///8DWeYAAAAAAAAXqRTsNEZFrVtk15AU60/MeTWjxGCZJIeQXwEAAAAAABepFIgB1fOQz3ajeGClCsf7Kn4BDG1Zh1DDAAAAAAAAFgAUOCnLFF5fXnue7LTJBtJne7SfW4xlCgAATwEENYfPAQPNCiuAAAAtoPXmwca4wIkJmJbT0l8IJkQoZyf1a0Hf3l3/y+P9YLsCb3zYh0WQQHK0NeKTHOh4tXmreSkeD5t+ayaPudyvWWAIA80KKy0AAIBPAQQ1h88BD4iQRIAAAC1xQDAuEKWgk+mzBHCEZ3Ibco/WRjRUB61ToV0CY2upCgMoWAP8JdgKLlkerHgciZglm2jGmPHrQqLuS8rgRqfwWQgPiJBELQAAgE8BBDWHzwF2n2lcgAAALXtkfUG4BFcO0mnNEFWpGBBvebmUn9Icjd9KVpKJF/MkA59Hw6Sxmpk0lp7SYIoBZJ8BFT3IVY9Ywu6NVn2JGfLmCHafaVwtAACAAAEAUwIAAAABLEtmpDrExA4GJ2itUuWqHQqVsr0WoamuwxKxFA+if3oDAAAAAP3///8BvIUBAAAAAAAXqRSO3FlqUGy1+B6q4UZU1uvY6aDX7YdkCgAAAQMEAQAAAAEEaVIhAhV0XDrvBSAO2pnyRtuyioVgPwb9fxQ7GwNSYKODA6XIIQKHsTdUi0B81JZaK9WASeMWb1ad2snk9iPJ8KKYGJDS+CEC6k1h+lULPMlXOd0x4bIBUwpoTr30vFfoHqr3gSKmlnlTriIGAoexN1SLQHzUllor1YBJ4xZvVp3ayeT2I8nwopgYkNL4EAPNCistAACAAAAAAAQAAAAiBgLqTWH6VQs8yVc53THhsgFTCmhOvfS8V+geqveBIqaWeRAPiJBELQAAgAAAAAAEAAAAIgYCFXRcOu8FIA7amfJG27KKhWA/Bv1/FDsbA1Jgo4MDpcgQdp9pXC0AAIAAAAAABAAAAAABAFMCAAAAASxLZqQ6xMQOBidorVLlqh0KlbK9FqGprsMSsRQPon96BAAAAAD9////ATGGAQAAAAAAF6kU7vgoQJrHpHs0uEBUzW4ogkY3VmuHYwoAAAEDBAEAAAABBGlSIQJMzyIV0BhlIAdtCFRC0nWcJ+qiowFHgStyQvx/Ov9lYSECo3z9DGK1zjn25m1n8NHEoQlcNOnsnF5UA2khAfUhxTUhA9IpGx2/u34tqOV/jRErjSguk6uQK3L743i2LgKpXB+VU64iBgJMzyIV0BhlIAdtCFRC0nWcJ+qiowFHgStyQvx/Ov9lYRADzQorLQAAgAAAAAADAAAAIgYD0ikbHb+7fi2o5X+NESuNKC6Tq5ArcvvjeLYuAqlcH5UQD4iQRC0AAIAAAAAAAwAAACIGAqN8/Qxitc459uZtZ/DRxKEJXDTp7JxeVANpIQH1IcU1EHafaVwtAACAAAAAAAMAAAAAAQBpUiEC7j3OSch6J9P+ZAcOiGeZ4Be3wS4zjzXyU6EzwixfEqQhAxzm3beiYzYmSxMsG0XD5jHoUCvBVSJtRvw41z1X+eT/IQMdnm4JRBPcOlCFGPcpryOjWzlDynm6+8Va+rYxWV5cz1OuIgIDHZ5uCUQT3DpQhRj3Ka8jo1s5Q8p5uvvFWvq2MVleXM8QA80KKy0AAIABAAAAAAAAACICAxzm3beiYzYmSxMsG0XD5jHoUCvBVSJtRvw41z1X+eT/EA+IkEQtAACAAQAAAAAAAAAiAgLuPc5JyHon0/5kBw6IZ5ngF7fBLjOPNfJToTPCLF8SpBB2n2lcLQAAgAEAAAAAAAAAAAEAaVIhAoETdqS+0tZtmj0auNDI9SxxCmUw5Iq9JJjvWjrpPGOCIQKD7KrnsR4fGz0vM67hRh17r9WznwE4JfSEJxSdJMVopyEDvLJhv9fUi2uoUAQN9AQ7fYeUFJMa/iRw2jKBYDn04zpTriICAoPsquexHh8bPS8zruFGHXuv1bOfATgl9IQnFJ0kxWinEAPNCistAACAAAAAAAUAAAAiAgKBE3akvtLWbZo9GrjQyPUscQplMOSKvSSY71o66TxjghAPiJBELQAAgAAAAAAFAAAAIgIDvLJhv9fUi2uoUAQN9AQ7fYeUFJMa/iRw2jKBYDn04zoQdp9pXC0AAIAAAAAABQAAAAAA"
|
|
raw = a2b_base64(psbt_base64)
|
|
tx = PSBT.parse(raw)
|
|
|
|
# 03cd0a2b test seed
|
|
mnemonic = "better gown govern speak spawn vendor exercise item uncle odor sound cat".split()
|
|
seed = Seed(mnemonic)
|
|
assert seed.get_fingerprint() == "03cd0a2b"
|
|
|
|
psbt_parser = PSBTParser(p=tx, seed=seed, network=SettingsConstants.REGTEST)
|
|
|
|
assert psbt_parser.spend_amount == 50000
|
|
assert psbt_parser.change_amount == 90000 + 58969
|
|
assert psbt_parser.fee_amount == 692
|
|
|
|
assert psbt_parser.destination_addresses == ['bcrt1q8q5uk9z7ta08h8hvknysd5n80w6f7kuvk5ey2m']
|
|
assert psbt_parser.destination_amounts == [50000]
|
|
|
|
assert psbt_parser.get_change_data(0)['address'] == '2NEnA5emHw9Q6vHXr912hGMSPtnrwAMReLz'
|
|
assert psbt_parser.get_change_data(0)["amount"] == 58969
|
|
|
|
assert psbt_parser.get_change_data(1)['address'] == '2N5eN5vUpgsLHAGzKm2VfmYyvNwXmCug5dH'
|
|
assert psbt_parser.get_change_data(1)["amount"] == 90000
|
|
|
|
# We should be able to verify the change addr
|
|
assert psbt_parser.verify_multisig_output(descriptor, 0)
|
|
|
|
# And the self-transfer receive addr
|
|
assert psbt_parser.verify_multisig_output(descriptor, 1)
|
|
|
|
|
|
|
|
# TODO: Test no longer necessary now that we have exhaustive tests for all types above?
|
|
def test_p2sh_p2wpkh_nested_segwit():
|
|
"""
|
|
Should correctly parse a nested segwit (m/49'/1'/0') psbt.
|
|
|
|
PSBT Tx, wallet, and keys
|
|
- nested segwit single sig
|
|
- Regtest xpubs:
|
|
- c751dc07 c751dc07 tpubDDS23bf7c9mdfWpuvA61HHCYDusq25UtMNYsFagKPNMNWHSm8bvwmNNP2KSpivN3gQWAK8fhDFk3dzgoBn9rPoMncKxJuqNAv7sJMShbZ6i
|
|
|
|
- 1 Inputs
|
|
- 149,009 sats
|
|
- 2 Outputs
|
|
- 1 Output spend to another wallet: 93,000 sats to tb1qs7mdpjq7g7zq46vvycr8d6udc7za726ut8har9krfxpnc7kr04gqmdy2e4
|
|
- 1 Output change
|
|
- addresss 2Mz3MthXyM4YDjLPw1V4PAacKt4pD8Cz8N3)
|
|
- amount 55,832 sats
|
|
- change addresses is index 1/1
|
|
- Fee 177 sats
|
|
|
|
seed: goddess rough corn exclude cream trial fee trumpet million prevent gaze power
|
|
passphrase: test
|
|
|
|
"""
|
|
|
|
descriptor = Descriptor.from_string("sh(wpkh([c751dc07/49h/1h/0h]tpubDDS23bf7c9mdfWpuvA61HHCYDusq25UtMNYsFagKPNMNWHSm8bvwmNNP2KSpivN3gQWAK8fhDFk3dzgoBn9rPoMncKxJuqNAv7sJMShbZ6i/<0;1>/*))#7sn8gf37".replace("<0;1>", "{0,1}"))
|
|
psbt_base64 = "cHNidP8BAH4CAAAAAXfY5crHl+bXtTvKvdo2MaFQeIXw+P+3kzZwBRgw84lFAQAAAAD9////AhjaAAAAAAAAF6kUSop8lEmO4FB1AyV1GJe2bygA7ASHSGsBAAAAAAAiACCHttDIHkeECumMJgZ2643Hhd8rXFnv0ZbDSYM8esN9UIouEwBPAQQ1h88Dv3UWAIAAAACfHgAYuw3ODwXCSP0valI9edAB1t3EInR2TXkbOd+F+AJgmJs8XUkZD5zQAgd3+/ijOqVphlWUMzxDnRorBQYEgxDHUdwHMQAAgAEAAIAAAACAAAEBIBFGAgAAAAAAF6kU7ijES3iWT8u0+44/blPlLfh9WkyHAQMEAQAAAAEEFgAUX7JspW1r0gC+WkUHwGABJ8DU9f8iBgO1/adRC+r8XJ/bjnfdwk3740n0m8gE3+xN8GHsNrxDUxjHUdwHMQAAgAEAAIAAAACAAQAAAAAAAAAAAQAWABT8V9vY29XR8niVYdVSF9H4zRTAbiICArH6DjPShnzXiaAnc2BR1f61QQliH0BOhqAvksByf3e9GMdR3AcxAACAAQAAgAAAAIABAAAAAQAAAAAA"
|
|
raw = a2b_base64(psbt_base64)
|
|
tx = PSBT.parse(raw)
|
|
|
|
# 03cd0a2b test seed
|
|
mnemonic = "goddess rough corn exclude cream trial fee trumpet million prevent gaze power".split()
|
|
seed = Seed(mnemonic=mnemonic, passphrase="test")
|
|
assert seed.get_fingerprint() == "c751dc07"
|
|
|
|
psbt_parser = PSBTParser(p=tx, seed=seed, network=SettingsConstants.TESTNET)
|
|
|
|
assert psbt_parser.spend_amount == 93000
|
|
assert psbt_parser.change_amount == 55832
|
|
assert psbt_parser.fee_amount == 177
|
|
|
|
assert psbt_parser.destination_addresses == ['tb1qs7mdpjq7g7zq46vvycr8d6udc7za726ut8har9krfxpnc7kr04gqmdy2e4']
|
|
assert psbt_parser.destination_amounts == [93000]
|
|
|
|
assert psbt_parser.get_change_data(0)['address'] == '2Mz3MthXyM4YDjLPw1V4PAacKt4pD8Cz8N3'
|
|
assert psbt_parser.get_change_data(0)["amount"] == 55832
|
|
|
|
# We should be able to verify the change addr
|
|
assert psbt_parser.verify_multisig_output(descriptor, 0)
|
|
|
|
|
|
|
|
def test_parse_op_return_content():
|
|
"""
|
|
Should successfully parse the OP_RETURN content from a PSBT.
|
|
|
|
PSBT Tx and Wallet Details
|
|
- Single Sig Wallet P2WPKH (Native Segwit) with no passphrase
|
|
- Regtest 0fb882ff m/84'/1'/0' tpubDCfk37PqcQx6nFtFVuYHvRLJHxvYj33NjHkKRyRmWyCjyJ64sYBXyVjsTHaLBp5GLhM91VBgJ8nKDWDu52J2xVRy64c7ybEjjyWQJuQGLcg
|
|
- 1 Input
|
|
- 99,992,460 sats
|
|
- 2 Outputs
|
|
- 1 Output back to self (bcrt1qvwkhakqhz7m7kmz6332avatsmdy32m644g86vv) of 99,992,296 sats
|
|
- 1 OP_RETURN: "Chancellor on the brink of third bailout"
|
|
- Fee 164 sats
|
|
"""
|
|
psbt_base64 = "cHNidP8BAIYCAAAAATpQ10o+gKdZ8ThpKsbfHiHYn3NhvUrQ5DvW0ZWX8jKLAAAAAAD9////AujC9QUAAAAAFgAUY61+2BcXt+tsWoxV1nVw20kVb1UAAAAAAAAAACtqTChDaGFuY2VsbG9yIG9uIHRoZSBicmluayBvZiB0aGlyZCBiYWlsb3V0aQAAAE8BBDWHzwNXmUmVgAAAANRFa7R5gYD84Wbha3d1QnjgfYPOBw87on6cXS32WoyqAsPFtPxB7PRTdbujUnBPUVDh9YUBtwrl4nc0OcRNGvIyEA+4gv9UAACAAQAAgAAAAIAAAQB0AgAAAAGNFK/1X0fP5q+nu5XX7Tk2VRa0EL+jkGI9CHiJvsjZCgAAAAAA/f///wKMw/UFAAAAABYAFIpZMNnUU6cQt8Q0YpZ0pnvsSA5fAAAAAAAAAAAZakwWYml0Y29pbiBpcyBmcmVlIHNwZWVjaGgAAAABAR+Mw/UFAAAAABYAFIpZMNnUU6cQt8Q0YpZ0pnvsSA5fAQMEAQAAACIGAvxDI0eNI1oQ2AU69R7A0jf+hUdilWCgrWHgdzkqlaXMGA+4gv9UAACAAQAAgAAAAIAAAAAAAQAAAAAiAgK9qKtzGWyiRrpmupdA99NVLriz3GQy6cENbyD19sfl/hgPuIL/VAAAgAEAAIAAAACAAAAAAAIAAAAAAA=="
|
|
|
|
raw = a2b_base64(psbt_base64)
|
|
tx = PSBT.parse(raw)
|
|
|
|
mnemonic = "model ensure search plunge galaxy firm exclude brain satoshi meadow cable roast".split()
|
|
pw = ""
|
|
seed = Seed(mnemonic, passphrase=pw)
|
|
|
|
psbt_parser = PSBTParser(p=tx, seed=seed, network=SettingsConstants.REGTEST)
|
|
|
|
# Remember to do the comparison as bytes
|
|
assert psbt_parser.op_return_data == "Chancellor on the brink of third bailout".encode()
|
|
|
|
# PSBT is an internal self-spend to the its own receive addr, but the parser categorizes it as "change"
|
|
assert psbt_parser.change_data == [
|
|
{
|
|
'output_index': 0,
|
|
'address': 'bcrt1qvwkhakqhz7m7kmz6332avatsmdy32m644g86vv',
|
|
'amount': 99992296,
|
|
'verified_derivation_path': bip32.parse_path("m/84h/1h/0h/0/2")}
|
|
]
|
|
assert psbt_parser.spend_amount == 0 # This is a self-spend; no value being spent, other than the tx fee
|
|
assert psbt_parser.change_amount == 99992296
|
|
assert psbt_parser.destination_addresses == []
|
|
assert psbt_parser.destination_amounts == []
|
|
|
|
|
|
|
|
class TestPSBTParserOptimizations:
|
|
"""
|
|
Guard tests for the parse-time optimizations in PSBTParser: that each one actually
|
|
takes effect, and that none of them changes the result of a parse.
|
|
"""
|
|
seed = PSBTTestData.seed
|
|
|
|
def _root(self, seed: Seed = None) -> bip32.HDKey:
|
|
if seed is None:
|
|
seed = self.seed
|
|
return bip32.HDKey.from_seed(
|
|
seed.seed_bytes, version=NETWORKS["main"]["xprv"])
|
|
|
|
|
|
def assert_same_parse_result(self, parser_a: PSBTParser, parser_b: PSBTParser):
|
|
"""
|
|
Asserts that two parses produced the same result, field by field so that a failure
|
|
names the exact field that differs.
|
|
|
|
The fill path writes recovered fingerprints back into the psbt, so the serialized
|
|
psbt is compared too, not just the parser's own attributes.
|
|
"""
|
|
assert parser_a.policy == parser_b.policy
|
|
assert parser_a.input_amount == parser_b.input_amount
|
|
assert parser_a.spend_amount == parser_b.spend_amount
|
|
assert parser_a.change_amount == parser_b.change_amount
|
|
assert parser_a.fee_amount == parser_b.fee_amount
|
|
assert parser_a.num_inputs == parser_b.num_inputs
|
|
assert parser_a.destination_addresses == parser_b.destination_addresses
|
|
assert parser_a.destination_amounts == parser_b.destination_amounts
|
|
assert parser_a.change_data == parser_b.change_data
|
|
assert parser_a.op_return_data == parser_b.op_return_data
|
|
assert parser_a.psbt.serialize() == parser_b.psbt.serialize()
|
|
|
|
|
|
def cache_size_recorder(self, cache_sizes: list):
|
|
"""
|
|
Returns a stand-in for _derive_with_cache that derives exactly as the real one
|
|
does, but appends the cache's size to cache_sizes on the way out of every call.
|
|
|
|
The cache is a local inside parse(), so intercepting the calls it gets handed to
|
|
is the only way to see how large it grew.
|
|
"""
|
|
real_derive_with_cache = PSBTParser._derive_with_cache
|
|
|
|
def recorded(parent_key, derivation_path, cache=None):
|
|
derived_key = real_derive_with_cache(parent_key, derivation_path, cache)
|
|
cache_sizes.append(len(cache))
|
|
return derived_key
|
|
|
|
return recorded
|
|
|
|
|
|
def test_my_fingerprint_equals_child0_fingerprint(self):
|
|
"""
|
|
Reading my_fingerprint in place of child(0).fingerprint is byte-identical,
|
|
because HDKey.child(0) sets its .fingerprint to hash160(parent.sec())[:4],
|
|
which is exactly parent.my_fingerprint.
|
|
|
|
This is really a unit test / regression test against embit itself, but it is worth
|
|
testing here.
|
|
"""
|
|
root = self._root()
|
|
assert root.my_fingerprint == root.child(0).fingerprint
|
|
|
|
|
|
def test_zero_fingerprint_fill_over_many_inputs(self, monkeypatch):
|
|
"""
|
|
The inputs in this test have their fingerprints blanked (set to all zero), which
|
|
should then require one full derivation per input to work out whether that input
|
|
is ours.
|
|
|
|
The artificial inputs in this test share the same full derivation path so each
|
|
level should only be derived once total rather than once per input.
|
|
"""
|
|
psbt = PSBT.parse(a2b_base64(PSBTTestData.SINGLE_SIG_NATIVE_SEGWIT_1_INPUT))
|
|
master_fingerprint = self._root().my_fingerprint
|
|
|
|
# Sanity check that this artificial psbt has no outputs. We have to make sure that
|
|
# the derivation counts at the end of the test were only for inputs, not outputs.
|
|
assert len(psbt.outputs) == 0, "fixture is expected to have no outputs"
|
|
|
|
# Artificially boost this test psbt to 10 total inputs from the same wallet
|
|
for _ in range(9):
|
|
psbt.inputs.append(deepcopy(psbt.inputs[0]))
|
|
|
|
# Zero out all of the inputs' fingerprints
|
|
num_zeroed = 0
|
|
for inp in psbt.inputs:
|
|
for pub, dp in list(inp.bip32_derivations.items()):
|
|
inp.bip32_derivations[pub] = DerivationPath(b"\x00\x00\x00\x00", dp.derivation)
|
|
num_zeroed += 1
|
|
assert num_zeroed == len(psbt.inputs), "fixture did not yield one derivation per input"
|
|
|
|
num_levels = len(list(psbt.inputs[0].bip32_derivations.values())[0].derivation)
|
|
|
|
# Attach a counter to track every level actually derived during the parse
|
|
num_derivations = 0
|
|
uncounted_child = bip32.HDKey.child
|
|
def counting_child(self, index, hardened=False):
|
|
nonlocal num_derivations # reference the above var outside the function scope
|
|
num_derivations += 1
|
|
return uncounted_child(self, index, hardened)
|
|
monkeypatch.setattr(bip32.HDKey, "child", counting_child)
|
|
|
|
# Instantiating the parser with the psbt will automatically fill in the zeroed
|
|
# fingerprints.
|
|
PSBTParser(psbt, self.seed, network=SettingsConstants.MAINNET)
|
|
|
|
# All 10 inputs share the one derivation path, so each of its levels should have
|
|
# been derived exactly once between them, rather than once per input.
|
|
assert num_derivations == num_levels
|
|
|
|
# Sanity check: num_derivations could be correct when just ONE of the ten inputs
|
|
# was processed. Confirm that EVERY input really was processed by verifying that
|
|
# each input was filled in with the correct fingerprint.
|
|
for inp in psbt.inputs:
|
|
for pub, dp in inp.bip32_derivations.items():
|
|
assert dp.fingerprint == master_fingerprint
|
|
|
|
|
|
def test_derive_with_cache_does_not_cross_parent_keys(self):
|
|
"""
|
|
Multisig traverses the same relative derivation path below every cosigner's
|
|
account xpub. Verify that the cache properly keeps the parents' cache data
|
|
separate despite having derivations that share the same relative path.
|
|
"""
|
|
# Two cosigners' account xpubs from the multisig test fixtures
|
|
cosigner_a_xpub = self._root(PSBTTestData.multisig_key_2).derive("m/48h/0h/0h/2h").to_public()
|
|
cosigner_b_xpub = self._root(PSBTTestData.multisig_key_3).derive("m/48h/0h/0h/2h").to_public()
|
|
|
|
# The receive address at index 5 is: m/48h/0h/0h/2h/0/5. The parent xpubs already
|
|
# have the first 4 levels derived, so this operation is only the final two levels.
|
|
receive_index_5 = [0, 5]
|
|
cache = {}
|
|
|
|
# The cache here isn't providing any speedup (there are no derivations in the
|
|
# cache to take advantage of), but we're just testing that the cache doesn't
|
|
# confuse/combine the two cosigners' derivation data.
|
|
from_a = PSBTParser._derive_with_cache(cosigner_a_xpub, receive_index_5, cache)
|
|
from_b = PSBTParser._derive_with_cache(cosigner_b_xpub, receive_index_5, cache)
|
|
|
|
# Two levels should have been added for each cosigner
|
|
assert len(cache) == 4
|
|
|
|
# The resulting derived child keys should be different
|
|
assert from_a.key.sec() != from_b.key.sec()
|
|
|
|
# The result derived with the cache must be identical to deriving from the xpub
|
|
# directly.
|
|
assert from_a.key.sec() == cosigner_a_xpub.derive(receive_index_5).key.sec()
|
|
assert from_b.key.sec() == cosigner_b_xpub.derive(receive_index_5).key.sec()
|
|
|
|
|
|
def test_get_cosigners_identical_with_and_without_cache(self):
|
|
"""
|
|
The cache is transparent to callers: _get_cosigners returns the same cosigner
|
|
list whether it derives every level itself or reads them back out of the cache.
|
|
"""
|
|
psbt = PSBT.parse(a2b_base64(PSBTTestData.MULTISIG_NATIVE_SEGWIT_1_INPUT))
|
|
inp = psbt.inputs[0]
|
|
pubkeys = list(inp.bip32_derivations.keys())
|
|
|
|
# No cache at all; every level is derived directly
|
|
uncached = PSBTParser._get_cosigners(pubkeys, inp.bip32_derivations, psbt.xpubs, None)
|
|
|
|
# An empty cache still has to derive every level, but now stores each one
|
|
child_key_derivation_cache = {}
|
|
populating_the_cache = PSBTParser._get_cosigners(pubkeys, inp.bip32_derivations, psbt.xpubs, child_key_derivation_cache)
|
|
|
|
# 3 cosigners x 2 levels each
|
|
assert len(child_key_derivation_cache) == 6
|
|
|
|
# The same call against the now-populated cache reads those levels back instead
|
|
# of deriving them. Each level sits below a different cosigner's xpub, so a cache
|
|
# that confused parents would return the wrong cosigner here.
|
|
reading_from_the_cache = PSBTParser._get_cosigners(pubkeys, inp.bip32_derivations, psbt.xpubs, child_key_derivation_cache)
|
|
|
|
assert populating_the_cache == uncached
|
|
assert reading_from_the_cache == uncached
|
|
|
|
|
|
def test_cache_does_not_change_parse_output(self):
|
|
"""
|
|
The whole point of the cache is that it changes nothing at all. Parse the same
|
|
psbt twice — once normally, once with the cache discarded so that every derivation
|
|
falls through to embit's own HDKey.derive() — and require identical parser state
|
|
and identical resulting psbt bytes.
|
|
|
|
Single-sig and multisig each get a run because they reach the cache from different
|
|
starting points: single-sig traverses down from our own root, multisig down from
|
|
each cosigner's account xpub.
|
|
"""
|
|
def build_psbt(input_base64: str, change_hex: str) -> PSBT:
|
|
# A fresh psbt for each parse: the base psbt plus its change output, twice.
|
|
psbt = PSBT.parse(a2b_base64(input_base64))
|
|
psbt.outputs.append(create_output(change_hex, 10_000))
|
|
|
|
# Add a duplicate output to ensure that the cache yields some hits; the second
|
|
# output will traverse the same levels the first one just cached.
|
|
psbt.outputs.append(create_output(change_hex, 10_000))
|
|
return psbt
|
|
|
|
def assert_cache_makes_no_difference(input_base64: str, change_hex: str):
|
|
# Store the real function before the patches below replace it. Each replacement
|
|
# still needs access to the real function to do the actual deriving.
|
|
real_derive_with_cache = PSBTParser._derive_with_cache
|
|
|
|
# This version of the replacement will derive exactly as the real cache-backed
|
|
# function does, but will also record the cache it was handed on each call.
|
|
caches_received = []
|
|
def recording_derive_with_cache(parent_key, derivation_path, cache=None):
|
|
caches_received.append(cache)
|
|
return real_derive_with_cache(parent_key, derivation_path, cache)
|
|
|
|
with patch.object(PSBTParser, "_derive_with_cache", staticmethod(recording_derive_with_cache)):
|
|
with_cache = PSBTParser(
|
|
build_psbt(input_base64, change_hex), self.seed, network=SettingsConstants.REGTEST)
|
|
|
|
# Sanity check: the cache was actually available during the parse
|
|
assert any(cache is not None for cache in caches_received)
|
|
|
|
# And then this version discards the cache, which sends the real function down
|
|
# its no-cache branch.
|
|
def cache_free_derive(parent_key, derivation_path, cache=None):
|
|
return real_derive_with_cache(parent_key, derivation_path)
|
|
|
|
with patch.object(PSBTParser, "_derive_with_cache", staticmethod(cache_free_derive)):
|
|
without_cache = PSBTParser(
|
|
build_psbt(input_base64, change_hex), self.seed, network=SettingsConstants.REGTEST)
|
|
|
|
# Regardless of whether or not the cache was available, the resulting parser
|
|
# state should be identical.
|
|
self.assert_same_parse_result(with_cache, without_cache)
|
|
|
|
assert_cache_makes_no_difference(PSBTTestData.SINGLE_SIG_NATIVE_SEGWIT_1_INPUT, PSBTTestData.SINGLE_SIG_NATIVE_SEGWIT_CHANGE)
|
|
assert_cache_makes_no_difference(PSBTTestData.MULTISIG_NATIVE_SEGWIT_1_INPUT, PSBTTestData.MULTISIG_NATIVE_SEGWIT_CHANGE)
|
|
|
|
|
|
def test_maxed_out_cache_does_not_change_parse_output(self):
|
|
"""
|
|
There should be no effect on the parse output when the cache is maxed out.
|
|
|
|
Parse a multisig and a single-sig psbt with the cache free to grow, then parse
|
|
them again with the cap low enough that both hit the cap partway through. Verify
|
|
that we get the identical parser state each time.
|
|
"""
|
|
multisig_case = (PSBTTestData.MULTISIG_NATIVE_SEGWIT_1_INPUT, PSBTTestData.MULTISIG_NATIVE_SEGWIT_CHANGE)
|
|
singlesig_case = (PSBTTestData.SINGLE_SIG_NATIVE_SEGWIT_1_INPUT, PSBTTestData.SINGLE_SIG_NATIVE_SEGWIT_CHANGE)
|
|
|
|
def build_psbt(case: tuple) -> PSBT:
|
|
# A fresh psbt for each parse: the case's base psbt plus its change output
|
|
input_base64, change_hex = case
|
|
psbt = PSBT.parse(a2b_base64(input_base64))
|
|
psbt.outputs.append(create_output(change_hex, 10_000))
|
|
return psbt
|
|
|
|
# Record how large the cache grew over the course of each parse
|
|
unconstrained_sizes = []
|
|
with patch.object(PSBTParser, "_derive_with_cache", staticmethod(self.cache_size_recorder(unconstrained_sizes))):
|
|
multisig_unconstrained = PSBTParser(build_psbt(multisig_case), self.seed, network=SettingsConstants.REGTEST)
|
|
singlesig_unconstrained = PSBTParser(build_psbt(singlesig_case), self.seed, network=SettingsConstants.REGTEST)
|
|
|
|
# Now constrain the cache enough that both psbts fill it partway through their
|
|
# parse.
|
|
cap = 3
|
|
capped_sizes = []
|
|
with patch.object(PSBTParser, "MAX_CACHED_DERIVATIONS", cap):
|
|
with patch.object(PSBTParser, "_derive_with_cache", staticmethod(self.cache_size_recorder(capped_sizes))):
|
|
multisig_capped = PSBTParser(build_psbt(multisig_case), self.seed, network=SettingsConstants.REGTEST)
|
|
singlesig_capped = PSBTParser(build_psbt(singlesig_case), self.seed, network=SettingsConstants.REGTEST)
|
|
|
|
self.assert_same_parse_result(multisig_unconstrained, multisig_capped)
|
|
self.assert_same_parse_result(singlesig_unconstrained, singlesig_capped)
|
|
|
|
# Sanity check: this test depends on the unconstrained cache actually being larger
|
|
# than the capped cache's max.
|
|
assert max(unconstrained_sizes) > cap
|
|
assert max(capped_sizes) == cap
|
|
|
|
|
|
|
|
class PSBTParserOwnershipTestBase:
|
|
"""
|
|
Base class for the two ownership test classes below. Provides:
|
|
* the signing seed
|
|
* a psbt made of nothing but that seed's own scopes
|
|
* the parse call under test.
|
|
"""
|
|
seed = PSBTTestData.seed
|
|
|
|
def _root(self) -> bip32.HDKey:
|
|
return root_for_seed(self.seed)
|
|
|
|
|
|
def _psbt_with_change(self, input_base64: str = None, change_hex: str = None) -> PSBT:
|
|
"""
|
|
A base psbt plus its own change output. But no external recipient output is added,
|
|
so all paths are owned by the seed.
|
|
"""
|
|
if input_base64 is None:
|
|
input_base64 = PSBTTestData.SINGLE_SIG_NATIVE_SEGWIT_1_INPUT
|
|
if change_hex is None:
|
|
change_hex = PSBTTestData.SINGLE_SIG_NATIVE_SEGWIT_CHANGE
|
|
|
|
psbt = PSBT.parse(a2b_base64(input_base64))
|
|
psbt.outputs.append(create_output(change_hex, 10_000))
|
|
return psbt
|
|
|
|
|
|
def _parse(self, psbt: PSBT) -> PSBTParser:
|
|
return PSBTParser(psbt, self.seed, network=SettingsConstants.REGTEST)
|
|
|
|
|
|
|
|
class TestPSBTParserSeedOwnership(PSBTParserOwnershipTestBase):
|
|
"""
|
|
The ownership scan: what the signing seed provably owns in a psbt and the rejection
|
|
of any psbt whose ownership claims do not hold up.
|
|
"""
|
|
def test__seed_owns_pubkey__accepts_the_seeds_own_key(self):
|
|
"""
|
|
seed_owns_pubkey should confirm the simple base case that a pubkey directly
|
|
derived from the seed is owned by the seed.
|
|
"""
|
|
root = self._root()
|
|
derivation_path = bip32.parse_path("m/84h/1h/0h/0/0")
|
|
public_key = root.derive(derivation_path).get_public_key()
|
|
|
|
assert PSBTParser.seed_owns_pubkey(root, derivation_path, public_key, child_key_derivation_cache=None) is True
|
|
|
|
|
|
def test__seed_owns_pubkey__rejects_a_key_the_seed_does_not_control(self):
|
|
"""
|
|
seed_owns_pubkey should reject a pubkey that the seed does not control.
|
|
"""
|
|
root = self._root()
|
|
derivation_path = bip32.parse_path("m/84h/1h/0h/0/0")
|
|
|
|
assert PSBTParser.seed_owns_pubkey(root, derivation_path, foreign_public_key(), child_key_derivation_cache=None) is False
|
|
|
|
|
|
def test__seed_owns_pubkey__rejects_the_seeds_own_key_at_the_wrong_path(self):
|
|
"""
|
|
The path is as much a part of the claim as the key is. The seed owns this key, but
|
|
not at the path the claim names so it is still a false claim.
|
|
"""
|
|
root = self._root()
|
|
public_key = root.derive(bip32.parse_path("m/84h/1h/0h/0/0")).get_public_key()
|
|
|
|
assert PSBTParser.seed_owns_pubkey(root, bip32.parse_path("m/84h/1h/0h/0/1"), public_key, child_key_derivation_cache=None) is False
|
|
|
|
|
|
def test__seed_owns_pubkey__compares_taproot_keys_without_parity(self):
|
|
"""
|
|
Taproot keys are x-only, so a key the seed genuinely owns routinely differs from
|
|
the derived key by the parity byte alone. Comparing the full key would read that
|
|
as a stranger's key and reject the seed's own output.
|
|
"""
|
|
root = self._root()
|
|
|
|
# Address index 1 is the first whose derived key has odd parity, which is the
|
|
# case where a full comparison and an x-only comparison disagree.
|
|
derivation_path = bip32.parse_path("m/86h/1h/0h/0/1")
|
|
public_key = root.derive(derivation_path).get_public_key()
|
|
|
|
# Sanity check: the key's first byte is its parity, 0x02 for even, 0x03 for odd
|
|
assert public_key.sec()[0] == 0x03
|
|
|
|
# The psbt carries the key x-only, so reconstruct what it would hold: the same x
|
|
# coordinate, with the EVEN-parity prefix.
|
|
as_written_in_psbt = PublicKey.parse(b"\x02" + public_key.xonly())
|
|
|
|
# Taproot is x-only so it ignores the now-even parity byte
|
|
assert PSBTParser.seed_owns_pubkey(root, derivation_path, as_written_in_psbt, child_key_derivation_cache=None, is_taproot=True) is True
|
|
|
|
# Non-taproot compares the full key so it rejects the key with the wrong parity byte
|
|
assert PSBTParser.seed_owns_pubkey(root, derivation_path, as_written_in_psbt, child_key_derivation_cache=None) is False
|
|
|
|
|
|
def test__parse__populates_verified_derivation_paths(self):
|
|
"""
|
|
The scan writes one verified_*_derivation_paths entry per input and per output
|
|
(regardless of ownership; not-owned paths are recorded as None) in the psbt's own
|
|
order.
|
|
"""
|
|
# The psbt fixture has one input and one output, both of which are owned by the
|
|
# seed.
|
|
psbt = self._psbt_with_change()
|
|
psbt_parser = self._parse(psbt)
|
|
|
|
assert len(psbt_parser.verified_input_derivation_paths) == len(psbt.inputs)
|
|
assert len(psbt_parser.verified_output_derivation_paths) == len(psbt.outputs)
|
|
|
|
# Every recorded path is one the seed really does derive the scope's key at
|
|
for scopes, verified_derivation_paths in [
|
|
(psbt.inputs, psbt_parser.verified_input_derivation_paths),
|
|
(psbt.outputs, psbt_parser.verified_output_derivation_paths),
|
|
]:
|
|
for scope, verified_derivation_path in zip(scopes, verified_derivation_paths):
|
|
assert verified_derivation_path is not None
|
|
public_key = list(scope.bip32_derivations.keys())[0]
|
|
assert PSBTParser.seed_owns_pubkey(psbt_parser.root, verified_derivation_path, public_key, child_key_derivation_cache=None) is True
|
|
|
|
|
|
def test__parse__verified_derivation_paths_none_for_not_owned_output(self):
|
|
"""
|
|
An output paying someone else is not a failure; the seed simply owns nothing
|
|
there so the matching verified_output_derivation_paths should be None.
|
|
"""
|
|
psbt = self._psbt_with_change()
|
|
|
|
# Add an external recipient at index 0
|
|
psbt.outputs.insert(0, create_output(PSBTTestData.SINGLE_SIG_NATIVE_SEGWIT_RECEIVE, 10_000))
|
|
|
|
psbt_parser = self._parse(psbt)
|
|
|
|
assert psbt_parser.verified_output_derivation_paths[0] is None
|
|
assert psbt_parser.verified_output_derivation_paths[1] is not None
|
|
|
|
|
|
def test__parse__verified_derivation_paths_none_for_not_owned_input(self):
|
|
"""
|
|
A collaborative spend also includes an input belonging to another party, in two
|
|
shapes: a payjoin counterparty's input arrives finalized with no derivation info
|
|
at all (BIP-78 requires the sender to verify no keypaths appear anywhere), while
|
|
a coordinator managing every party's wallet writes each party's genuine
|
|
derivation entry. Neither is a failure; the seed simply owns nothing there.
|
|
"""
|
|
psbt = self._psbt_with_change()
|
|
|
|
# The other party's input: their utxo, carrying no derivation info
|
|
foreign_input = deepcopy(psbt.inputs[0])
|
|
foreign_input.bip32_derivations.clear()
|
|
foreign_input.witness_utxo.script_pubkey = script.p2wpkh(foreign_public_key())
|
|
psbt.inputs.append(foreign_input)
|
|
|
|
# The payjoin shape
|
|
psbt_parser = self._parse(psbt)
|
|
assert psbt_parser.verified_input_derivation_paths[0] is not None
|
|
assert psbt_parser.verified_input_derivation_paths[1] is None
|
|
|
|
# The coordinated shape: the derivation entry is truthful, naming the other
|
|
# party's fingerprint and a key that party really controls.
|
|
claim_seed_owns_key(foreign_input, "m/84h/1h/0h/0/0", foreign_public_key(), seed=PSBTTestData.recipient_seed)
|
|
psbt_parser = self._parse(psbt)
|
|
assert psbt_parser.verified_input_derivation_paths[0] is not None
|
|
assert psbt_parser.verified_input_derivation_paths[1] is None
|
|
|
|
|
|
def test__parse__rejects_a_forged_claim_on_an_input(self):
|
|
"""
|
|
`parse` should raise PSBTInputOwnershipClaimError when a psbt carries a false
|
|
claim that the seed owns an input that it does not actually control.
|
|
"""
|
|
psbt = self._psbt_with_change()
|
|
claim_seed_owns_key(psbt.inputs[0], "m/84h/1h/0h/0/0", foreign_public_key())
|
|
|
|
with pytest.raises(PSBTInputOwnershipClaimError):
|
|
self._parse(psbt)
|
|
|
|
|
|
def test__parse__rejects_a_forged_claim_on_an_output(self):
|
|
"""
|
|
`parse` should raise PSBTOutputOwnershipClaimError when a psbt carries a false
|
|
claim that the seed owns an output that it does not actually control. This is
|
|
the fake-change attack scenario that is more severe than a forged input claim.
|
|
"""
|
|
psbt = self._psbt_with_change()
|
|
claim_seed_owns_key(psbt.outputs[0], "m/84h/1h/0h/1/0", foreign_public_key())
|
|
|
|
# The output-scope error specifically: the dire, attack-framed rejection
|
|
with pytest.raises(PSBTOutputOwnershipClaimError):
|
|
self._parse(psbt)
|
|
|
|
|
|
def test__parse__forged_output_claim_outranks_forged_input_claim(self):
|
|
"""
|
|
The scan stops at the first false claim, so scan order decides which error the
|
|
psbt is reported with. A false output claim is the fake-change forgery and must
|
|
be the one reported even when the same psbt also carries a false input claim --
|
|
otherwise an attacker could plant a throwaway input claim just to be routed to
|
|
the milder input-scope warning.
|
|
"""
|
|
psbt = self._psbt_with_change()
|
|
claim_seed_owns_key(psbt.inputs[0], "m/84h/1h/0h/0/0", foreign_public_key())
|
|
claim_seed_owns_key(psbt.outputs[0], "m/84h/1h/0h/1/0", foreign_public_key())
|
|
|
|
# The output error is the more severe issue
|
|
with pytest.raises(PSBTOutputOwnershipClaimError):
|
|
self._parse(psbt)
|
|
|
|
|
|
def test__parse__rejects_a_forged_taproot_claim(self):
|
|
"""
|
|
A false taproot claim is rejected the same way as an ecdsa one: the input-scope
|
|
error on an input, the output-scope (attack) error on an output. Taproot claims
|
|
live in their own taproot_bip32_derivations dict, so the scan's coverage of that
|
|
dict is pinned on both scope types.
|
|
"""
|
|
psbt = self._psbt_with_change(PSBTTestData.SINGLE_SIG_TAPROOT_1_INPUT, PSBTTestData.SINGLE_SIG_TAPROOT_CHANGE)
|
|
claim_seed_owns_key(psbt.inputs[0], "m/86h/1h/0h/0/0", foreign_public_key(), is_taproot=True)
|
|
|
|
with pytest.raises(PSBTInputOwnershipClaimError):
|
|
self._parse(psbt)
|
|
|
|
# The same forgery on a taproot output gets the attack classification
|
|
psbt = self._psbt_with_change(PSBTTestData.SINGLE_SIG_TAPROOT_1_INPUT, PSBTTestData.SINGLE_SIG_TAPROOT_CHANGE)
|
|
claim_seed_owns_key(psbt.outputs[0], "m/86h/1h/0h/1/0", foreign_public_key(), is_taproot=True)
|
|
|
|
with pytest.raises(PSBTOutputOwnershipClaimError):
|
|
self._parse(psbt)
|
|
|
|
|
|
def test__parse__rejects_a_forged_claim_on_a_multisig_scope(self):
|
|
"""
|
|
Multisig scopes legitimately carry one derivation entry per cosigner, but a
|
|
forged claim naming this seed's fingerprint is rejected there exactly as in
|
|
single-sig: the input-scope error on an input, the attack-classified error on
|
|
an output.
|
|
"""
|
|
psbt = self._psbt_with_change(PSBTTestData.MULTISIG_NATIVE_SEGWIT_1_INPUT, PSBTTestData.MULTISIG_NATIVE_SEGWIT_CHANGE)
|
|
claim_seed_owns_key(psbt.inputs[0], "m/48h/1h/0h/2h/0/9", foreign_public_key())
|
|
|
|
with pytest.raises(PSBTInputOwnershipClaimError):
|
|
self._parse(psbt)
|
|
|
|
# The same forgery on the multisig change output gets the attack classification
|
|
psbt = self._psbt_with_change(PSBTTestData.MULTISIG_NATIVE_SEGWIT_1_INPUT, PSBTTestData.MULTISIG_NATIVE_SEGWIT_CHANGE)
|
|
claim_seed_owns_key(psbt.outputs[0], "m/48h/1h/0h/2h/1/9", foreign_public_key())
|
|
|
|
with pytest.raises(PSBTOutputOwnershipClaimError):
|
|
self._parse(psbt)
|
|
|
|
|
|
def test__parse__rejects_a_forged_claim_behind_a_genuine_one(self):
|
|
"""
|
|
A scope can hold more than one key. Finding the one the seed owns is not a reason
|
|
to stop looking.
|
|
|
|
No honest coordinator writes extra derivation entries on a single-sig scope; this
|
|
shape arrives only from buggy or adversarial software. The scan copes with it
|
|
because the same scan serves multisig, where multi-entry scopes are the norm.
|
|
"""
|
|
psbt = self._psbt_with_change()
|
|
|
|
# The scope's own genuine derivation stays exactly where it is...
|
|
assert len(psbt.inputs[0].bip32_derivations) == 1
|
|
|
|
# ...but add a false claim to the bip32_derivations dict
|
|
claim_seed_owns_key(psbt.inputs[0], "m/84h/1h/0h/0/9", foreign_public_key())
|
|
assert len(psbt.inputs[0].bip32_derivations) == 2
|
|
assert list(psbt.inputs[0].bip32_derivations.keys())[-1] == foreign_public_key()
|
|
|
|
with pytest.raises(PSBTInputOwnershipClaimError):
|
|
self._parse(psbt)
|
|
|
|
|
|
def test__parse__accepts_a_key_belonging_to_someone_else(self):
|
|
"""
|
|
Only a claim on THIS seed's fingerprint is ever checked. A key openly belonging to
|
|
another wallet says nothing about this seed and must not reject the psbt.
|
|
|
|
No honest coordinator writes extra derivation entries on a single-sig scope; this
|
|
shape arrives only from buggy or adversarial software. The scan copes with it
|
|
because the same scan serves multisig, where multi-entry scopes are the norm.
|
|
"""
|
|
psbt = self._psbt_with_change()
|
|
other_seed = PSBTTestData.recipient_seed
|
|
claim_seed_owns_key(psbt.inputs[0], "m/84h/1h/0h/0/0", foreign_public_key(), seed=other_seed)
|
|
|
|
# Confirm the fixture really does carry someone else's fingerprint
|
|
claimed_fingerprint = psbt.inputs[0].bip32_derivations[foreign_public_key()].fingerprint
|
|
assert claimed_fingerprint == root_for_seed(other_seed).my_fingerprint
|
|
assert claimed_fingerprint != self._root().my_fingerprint
|
|
|
|
psbt_parser = self._parse(psbt)
|
|
|
|
# The seed still owns its own key in that input, via the scope's genuine
|
|
# derivation.
|
|
assert psbt_parser.verified_input_derivation_paths[0] is not None
|
|
|
|
|
|
def test_genuine_fingerprint_collision_is_rejected_like_a_forgery(self):
|
|
"""
|
|
4-byte fingerprints collide. The fixture pair were brute-force generated so that
|
|
they share a master fingerprint with no forgery involved, so a foreign party's
|
|
honest derivation entry can name our fingerprint on a key we do not control.
|
|
|
|
The parser cannot distinguish that from a forged claim -- they are byte-for-byte
|
|
the same situation -- and so it deliberately fails the psbt as an input-scope
|
|
inconsistency.
|
|
"""
|
|
foreign_root = root_for_seed(PSBTTestData.collision_seed_b)
|
|
|
|
# The collision is real: same fingerprint, different key material
|
|
signing_root = root_for_seed(PSBTTestData.collision_seed_a)
|
|
assert foreign_root.my_fingerprint == signing_root.my_fingerprint
|
|
assert foreign_root.get_public_key() != signing_root.get_public_key()
|
|
|
|
# The foreign party's own genuine entry on their own input: their true
|
|
# fingerprint beside their true key, no forgery anywhere
|
|
psbt = self._psbt_with_change()
|
|
foreign_derivation_path = bip32.parse_path("m/84h/1h/0h/0/0")
|
|
colliding_public_key = foreign_root.derive(foreign_derivation_path).get_public_key()
|
|
psbt.inputs[0].bip32_derivations[colliding_public_key] = DerivationPath(foreign_root.my_fingerprint, foreign_derivation_path)
|
|
|
|
with pytest.raises(PSBTInputOwnershipClaimError):
|
|
PSBTParser(psbt, PSBTTestData.collision_seed_a, network=SettingsConstants.REGTEST)
|
|
|
|
|
|
def test_ownership_scan_derives_through_the_cache(self):
|
|
"""
|
|
Ownership verification does its own deriving and the later parse phases do theirs,
|
|
but all of it flows through one shared cache: across the whole parse, each unique
|
|
path level is derived exactly once, no matter how many scopes claim it or how many
|
|
phases revisit it.
|
|
|
|
Note that even the initial pass benefits from the cache by skipping shared
|
|
levels that have already been derived.
|
|
"""
|
|
psbt = self._psbt_with_change()
|
|
|
|
# Boost to 10 inputs from the same wallet, all sharing the one derivation path
|
|
for _ in range(9):
|
|
psbt.inputs.append(deepcopy(psbt.inputs[0]))
|
|
|
|
# How deep does the derivation path go?
|
|
num_levels = len(list(psbt.inputs[0].bip32_derivations.values())[0].derivation)
|
|
|
|
# Count every level actually derived over the course of the parse
|
|
num_derivations = 0
|
|
uncounted_child = bip32.HDKey.child
|
|
def counting_child(self, index, hardened=False):
|
|
nonlocal num_derivations
|
|
num_derivations += 1
|
|
return uncounted_child(self, index, hardened)
|
|
|
|
with patch.object(bip32.HDKey, "child", counting_child):
|
|
psbt_parser = self._parse(psbt)
|
|
|
|
# Sanity check: the scan really did run over all ten inputs and the change output
|
|
assert len(psbt_parser.verified_input_derivation_paths) == 10
|
|
assert all(path is not None for path in psbt_parser.verified_input_derivation_paths)
|
|
assert psbt_parser.verified_output_derivation_paths[0] is not None
|
|
|
|
# The inputs were cloned so they all use the same path with num_levels depth. The
|
|
# change output differs only in its last two levels. Verify that each of these
|
|
# levels was derived exactly once.
|
|
assert num_derivations == num_levels + 2
|
|
|
|
|
|
def test__parse__rejects_a_seed_that_owns_no_input(self):
|
|
"""
|
|
The wrong seed for a psbt can produce no signature at all, so parsing raises
|
|
PSBTSeedCannotSignError rather than letting the flow discover it after the user
|
|
has approved.
|
|
|
|
And under the same wrong-seed conditions, embit will produce no signature anyway.
|
|
"""
|
|
psbt = self._psbt_with_change()
|
|
|
|
# Intentionally use the wrong seed
|
|
with pytest.raises(PSBTSeedCannotSignError):
|
|
PSBTParser(psbt, PSBTTestData.recipient_seed, network=SettingsConstants.REGTEST)
|
|
|
|
# Try to sign with the wrong seed anyway
|
|
root = root_for_seed(PSBTTestData.recipient_seed)
|
|
assert psbt.sign_with(root) == 0
|
|
|
|
|
|
def test__parse__accepts_a_cosigner_seed_on_a_multisig(self):
|
|
"""
|
|
Each cosigner holding a key on the device signs the same psbt in turn, so a seed
|
|
that owns an input must pass whether or not it is the first one used.
|
|
"""
|
|
psbt = self._psbt_with_change(PSBTTestData.MULTISIG_NATIVE_SEGWIT_1_INPUT, PSBTTestData.MULTISIG_NATIVE_SEGWIT_CHANGE)
|
|
|
|
psbt_parser = PSBTParser(psbt, PSBTTestData.seed, network=SettingsConstants.REGTEST)
|
|
assert any(path is not None for path in psbt_parser.verified_input_derivation_paths)
|
|
|
|
psbt_parser = PSBTParser(psbt, PSBTTestData.multisig_key_2, network=SettingsConstants.REGTEST)
|
|
assert any(path is not None for path in psbt_parser.verified_input_derivation_paths)
|
|
|
|
psbt_parser = PSBTParser(psbt, PSBTTestData.multisig_key_3, network=SettingsConstants.REGTEST)
|
|
assert any(path is not None for path in psbt_parser.verified_input_derivation_paths)
|
|
|
|
|
|
def test_a_psbt_with_no_utxos_is_rejected_rather_than_crashing(self):
|
|
"""
|
|
A psbt with no inputs is malformed and cannot be signed. `parse` should raise
|
|
PSBTSeedCannotSignError. Note that diagnosing the malformation is not this check's
|
|
job.
|
|
"""
|
|
psbt = self._psbt_with_change()
|
|
for psbt_input in psbt.inputs:
|
|
psbt_input.witness_utxo = None
|
|
psbt_input.non_witness_utxo = None
|
|
psbt_input._utxo = None
|
|
psbt_input.bip32_derivations.clear()
|
|
|
|
with pytest.raises(PSBTSeedCannotSignError):
|
|
self._parse(psbt)
|
|
|
|
|
|
|
|
class TestPSBTParserOutputOwnership(PSBTParserOwnershipTestBase):
|
|
"""
|
|
A psbt annotates its outputs with claims about which keys own them by providing
|
|
derivation path entries for each key.
|
|
|
|
But it's the output's script that actually determines where the funds go.
|
|
|
|
These tests cover invalid claims as well as the ways that the claims and the script
|
|
can disagree and how PSBTParser handles such discrepancies.
|
|
"""
|
|
def _foreign_multisig_script(self) -> script.Script:
|
|
"""A 2-of-3 built entirely from someone else's keys."""
|
|
return script.multisig(2, [foreign_public_key(f"m/48h/1h/0h/2h/0/{i}") for i in range(3)])
|
|
|
|
|
|
def _rebuild_around_foreign_keys(self, out: OutputScope):
|
|
"""
|
|
Replace a multisig output's script to pay to a different quorum made entirely of
|
|
someone else's keys, but leave the output's original derivation path entries
|
|
alone. The output now misrepresents who receives its funds.
|
|
"""
|
|
foreign_script = self._foreign_multisig_script()
|
|
|
|
if out.witness_script is not None:
|
|
out.witness_script = foreign_script
|
|
inner_script = script.p2wsh(foreign_script)
|
|
else:
|
|
inner_script = foreign_script
|
|
|
|
if out.redeem_script is not None:
|
|
out.redeem_script = inner_script
|
|
out.script_pubkey = script.p2sh(inner_script)
|
|
else:
|
|
out.script_pubkey = inner_script
|
|
|
|
|
|
def test__parse__rejects_a_single_key_output_that_claims_more_than_one_path(self):
|
|
"""
|
|
It is nonsensical for a single sig output to list more than one derivation path
|
|
entry. Some elaborate deceptions may be possible with extra derivation paths, but
|
|
we simply reject such psbts by raising PSBTSurplusDerivationPathsError.
|
|
"""
|
|
decoy_derivation_path = "m/84h/1h/0h/1/9"
|
|
|
|
# A normal psbt with change; includes the single derivation path entry that
|
|
# describes it.
|
|
psbt = self._psbt_with_change()
|
|
|
|
psbt_parser = self._parse(psbt)
|
|
assert psbt_parser.change_amount == 10_000
|
|
|
|
# The same psbt but with a second entry naming a key this seed really does own at
|
|
# another path.
|
|
psbt = self._psbt_with_change()
|
|
decoy_public_key = self._root().derive(decoy_derivation_path).get_public_key()
|
|
claim_seed_owns_key(psbt.outputs[0], decoy_derivation_path, decoy_public_key)
|
|
|
|
with pytest.raises(PSBTSurplusDerivationPathsError):
|
|
self._parse(psbt)
|
|
|
|
|
|
def test__parse__rejects_a_taproot_output_that_claims_more_than_one_internal_key(self):
|
|
"""
|
|
Taproot is an exception to the rule that single sig outputs cannot have more than
|
|
one derivation path entry. A BIP-371 output can legitimately have several entries:
|
|
the internal key plus one for each key in its script tree.
|
|
|
|
But the basic single sig logic holds for the internal key: there can be only one.
|
|
|
|
Entries that do name a leaf are legitimate and should be left out of the count.
|
|
"""
|
|
decoy_derivation_path = "m/86h/1h/0h/1/7"
|
|
decoy_public_key = self._root().derive(decoy_derivation_path).get_public_key()
|
|
|
|
leaf_derivation_path = "m/86h/1h/0h/1/8"
|
|
leaf_public_key = self._root().derive(leaf_derivation_path).get_public_key()
|
|
|
|
# The wallet's own taproot change, carrying the single entry that describes it
|
|
psbt = self._psbt_with_change(PSBTTestData.SINGLE_SIG_TAPROOT_1_INPUT, PSBTTestData.SINGLE_SIG_TAPROOT_CHANGE)
|
|
|
|
# The simple case parses and identifies the output as change
|
|
psbt_parser = self._parse(psbt)
|
|
assert psbt_parser.change_amount == 10_000
|
|
|
|
# The same psbt, with a second entry naming no leaf hashes, which is therefore a
|
|
# second claim to be the output's one internal key.
|
|
psbt = self._psbt_with_change(PSBTTestData.SINGLE_SIG_TAPROOT_1_INPUT, PSBTTestData.SINGLE_SIG_TAPROOT_CHANGE)
|
|
claim_seed_owns_key(psbt.outputs[0], decoy_derivation_path, decoy_public_key, is_taproot=True)
|
|
|
|
with pytest.raises(PSBTSurplusDerivationPathsError):
|
|
self._parse(psbt)
|
|
|
|
# The same two internal key entries, now with a genuine script tree key beside
|
|
# them. The surplus is still a surplus.
|
|
psbt = self._psbt_with_change(PSBTTestData.SINGLE_SIG_TAPROOT_1_INPUT, PSBTTestData.SINGLE_SIG_TAPROOT_CHANGE)
|
|
claim_seed_owns_key(psbt.outputs[0], decoy_derivation_path, decoy_public_key, is_taproot=True)
|
|
claim_seed_owns_key(psbt.outputs[0], leaf_derivation_path, leaf_public_key, is_taproot=True,
|
|
leaf_hashes=[tapleaf_hash(leaf_public_key)])
|
|
|
|
with pytest.raises(PSBTSurplusDerivationPathsError):
|
|
self._parse(psbt)
|
|
|
|
|
|
def test__parse__rejects_an_output_with_entries_in_both_derivation_path_maps(self):
|
|
"""
|
|
The taproot derivation paths (taproot_bip32_derivations) are only relevant for
|
|
taproot scripts, just as the non-taproot derivation paths (bip32_derivations) are
|
|
only relevant for non-taproot scripts.
|
|
|
|
There is no scenario where both maps could carry valid data for the same output.
|
|
|
|
A psbt that includes both is rejected with PSBTMixedDerivationPathTypesError.
|
|
"""
|
|
# The wallet's own taproot change, with an empty segwit-v0 map beside it
|
|
psbt = self._psbt_with_change(PSBTTestData.SINGLE_SIG_TAPROOT_1_INPUT, PSBTTestData.SINGLE_SIG_TAPROOT_CHANGE)
|
|
assert len(psbt.outputs[0].bip32_derivations) == 0
|
|
assert len(psbt.outputs[0].taproot_bip32_derivations) == 1
|
|
|
|
# Parses successfully as expected
|
|
psbt_parser = self._parse(psbt)
|
|
assert psbt_parser.change_amount == 10_000
|
|
|
|
# The same psbt, with a truthful entry on a key this seed owns written into the
|
|
# non-taproot map.
|
|
psbt = self._psbt_with_change(PSBTTestData.SINGLE_SIG_TAPROOT_1_INPUT, PSBTTestData.SINGLE_SIG_TAPROOT_CHANGE)
|
|
|
|
extra_derivation_path = "m/84h/1h/0h/1/9"
|
|
extra_public_key = self._root().derive(extra_derivation_path).get_public_key()
|
|
claim_seed_owns_key(psbt.outputs[0], extra_derivation_path, extra_public_key)
|
|
|
|
# The output now has entries in both maps
|
|
assert len(psbt.outputs[0].bip32_derivations) == 1
|
|
assert len(psbt.outputs[0].taproot_bip32_derivations) == 1
|
|
|
|
# So it fails as expected
|
|
with pytest.raises(PSBTMixedDerivationPathTypesError):
|
|
self._parse(psbt)
|
|
|
|
# Set up the same collision, but with a non-taproot output
|
|
psbt = self._psbt_with_change(PSBTTestData.SINGLE_SIG_NATIVE_SEGWIT_1_INPUT, PSBTTestData.SINGLE_SIG_NATIVE_SEGWIT_CHANGE)
|
|
assert len(psbt.outputs[0].bip32_derivations) == 1
|
|
assert len(psbt.outputs[0].taproot_bip32_derivations) == 0
|
|
|
|
# Add a valid taproot derivation path entry
|
|
taproot_derivation_path = "m/86h/1h/0h/1/9"
|
|
taproot_public_key = self._root().derive(taproot_derivation_path).get_public_key()
|
|
claim_seed_owns_key(psbt.outputs[0], taproot_derivation_path, taproot_public_key, is_taproot=True)
|
|
|
|
# The output now has entries in both maps
|
|
assert len(psbt.outputs[0].bip32_derivations) == 1
|
|
assert len(psbt.outputs[0].taproot_bip32_derivations) == 1
|
|
|
|
# Once again fails as expected
|
|
with pytest.raises(PSBTMixedDerivationPathTypesError):
|
|
self._parse(psbt)
|
|
|
|
|
|
def test__parse__counts_a_multisig_output_paying_other_people_as_a_spend(self):
|
|
"""
|
|
Most coordinators will not provide any output derivation paths nor the output
|
|
script itself for external spends. In most cases the coordinator simply wouldn't
|
|
know that information for outside parties.
|
|
|
|
But even if that information is provided (as Bitcoin Core can do if the recipient
|
|
is another wallet for which it knows the internal details), the parser should
|
|
still interpret the output correctly: as an external spend.
|
|
|
|
Most of the tests in this class are about what is not allowed. This test gives the
|
|
parser a scenario that IS allowed, but it touches on many of the areas that the
|
|
parser's rejection logic depends on.
|
|
"""
|
|
psbt = PSBT.parse(a2b_base64(PSBTTestData.MULTISIG_NATIVE_SEGWIT_1_INPUT))
|
|
recipient_output = create_output(PSBTTestData.MULTISIG_NATIVE_SEGWIT_RECEIVE_ANNOTATED, 10_000)
|
|
psbt.outputs.append(recipient_output)
|
|
|
|
# The coordinator's annotation is honest: the script it supplied is the one the
|
|
# output really pays, and the keys it names are the recipient's own.
|
|
assert script.p2wsh(recipient_output.witness_script).data == recipient_output.script_pubkey.data
|
|
assert len(recipient_output.bip32_derivations) == 3
|
|
|
|
# As expected: no errors raised, no deceptions detected
|
|
psbt_parser = self._parse(psbt)
|
|
|
|
# Trivial confirmation: none of the output's three derivation path entries claimed
|
|
# to belong to this seed.
|
|
assert psbt_parser.verified_output_derivation_paths[0] is None
|
|
|
|
# The parser correctly categorized the output as an external spend
|
|
assert psbt_parser.change_data == []
|
|
assert psbt_parser.change_amount == 0
|
|
assert psbt_parser.spend_amount == 10_000
|
|
|
|
# BIP-174 makes the derivation path entries optional, so a coordinator may supply
|
|
# the output's script and nothing else. Only the entries are dropped here; the
|
|
# output still commits to the same outside parties' script.
|
|
recipient_output.bip32_derivations.clear()
|
|
|
|
# Still parses successfully; no errors raised, no deceptions detected
|
|
psbt_parser = self._parse(psbt)
|
|
|
|
# Same result. The output was correctly categorized as an external spend.
|
|
assert psbt_parser.change_data == []
|
|
assert psbt_parser.change_amount == 0
|
|
assert psbt_parser.spend_amount == 10_000
|
|
|
|
|
|
def test__parse__counts_multisig_change_with_no_derivation_paths_as_a_spend(self):
|
|
"""
|
|
The same shape as an outgoing payment, but this output really is our own change:
|
|
the script it commits to does hold a key of this seed. BIP-174 makes the entries
|
|
naming that key optional, and this psbt omits them.
|
|
|
|
With no entry to derive from there is no key to go looking for in the script, so
|
|
the output is counted as a spend. That over-reports what is leaving the wallet,
|
|
and it is a limit on what we can see rather than a detection of anything wrong.
|
|
"""
|
|
psbt = self._psbt_with_change(PSBTTestData.MULTISIG_NATIVE_SEGWIT_1_INPUT, PSBTTestData.MULTISIG_NATIVE_SEGWIT_CHANGE)
|
|
|
|
# Unmodified, this output is the wallet's own change
|
|
psbt_parser = self._parse(psbt)
|
|
|
|
# With the derivation paths present, we verified that the output did name a key
|
|
# that this seed owns (which also enabled the parser to verify that our key was
|
|
# indeed part of the script).
|
|
assert psbt_parser.verified_output_derivation_paths[0] is not None
|
|
|
|
# And the output was correctly categorized as change
|
|
assert psbt_parser.change_amount == 10_000
|
|
assert psbt_parser.spend_amount == 0
|
|
|
|
# Dropping the derivation path entries leaves the coordinator's witness_script
|
|
# intact, so the output still hashes to the committed scriptPubKey and still
|
|
# matches the input's policy. Only the path we would derive our key from is gone.
|
|
psbt.outputs[0].bip32_derivations = {}
|
|
|
|
psbt_parser = self._parse(psbt)
|
|
|
|
# The output provided no derivation paths to verify (leaving the parser unable to
|
|
# determine if our seed owns any of the keys in the output's script).
|
|
assert psbt_parser.verified_output_derivation_paths[0] is None
|
|
|
|
# Because we couldn't do proper verification, the parser correctly categorized the
|
|
# output as an external spend.
|
|
assert psbt_parser.change_data == []
|
|
assert psbt_parser.change_amount == 0
|
|
assert psbt_parser.spend_amount == 10_000
|
|
|
|
|
|
def test__parse__counts_multisig_change_with_no_script_as_a_spend(self):
|
|
"""
|
|
The same output as the test above, withholding the other half. Here the entries
|
|
naming this seed are intact and the supplied script is what is missing, which
|
|
BIP-174 also permits.
|
|
|
|
So the psbt claims the output for this seed and gives us nothing to check that
|
|
claim against. A claim we cannot check is not a contradiction, so the psbt is
|
|
not rejected, but the output is counted as a spend.
|
|
"""
|
|
psbt = self._psbt_with_change(PSBTTestData.MULTISIG_NATIVE_SEGWIT_1_INPUT, PSBTTestData.MULTISIG_NATIVE_SEGWIT_CHANGE)
|
|
|
|
# The entries claiming this seed are left alone; only the script is withheld
|
|
psbt.outputs[0].witness_script = None
|
|
|
|
psbt_parser = self._parse(psbt)
|
|
|
|
# The claim itself still verifies
|
|
assert psbt_parser.verified_output_derivation_paths[0] is not None
|
|
|
|
# But with no script there is no m-of-n to compare, so the output never becomes a
|
|
# change candidate at all.
|
|
assert psbt_parser.change_data == []
|
|
assert psbt_parser.change_amount == 0
|
|
assert psbt_parser.spend_amount == 10_000
|
|
|
|
|
|
def test__parse__counts_taproot_change_naming_our_internal_key_as_a_spend(self):
|
|
"""
|
|
SeedSigner would not be used (yet) for a taproot wallet that includes a script
|
|
tree, but the parser already has to distinguish a derivation path for the internal
|
|
key vs derivation paths for tapleaf keys. So this test and its sibling that
|
|
follows verify that the parser correctly handles tapleaf keys when encountered
|
|
(within the limitations we have due to not parsing the script tree itself).
|
|
|
|
This test provides a derivation path for the internal key, which our seed owns.
|
|
The wallet can spend the output through the key path, so the funds really are this
|
|
seed's own change. But the address commits to that internal key tweaked by the
|
|
script tree which we do not yet parse, so the parser's attempts at validating the
|
|
output's script will fail.
|
|
|
|
As a result, we have to treat this output as an external spend. An output cannot
|
|
be categorized as change if we have not fully verified it.
|
|
"""
|
|
# TODO: When the script tree is supported, this output should verify as change, by
|
|
# tweaking our internal key with the tree's merkle root.
|
|
|
|
# A taproot address where we control both spending routes: the internal key path
|
|
# and a one-leaf script tree. The coordinator annotates both keys in the psbt so
|
|
# the parser will see the derivation path for the internal key and the tapleaf
|
|
# key.
|
|
leaf_derivation_path = "m/86h/1h/0h/1/7"
|
|
leaf_public_key = self._root().derive(leaf_derivation_path).get_public_key()
|
|
merkle_root = tapleaf_hash(leaf_public_key)
|
|
|
|
psbt = self._psbt_with_change(PSBTTestData.SINGLE_SIG_TAPROOT_1_INPUT, PSBTTestData.SINGLE_SIG_TAPROOT_CHANGE)
|
|
taproot_output = psbt.outputs[0]
|
|
|
|
# The change output's key has already been annotated as the internal key. We just
|
|
# need to add the tapleaf key's entry.
|
|
taproot_output.script_pubkey = p2tr_with_script_tree(taproot_output.taproot_internal_key, merkle_root)
|
|
claim_seed_owns_key(taproot_output, leaf_derivation_path, leaf_public_key, is_taproot=True, leaf_hashes=[merkle_root])
|
|
|
|
psbt_parser = self._parse(psbt)
|
|
|
|
# Even though the parser verified that our seed owns the internal key...
|
|
assert psbt_parser.verified_output_derivation_paths[0] is not None
|
|
|
|
# ...the parser can't fully verify the output as change, so has to report it as an
|
|
# external spend.
|
|
assert psbt_parser.change_amount == 0
|
|
assert psbt_parser.spend_amount == 10_000
|
|
|
|
|
|
def test__parse__counts_taproot_change_naming_our_script_tree_key_as_a_spend(self):
|
|
"""
|
|
Sibling to the above test. This time no derivation path is provided for the
|
|
internal key (BIP-174 says that every entry is optional) and the sole derivation
|
|
path entry names a tapleaf key instead.
|
|
|
|
That is what a real-world script-path-only address looks like: intentionally
|
|
constructed so that nobody holds the internal key, so there's no derivation path
|
|
the coordinator could supply for it. Our seed owns the key in the leaf and can
|
|
spend the output through it, so this too is the wallet's own change. But since
|
|
embit doesn't yet parse the taproot script tree, the parser cannot verify the
|
|
output's script. So the output must be reported as a spend.
|
|
"""
|
|
# TODO: When the script tree is supported this output should verify as change, by
|
|
# finding our key among the tree's leaves.
|
|
|
|
# BIP 341's provably unspendable internal key (the "NUMS" point), over a one-leaf
|
|
# script tree holding a key this seed does own. With no internal key to describe,
|
|
# the one entry the coordinator writes is for the leaf key.
|
|
leaf_derivation_path = "m/86h/1h/0h/1/7"
|
|
leaf_public_key = self._root().derive(leaf_derivation_path).get_public_key()
|
|
merkle_root = tapleaf_hash(leaf_public_key)
|
|
|
|
psbt = self._psbt_with_change(PSBTTestData.SINGLE_SIG_TAPROOT_1_INPUT, PSBTTestData.SINGLE_SIG_TAPROOT_CHANGE)
|
|
taproot_output = psbt.outputs[0]
|
|
taproot_output.script_pubkey = p2tr_with_script_tree(NUMS_INTERNAL_KEY, merkle_root)
|
|
|
|
# Ensure that there's no derivation path entry for the internal key
|
|
taproot_output.taproot_bip32_derivations.clear()
|
|
|
|
# Add the one and only derivation path claim: the tapleaf key
|
|
claim_seed_owns_key(taproot_output, leaf_derivation_path, leaf_public_key, is_taproot=True, leaf_hashes=[merkle_root])
|
|
|
|
psbt_parser = self._parse(psbt)
|
|
|
|
# The parser verified that we own the tapleaf key...
|
|
assert psbt_parser.verified_output_derivation_paths[0] is not None
|
|
|
|
# ...but the output still has to be reported as an external spend
|
|
assert psbt_parser.change_amount == 0
|
|
assert psbt_parser.spend_amount == 10_000
|
|
|
|
|
|
def test__parse__rejects_an_output_that_claims_this_seed_but_pays_someone_else(self):
|
|
"""
|
|
The output's derivation path entry claims that the output pays to a key that our
|
|
seed genuinely owns, but the output's script actually pays out to a different key.
|
|
We consider this deception an attack.
|
|
"""
|
|
# First the normal case where it really is our change
|
|
psbt = self._psbt_with_change()
|
|
psbt_parser = self._parse(psbt)
|
|
|
|
# Confirmed by the parser
|
|
assert psbt_parser.change_amount == 10_000
|
|
|
|
# The same psbt, but with that output's script repointed at a stranger while its
|
|
# original derivation path entry is left in place. The output now misrepresents
|
|
# who receives its funds.
|
|
psbt = self._psbt_with_change()
|
|
psbt.outputs[0].script_pubkey = script.p2wpkh(foreign_public_key())
|
|
|
|
with pytest.raises(PSBTOutputOwnershipContradictionError):
|
|
self._parse(psbt)
|
|
|
|
# Removing the derivation path entry means that there is now no longer any
|
|
# deception; the output is just an external spend.
|
|
psbt = self._psbt_with_change()
|
|
psbt.outputs[0].script_pubkey = script.p2wpkh(foreign_public_key())
|
|
psbt.outputs[0].bip32_derivations.clear()
|
|
|
|
psbt_parser = self._parse(psbt)
|
|
assert psbt_parser.change_amount == 0
|
|
assert psbt_parser.spend_amount == 10_000
|
|
|
|
|
|
def test__parse__rejects_an_output_that_pays_this_seed_but_claims_someone_else(self):
|
|
"""
|
|
This is the opposite deception from the previous test. A psbt might mark our own
|
|
change with a derivation path that claims it belongs to someone else (provides
|
|
someone else's fingerprint).
|
|
|
|
We don't try to decide whether this is an attack or a mistake. Any incorrect
|
|
claim about ownership is a deception, so the psbt is rejected.
|
|
"""
|
|
# First the normal case where it really is our change
|
|
psbt = self._psbt_with_change()
|
|
psbt_parser = self._parse(psbt)
|
|
|
|
# Confirmed by the parser
|
|
assert psbt_parser.change_amount == 10_000
|
|
|
|
# The same psbt, with the output now claiming a stranger's fingerprint. The key
|
|
# that the output's script actually pays out to is still our key and the derivation
|
|
# path is untouched.
|
|
psbt = self._psbt_with_change()
|
|
public_key, derivation_path_obj = list(psbt.outputs[0].bip32_derivations.items())[0]
|
|
|
|
# Create the deception: Change the fingerprint, but keep the derivation path
|
|
psbt.outputs[0].bip32_derivations[public_key] = DerivationPath(
|
|
root_for_seed(PSBTTestData.recipient_seed).my_fingerprint, derivation_path_obj.derivation)
|
|
|
|
with pytest.raises(PSBTOutputOwnershipContradictionError):
|
|
self._parse(psbt)
|
|
|
|
|
|
def test__parse__rejects_taproot_change_that_claims_someone_else(self):
|
|
"""
|
|
The taproot mirror of the test above. The output's scriptPubKey still pays our
|
|
internal key at the derivation path the psbt supplies, but the entry claims a
|
|
stranger's fingerprint, so nothing verifies as ours and the psbt is refused.
|
|
|
|
Worth its own test because the p2tr branch reaches that refusal by a different
|
|
route, taking the path from the single internal-key entry rather than from
|
|
bip32_derivations.
|
|
"""
|
|
# First the normal case where it really is our change
|
|
psbt = self._psbt_with_change(PSBTTestData.SINGLE_SIG_TAPROOT_1_INPUT, PSBTTestData.SINGLE_SIG_TAPROOT_CHANGE)
|
|
psbt_parser = self._parse(psbt)
|
|
|
|
assert psbt_parser.change_amount == 10_000
|
|
|
|
# The same psbt, with only the fingerprint on the internal key's entry replaced.
|
|
# The scriptPubKey and the derivation path are left alone, so the output still
|
|
# pays the key that path produces.
|
|
psbt = self._psbt_with_change(PSBTTestData.SINGLE_SIG_TAPROOT_1_INPUT, PSBTTestData.SINGLE_SIG_TAPROOT_CHANGE)
|
|
public_key, (leaf_hashes, derivation_path_obj) = list(psbt.outputs[0].taproot_bip32_derivations.items())[0]
|
|
|
|
# The entry has to stay an internal-key claim, so its (empty) leaf hashes are kept
|
|
assert leaf_hashes == []
|
|
psbt.outputs[0].taproot_bip32_derivations[public_key] = (
|
|
leaf_hashes,
|
|
DerivationPath(root_for_seed(PSBTTestData.recipient_seed).my_fingerprint, derivation_path_obj.derivation))
|
|
|
|
with pytest.raises(PSBTOutputOwnershipContradictionError):
|
|
self._parse(psbt)
|
|
|
|
|
|
def test__parse__rejects_a_multisig_output_built_from_other_peoples_keys(self):
|
|
"""
|
|
A malicious psbt might try to trick the parser with a multisig output that claims
|
|
to be the wallet's change, but whose script is built entirely from someone else's
|
|
keys.
|
|
|
|
The output's script type and m-of-n match the inputs', so it is considered as a
|
|
possible change output. The attacker must also annotate the output with a
|
|
derivation path entry that names a key our seed really owns in order to pass other
|
|
parser checks. This creates a false claim that the change output belongs to our
|
|
seed.
|
|
|
|
But a final check prevents this deception from succeeding:
|
|
|
|
An output should only be considered change when one of our keys is in the output's
|
|
script.
|
|
|
|
Raise PSBTOutputOwnershipContradictionError if this deception is detected.
|
|
"""
|
|
# For each multisig script type...
|
|
for input_base64, change_hex in [
|
|
(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),
|
|
]:
|
|
# First create the psbt with the wallet's correct change
|
|
psbt = self._psbt_with_change(input_base64, change_hex)
|
|
|
|
# The parse is still successful and raises no alarms
|
|
psbt_parser = self._parse(psbt)
|
|
|
|
assert psbt_parser.change_amount == 10_000
|
|
assert psbt_parser.spend_amount == 0
|
|
|
|
# The same psbt but now make the output pay to someone else's keys
|
|
psbt = self._psbt_with_change(input_base64, change_hex)
|
|
self._rebuild_around_foreign_keys(psbt.outputs[0])
|
|
|
|
# The output now still claims to pay our seed but the actual output script
|
|
# says otherwise. The deception is flagged.
|
|
with pytest.raises(PSBTOutputOwnershipContradictionError):
|
|
self._parse(psbt)
|
|
|
|
|
|
def test__parse__rejects_a_multisig_output_that_hides_this_seed_behind_another_fingerprint(self):
|
|
"""
|
|
This scenario leaves a genuine multisig change output completely intact, but
|
|
relabels the entry describing this seed's key with a different fingerprint.
|
|
|
|
The seed's key is still in the script the output commits to, and the psbt still
|
|
supplies the derivation path that produced our key, so deriving at that path
|
|
proves this is actually our change.
|
|
|
|
But the contradicting fingerprint claimed in the derivation path entry is a
|
|
deception that we consider an attack. Raises
|
|
PSBTOutputOwnershipContradictionError.
|
|
"""
|
|
for input_base64, change_hex in [
|
|
(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),
|
|
]:
|
|
# Untouched, the output is recognized as change coming back to the seed
|
|
psbt_parser = self._parse(self._psbt_with_change(input_base64, change_hex))
|
|
assert psbt_parser.change_amount == 10_000
|
|
|
|
# The same psbt with only the fingerprint on this seed's entry replaced. The
|
|
# script, the scriptPubKey and every derivation path are left alone.
|
|
psbt = self._psbt_with_change(input_base64, change_hex)
|
|
root = self._root()
|
|
entries = psbt.outputs[0].bip32_derivations
|
|
|
|
relabeled = 0
|
|
for public_key, derivation_path_obj in list(entries.items()):
|
|
if derivation_path_obj.fingerprint == root.my_fingerprint:
|
|
entries[public_key] = DerivationPath(b"\x11\x22\x33\x44", derivation_path_obj.derivation)
|
|
relabeled += 1
|
|
|
|
# The tamper only means anything if it actually landed on this seed's entry
|
|
assert relabeled == 1
|
|
|
|
with pytest.raises(PSBTOutputOwnershipContradictionError):
|
|
self._parse(psbt)
|
|
|
|
|
|
def test__parse__refuses_a_multisig_decoy_entry_in_either_position(self):
|
|
"""
|
|
In this scenario the multisig change output is a legitimate change output that
|
|
genuinely belongs to our seed, but a decoy derivation path entry is added. The
|
|
decoy is ALSO a key that our seed owns, but it is not used in the output's script.
|
|
|
|
We don't need to decide if such a psbt has malicious intent; the fact that it
|
|
contradicts itself is unacceptable regardless:
|
|
* it names a key on an output whose script does not use it.
|
|
* it names more keys than that script has.
|
|
Both are provable from the psbt alone, so we reject the psbt.
|
|
|
|
This is similar to the single sig test earlier in this class, but is more
|
|
complicated for multisig since it's the norm for multiple derivation paths to be
|
|
provided for each multisig change output.
|
|
|
|
The derivation path entries are provided in a coordinator-controlled order, so
|
|
this test covers decoy entries that are listed before or after the seed's actual
|
|
cosigner entry, across all three multisig script types.
|
|
|
|
Both orderings are refused. The ordering only decides which problem we report.
|
|
We record the first entry that verifies against our seed, so:
|
|
* When the decoy is listed first, the decoy is what we record and it is not in
|
|
the script.
|
|
* When the decoy is listed last, the key we record is our real one and nothing
|
|
is wrong with it; what gives the decoy away instead is that the output named
|
|
more keys than its script has.
|
|
"""
|
|
root = self._root()
|
|
|
|
# For each script type...
|
|
for input_base64, change_hex in [
|
|
(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),
|
|
]:
|
|
# ...run both versions of the test: decoy listed first and decoy last
|
|
for decoy_first in [True, False]:
|
|
psbt = self._psbt_with_change(input_base64, change_hex)
|
|
|
|
cosigner_entries = dict(psbt.outputs[0].bip32_derivations)
|
|
|
|
# Build the decoy from the cosigners' baseline, then make one minor
|
|
# derivation path change.
|
|
genuine_derivation_path = list(cosigner_entries.values())[0].derivation
|
|
decoy_derivation_path = genuine_derivation_path[:-1] + [genuine_derivation_path[-1] + 1]
|
|
decoy_public_key = root.derive(decoy_derivation_path).get_public_key()
|
|
decoy_entry = DerivationPath(root.my_fingerprint, decoy_derivation_path)
|
|
|
|
# Add the decoy to the existing 3 derivations
|
|
entries = psbt.outputs[0].bip32_derivations
|
|
if decoy_first:
|
|
entries.clear()
|
|
entries[decoy_public_key] = decoy_entry
|
|
entries.update(cosigner_entries)
|
|
else:
|
|
entries[decoy_public_key] = decoy_entry
|
|
|
|
if decoy_first:
|
|
# The parser uses the decoy as the comparison against which keys are
|
|
# actually in the script.
|
|
expected_error = PSBTOutputOwnershipContradictionError
|
|
else:
|
|
# The original cosigner is verified but then the parser detects the
|
|
# decoy as a surplus derivation path.
|
|
expected_error = PSBTSurplusDerivationPathsError
|
|
|
|
with pytest.raises(expected_error):
|
|
self._parse(psbt)
|
|
|
|
|
|
def test__parse__rejects_a_multisig_output_whose_supplied_script_is_not_its_own(self):
|
|
"""
|
|
This is a change-theft scenario where a malicious coordinator replaces just the
|
|
scriptPubKey on a multisig change output. As a result, every check on the change
|
|
output correctly passes, right up until the output's script is hashed and compared
|
|
to the output's scriptPubKey. The two results are different, proving that the
|
|
output's funds are not going where the psbt claimed they were.
|
|
"""
|
|
for input_base64, change_hex in [
|
|
(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),
|
|
]:
|
|
# First create the psbt with the wallet's correct change
|
|
psbt = self._psbt_with_change(input_base64, change_hex)
|
|
psbt_parser = self._parse(psbt)
|
|
|
|
# Categorizes the output correctly as change coming back to the seed
|
|
assert psbt_parser.change_amount == 10_000
|
|
|
|
# The same psbt, with only the scriptPubKey repointed at a stranger's
|
|
# 2-of-3. The psbt's own witness/redeem script and the entries naming this
|
|
# seed are left untouched, so the psbt still holds its original claims
|
|
# about who owns the output.
|
|
psbt = self._psbt_with_change(input_base64, change_hex)
|
|
out = psbt.outputs[0]
|
|
|
|
foreign_script = self._foreign_multisig_script()
|
|
inner_script = script.p2wsh(foreign_script) if out.witness_script is not None else foreign_script
|
|
out.script_pubkey = script.p2sh(inner_script) if out.redeem_script is not None else inner_script
|
|
|
|
with pytest.raises(PSBTOutputOwnershipContradictionError):
|
|
self._parse(psbt)
|
|
|
|
|
|
def test_get_cosigners_returns_a_sorted_list(self):
|
|
"""
|
|
Two multisig scripts can list the same wallet's keys in different orders, so the
|
|
cosigners resolved for one script and the cosigners resolved for another have to
|
|
be sorted before they can be compared. Regression test against _get_cosigners
|
|
ever dropping the sort logic.
|
|
"""
|
|
psbt = PSBT.parse(a2b_base64(PSBTTestData.MULTISIG_NATIVE_SEGWIT_1_INPUT))
|
|
inp = psbt.inputs[0]
|
|
pubkeys = list(inp.bip32_derivations.keys())
|
|
|
|
cosigners = PSBTParser._get_cosigners(pubkeys, inp.bip32_derivations, psbt.xpubs, None)
|
|
|
|
assert cosigners == sorted(cosigners)
|
|
|
|
# Sanity check: the fixture's three cosigners are three different xpubs.
|
|
assert len(set(cosigners)) == 3
|
|
|
|
# The same keys, handed over in the opposite order
|
|
reordered = PSBTParser._get_cosigners(list(reversed(pubkeys)), inp.bip32_derivations, psbt.xpubs, None)
|
|
|
|
assert reordered == cosigners
|
|
|
|
|
|
def _repoint_at_a_different_quorum(self, psbt: PSBT):
|
|
"""
|
|
Helper function to rebuild the change output's script so it pays a 2-of-3 that
|
|
still holds this seed's key, but with one cosigner swapped for a different seed.
|
|
That swap makes the output's 2-of-3 a different wallet from the one the inputs
|
|
spend from.
|
|
"""
|
|
# Callers pass a psbt whose change output comes first
|
|
out = psbt.outputs[0]
|
|
|
|
seed_fingerprint = root_for_seed(self.seed).my_fingerprint
|
|
|
|
# The replacement cosigner will use the same account-level derivation path as the
|
|
# first xpub.
|
|
account_derivation_path = list(psbt.xpubs.values())[0].derivation
|
|
|
|
# Pick the first entry that isn't the current seed's.
|
|
for public_key, derivation_path_obj in out.bip32_derivations.items():
|
|
if derivation_path_obj.fingerprint != seed_fingerprint:
|
|
displaced_public_key = public_key
|
|
address_derivation_path = derivation_path_obj.derivation
|
|
break
|
|
|
|
# Build the outsider from a different seed: its account xpub at the shared
|
|
# account path, then the child key two levels down at the displaced entry's
|
|
# change/index.
|
|
outsider_root = root_for_seed(PSBTTestData.recipient_seed)
|
|
outsider_account = outsider_root.derive(account_derivation_path)
|
|
outsider_public_key = outsider_account.derive(address_derivation_path[-2:]).get_public_key()
|
|
|
|
# Add the outsider's account xpub to the psbt's global xpubs, which now hold
|
|
# four: the inputs' three cosigners plus the outsider.
|
|
psbt.xpubs[outsider_account.to_public()] = DerivationPath(outsider_root.my_fingerprint, account_derivation_path)
|
|
|
|
# Rebuild the multisig script with the outsider's key in the displaced key's
|
|
# slot.
|
|
original_script = out.witness_script if out.witness_script is not None else out.redeem_script
|
|
m, n, public_keys = PSBTParser._parse_multisig(original_script)
|
|
new_pubkey_list = []
|
|
for public_key in public_keys:
|
|
if public_key == displaced_public_key:
|
|
new_pubkey_list.append(outsider_public_key)
|
|
else:
|
|
new_pubkey_list.append(public_key)
|
|
rebuilt_script = script.multisig(m, new_pubkey_list)
|
|
|
|
# Swap the displaced cosigner's derivation path entry for the outsider's, so
|
|
# the psbt describes the rebuilt script truthfully.
|
|
del out.bip32_derivations[displaced_public_key]
|
|
out.bip32_derivations[outsider_public_key] = DerivationPath(outsider_root.my_fingerprint, address_derivation_path)
|
|
|
|
# Recommit the output to the rebuilt script, through the wrapping the fixture
|
|
# uses: p2wsh, p2sh-p2wsh, or bare p2sh.
|
|
if out.witness_script is not None:
|
|
out.witness_script = rebuilt_script
|
|
inner_script = script.p2wsh(rebuilt_script)
|
|
else:
|
|
inner_script = rebuilt_script
|
|
|
|
if out.redeem_script is not None:
|
|
out.redeem_script = inner_script
|
|
out.script_pubkey = script.p2sh(inner_script)
|
|
else:
|
|
out.script_pubkey = inner_script
|
|
|
|
|
|
def test__parse__counts_a_different_quorum_as_a_spend(self):
|
|
"""
|
|
An output paying a 2-of-3 that this seed is genuinely part of, but whose third
|
|
cosigner is a different seed. That makes the output's 2-of-3 a different wallet
|
|
from the one the inputs spend from.
|
|
|
|
Every ownership check passes: the output commits to a script holding this seed's
|
|
key and the psbt claims this seed there truthfully.
|
|
|
|
The psbt supplies global xpubs (the three from the input plus the outsider's that
|
|
is part of the output) and it fully annotates this output's derivation paths,
|
|
allowing for the parser to determine the output's cosigners accurately.
|
|
|
|
End result of this setup: The output's cosigner list differs from the inputs'
|
|
list. So the output is counted as a spend.
|
|
|
|
This sort of cosigner mismatch is a scenario that no known coordinator would
|
|
produce; normally an output to a different wallet would not be annotated nor have
|
|
its xpubs added to the global xpubs data.
|
|
"""
|
|
for input_base64, change_hex in [
|
|
(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),
|
|
]:
|
|
# The wallet's own change output, for comparison
|
|
psbt_parser = self._parse(self._psbt_with_change(input_base64, change_hex))
|
|
assert psbt_parser.change_amount == 10_000
|
|
assert psbt_parser.spend_amount == 0
|
|
|
|
psbt = self._psbt_with_change(input_base64, change_hex)
|
|
self._repoint_at_a_different_quorum(psbt)
|
|
|
|
# The parse accepts the psbt: it described this output accurately.
|
|
psbt_parser = self._parse(psbt)
|
|
|
|
# This seed's key really is in the committed script and the psbt's claim of
|
|
# this seed verified.
|
|
assert psbt_parser.verified_output_derivation_paths[0] is not None
|
|
|
|
# But the output pays a different quorum than the inputs spend from, so it
|
|
# is counted as a spend.
|
|
assert psbt_parser.change_data == []
|
|
assert psbt_parser.change_amount == 0
|
|
assert psbt_parser.spend_amount == 10_000
|
|
|
|
|
|
def test__parse__counts_a_different_quorum_as_change_if_no_global_xpubs(self):
|
|
"""
|
|
Same setup as the previous test, but this time the psbt omits its global xpubs.
|
|
|
|
The global xpubs are needed in order to resolve cosigners. So without them, the
|
|
inputs' cosigner list and the output's cosigner list comparison is skipped. The
|
|
user's seed is part of the output wallet and the output's policy "shape"
|
|
superficially matches the input's (2-of-3, same script type), so the output is
|
|
counted as presumed change.
|
|
|
|
BIP-174 makes the global xpubs optional and honest coordinators do omit them. The
|
|
previous test notes that no known coordinator annotates external spend outputs so
|
|
this test scenario is unlikely to be seen in the real world. But this version of
|
|
the test has one notable exception: Bitcoin Core.
|
|
|
|
Core can hold the descriptors of several spending wallets. It will annotate an
|
|
output that belongs to ANY of its descriptors, regardless of whether it differs
|
|
from the input wallet. But Core does not write global xpubs at all, so it wasn't a
|
|
factor in the previous test (which required the global xpubs).
|
|
|
|
But a Core-built psbt can exactly match this test's shape: a fully annotated
|
|
foreign output and no global xpubs to compare against.
|
|
"""
|
|
# Test each multisig script type
|
|
for input_base64, change_hex in [
|
|
(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),
|
|
]:
|
|
psbt = self._psbt_with_change(input_base64, change_hex)
|
|
self._repoint_at_a_different_quorum(psbt)
|
|
|
|
# The global xpubs must be omitted for this scenario
|
|
psbt.xpubs.clear()
|
|
|
|
psbt_parser = self._parse(psbt)
|
|
|
|
# Parser categorizes the output as presumed change.
|
|
assert psbt_parser.change_amount == 10_000
|
|
assert psbt_parser.spend_amount == 0
|
|
|
|
|
|
def test__parse__refuses_an_unsupported_script_type(self):
|
|
"""
|
|
Parsing should be aborted if a psbt has inputs and outputs that use a script type
|
|
that embit doesn't recognize. embit reports unhandled types as None. In this case
|
|
the output's policy shape would match the input's (`None` == `None`) and so the
|
|
parse would consider it as possible change. Rather than continue, we expect the
|
|
catch-all safety check to spot the unsupported script type and raise RuntimeError.
|
|
"""
|
|
root = self._root()
|
|
|
|
# embit does not support p2pk so its script type will be `None`
|
|
def p2pk(public_key: PublicKey) -> script.Script:
|
|
OP_PUSHBYTES_33 = b"\x21"
|
|
OP_CHECKSIG = b"\xac"
|
|
return script.Script(OP_PUSHBYTES_33 + public_key.sec() + OP_CHECKSIG)
|
|
|
|
psbt = self._psbt_with_change()
|
|
psbt.inputs[0].witness_utxo.script_pubkey = p2pk(root.derive("m/84h/1h/0h/0/3").get_public_key())
|
|
psbt.outputs[0].script_pubkey = p2pk(root.derive("m/84h/1h/0h/1/0").get_public_key())
|
|
|
|
# The policy shape check really does let this output through to the scriptPubKey
|
|
# rebuild step.
|
|
assert psbt.inputs[0].witness_utxo.script_pubkey.script_type() is None
|
|
assert psbt.outputs[0].script_pubkey.script_type() is None
|
|
|
|
with pytest.raises(RuntimeError, match="Unsupported policy type"):
|
|
self._parse(psbt)
|