Merge pull request #1005 from kdmukai/psbt_parser_trust_vocabulary

[refactor] PSBT parsing: rename `change_data`'s unverified fields to `claimed_*`
This commit is contained in:
Nick Klockenga
2026-08-22 22:21:36 -04:00
committed by GitHub
3 changed files with 60 additions and 36 deletions
+43 -18
View File
@@ -21,16 +21,36 @@ class OPCODES:
class PSBTParser():
"""
Reads a psbt on behalf of one seed and works out everything the signing flow shows
the user before they approve: the wallet policy (script type, plus m-of-n and the
cosigners for multisig), the amount coming in, what is being spent, what comes back
as change, the fee, where the spend is going, and any OP_RETURN payload.
Reads a psbt on behalf of one seed and works out everything the signing flow shows the
user before they approve: the wallet policy (script type, plus m-of-n and the
cosigners for multisig), the amount coming in, what is being spent, what comes back as
change, the fee, where the spend is going, and any OP_RETURN payload.
Constructing it with a seed parses immediately. The results are read off the instance
attributes, with per-change-output detail in change_data.
Constructing it with a seed parses immediately.
has_matching_input_fingerprint answers the earlier question of which seed a psbt is
for and needs no parse.
The parse fully processes the psbt, validates what it can, then stores the organized
results in the instance attributes (spend_amount, fee_amount, destination_addresses,
etc.). Note that change_data and change_amount cover EVERY output coming back to this
seed, including self-transfers to a receive address. The view layer tells the two
apart by the branch index in the derivation path.
A psbt is written by an untrusted coordinator. The metadata it carries about keys
(fingerprints, derivation paths, xpubs) is a claim, not a fact. The onus is on us to
verify by re-deriving from the signing seed. For multisig, verification depends on the
user providing a "known good" descriptor (i.e. can be trusted) from which we can
verify the outputs by deriving from the cosigners' xpubs.
This class makes the difference visible in its own names:
claimed_... coordinator-supplied metadata (fingerprints, derivation paths, xpubs).
Safe to read and display; never safe to make a decision on.
verified_... a fact this device proved by re-deriving from the signing seed and
matching real key material. Only assigned by code that performed that
derivation.
Invariant: no verified_ value is ever assigned from a claimed_ value without an
intervening re-derivation from self.root or from a user-supplied "known good"
descriptor.
"""
# Upper bound on how many levels of derivation a single parse will cache. 1000 is
@@ -237,27 +257,27 @@ class PSBTParser():
elif is_change:
addr = vout[i].script_pubkey.address(NETWORKS[SettingsConstants.map_network_to_embit(self.network)])
fingerprints = []
derivation_paths = []
claimed_fingerprints = []
claimed_derivation_paths = []
# extract info from non-taproot outputs
if len(self.psbt.outputs[i].bip32_derivations) > 0:
for d, derivation_path in self.psbt.outputs[i].bip32_derivations.items():
fingerprints.append(hexlify(derivation_path.fingerprint).decode())
derivation_paths.append(bip32.path_to_str(derivation_path.derivation))
claimed_fingerprints.append(hexlify(derivation_path.fingerprint).decode())
claimed_derivation_paths.append(bip32.path_to_str(derivation_path.derivation))
# extract info from taproot outputs
if len(self.psbt.outputs[i].taproot_bip32_derivations) > 0:
for d, (leaf_hashes, derivation) in self.psbt.outputs[i].taproot_bip32_derivations.items():
fingerprints.append(hexlify(derivation.fingerprint).decode())
derivation_paths.append(bip32.path_to_str(derivation.derivation))
claimed_fingerprints.append(hexlify(derivation.fingerprint).decode())
claimed_derivation_paths.append(bip32.path_to_str(derivation.derivation))
self.change_data.append({
"output_index": i,
"address": addr,
"amount": vout[i].value,
"fingerprint": fingerprints,
"derivation_path": derivation_paths,
"claimed_fingerprints": claimed_fingerprints,
"claimed_derivation_paths": claimed_derivation_paths,
})
self.change_amount += vout[i].value
@@ -469,8 +489,13 @@ class PSBTParser():
@staticmethod
def has_matching_input_fingerprint(psbt: PSBT, seed: Seed, network: str = SettingsConstants.MAINNET):
"""
Extracts the fingerprint from each psbt input utxo. Returns True if any match
the current seed.
Extracts the claimed fingerprint from each psbt input. Returns True if any
match the provided seed.
This is merely a routing hint to help the user select a seed that looks like
it should be able to sign the psbt; it verifies nothing. Actual verification
only begins once a seed has been selected and passed into a PSBTParser
instance.
"""
seed_fingerprint = seed.get_fingerprint(network)
+13 -14
View File
@@ -116,16 +116,15 @@ class PSBTOverviewView(View):
{
'address': 'bc1q............',
'amount': 397621401,
'fingerprint': ['22bde1a9', '73c5da0a'],
'derivation_path': ['m/48h/1h/0h/2h/1/0', 'm/48h/1h/0h/2h/1/0']
'claimed_fingerprints': ['22bde1a9', '73c5da0a'],
'claimed_derivation_paths': ['m/48h/1h/0h/2h/1/0', 'm/48h/1h/0h/2h/1/0']
}, {},
]
"""
num_change_outputs = 0
num_self_transfer_outputs = 0
for change_output in change_data:
# print(f"""{change_output["derivation_path"][0]}""")
if change_output["derivation_path"][0].split("/")[-2] == "1":
if change_output["claimed_derivation_paths"][0].split("/")[-2] == "1":
num_change_outputs += 1
else:
num_self_transfer_outputs += 1
@@ -325,8 +324,8 @@ class PSBTChangeDetailsView(View):
{
'address': 'bc1q............',
'amount': 397621401,
'fingerprint': ['22bde1a9', '73c5da0a'],
'derivation_path': ['m/48h/1h/0h/2h/1/0', 'm/48h/1h/0h/2h/1/0']
'claimed_fingerprints': ['22bde1a9', '73c5da0a'],
'claimed_derivation_paths': ['m/48h/1h/0h/2h/1/0', 'm/48h/1h/0h/2h/1/0']
}
"""
@@ -334,16 +333,16 @@ class PSBTChangeDetailsView(View):
# and derivation path.
seed_fingerprint = self.controller.psbt_seed.get_fingerprint(self.settings.get_value(SettingsConstants.SETTING__NETWORK))
if seed_fingerprint not in change_data.get("fingerprint"):
if seed_fingerprint not in change_data.get("claimed_fingerprints"):
# TODO: Something is wrong with this psbt(?). Reroute to warning?
return Destination(NotYetImplementedView)
i = change_data.get("fingerprint").index(seed_fingerprint)
derivation_path = change_data.get("derivation_path")[i]
i = change_data.get("claimed_fingerprints").index(seed_fingerprint)
claimed_derivation_path = change_data.get("claimed_derivation_paths")[i]
# 'm/84h/1h/0h/1/0' would be a change addr while 'm/84h/1h/0h/0/0' is a self-receive
is_change_derivation_path = int(derivation_path.split("/")[-2]) == 1
derivation_path_addr_index = int(derivation_path.split("/")[-1])
is_change_derivation_path = int(claimed_derivation_path.split("/")[-2]) == 1
derivation_path_addr_index = int(claimed_derivation_path.split("/")[-1])
if is_change_derivation_path:
# TRANSLATOR_NOTE: The amount you're receiving back from the transaction
@@ -384,8 +383,8 @@ class PSBTChangeDetailsView(View):
script_type = pubkey.script_type()
# extract derivation path to get wallet and change derivation
change_path = '/'.join(derivation_path.split("/")[-2:])
wallet_path = '/'.join(derivation_path.split("/")[:-2])
change_path = '/'.join(claimed_derivation_path.split("/")[-2:])
wallet_path = '/'.join(claimed_derivation_path.split("/")[:-2])
xpub = self.controller.psbt_seed.get_xpub(
wallet_path=wallet_path,
@@ -425,7 +424,7 @@ class PSBTChangeDetailsView(View):
amount=change_data.get("amount"),
is_multisig=psbt_parser.is_multisig,
fingerprint=seed_fingerprint,
derivation_path=derivation_path,
derivation_path=claimed_derivation_path,
is_change_derivation_path=is_change_derivation_path,
derivation_path_addr_index=derivation_path_addr_index,
is_change_addr_verified=is_change_addr_verified,
+4 -4
View File
@@ -321,8 +321,8 @@ def test_p2tr_change_detection():
'output_index': 0,
'address': 'bcrt1prz4g6saush37epdwhvwpu78td3q7yfz3xxz37axlx7udck6wracq3rwq30',
'amount': 2871443918,
'fingerprint': ['394aed14'],
'derivation_path': ['m/86h/1h/0h/1/1']}
'claimed_fingerprints': ['394aed14'],
'claimed_derivation_paths': ['m/86h/1h/0h/1/1']}
]
assert pp.spend_amount == 319049328
assert pp.change_amount == 2871443918
@@ -481,8 +481,8 @@ def test_parse_op_return_content():
'output_index': 0,
'address': 'bcrt1qvwkhakqhz7m7kmz6332avatsmdy32m644g86vv',
'amount': 99992296,
'fingerprint': ['0fb882ff'],
'derivation_path': ["m/84h/1h/0h/0/2"]}
'claimed_fingerprints': ['0fb882ff'],
'claimed_derivation_paths': ["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