Mark coordinator-supplied change data as claimed

Nothing in a psbt proves the fingerprints and derivation paths it
carries, but change_data's fingerprint and derivation_path keys read
like facts -- the change details view splits one into a wallet path and
hands it straight to get_xpub. Renaming them to claimed_fingerprint and
claimed_derivation_path, along with the locals that carry them, puts the
trust boundary in the data itself rather than in the logic around it.

The class docstring now states the convention behind the prefix:
claimed_ for coordinator-supplied metadata, verified_ for what this
device proved by re-deriving from the signing seed, and the invariant
that no verified_ value comes from a claimed_ one without a derivation
in between. It also records that change_data covers self-transfers, not
just change-branch outputs. No logic changes; the two dict keys are the
only behavioral difference.
This commit is contained in:
kdmukai
2026-08-20 11:53:42 -05:00
parent fa35c3509e
commit fc21710b9d
3 changed files with 47 additions and 27 deletions
+29 -9
View File
@@ -27,10 +27,30 @@ class PSBTParser():
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.
attributes, with per-output detail in change_data. Note that change_data and
change_amount cover every output coming back to this seed, self-transfers to a receive
address included, not just the ones on a change branch. The view layer tells the two
apart by the branch index in the derivation path.
has_matching_input_fingerprint answers the earlier question of which seed a psbt is
for and needs no parse.
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.
"""
# 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_fingerprint": claimed_fingerprints,
"claimed_derivation_path": claimed_derivation_paths,
})
self.change_amount += vout[i].value
+14 -14
View File
@@ -116,16 +116,16 @@ 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_fingerprint': ['22bde1a9', '73c5da0a'],
'claimed_derivation_path': ['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":
# print(f"""{change_output["claimed_derivation_path"][0]}""")
if change_output["claimed_derivation_path"][0].split("/")[-2] == "1":
num_change_outputs += 1
else:
num_self_transfer_outputs += 1
@@ -325,8 +325,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_fingerprint': ['22bde1a9', '73c5da0a'],
'claimed_derivation_path': ['m/48h/1h/0h/2h/1/0', 'm/48h/1h/0h/2h/1/0']
}
"""
@@ -334,16 +334,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_fingerprint"):
# 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_fingerprint").index(seed_fingerprint)
claimed_derivation_path = change_data.get("claimed_derivation_path")[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 +384,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 +425,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_fingerprint': ['394aed14'],
'claimed_derivation_path': ['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_fingerprint': ['0fb882ff'],
'claimed_derivation_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