Avoid displaying unverified PSBT fees
What changed, and why it matters
This update changes how the Passport hardware wallet handles Bitcoin transaction fees shown during signing. Previously, the wallet could display a network fee even when it could not independently verify the input amounts supplied by another wallet. Now it marks such fees as 'Unverified' and skips fee-based warnings that could mislead the user. It also adds cross-checks when a transaction input provides both witness and non-witness data, ensuring they agree before trusting the amount.
Treat this as a security-hardening fix and include it in the next firmware release. Users should be advised to update and to treat 'Unverified Fee' prompts as a reason to double-check the transaction in the originating wallet. Review companion firmware components for similar fee-display logic.
Security signals we found
UI no longer displays a concrete fee value when input amounts cannot be independently verified
New fee_is_verified flag tracks whether all input values were validated against full previous transactions
Witness and non-witness UTXO data are now cross-checked for amount and scriptPubKey equality
Witness UTXO supplied for a non-SegWit input now raises FatalPSBTIssue
Fee-percentage warnings (Big Fee / Huge Fee) are skipped when the fee is unverified
Evidence from the diff
The commit modifies the PSBT signing flow to set psbtObject.fee_is_verified = False when an external input supplies only a witness UTXO (no non-witness UTXO) and Passport is not signing that input. It also strengthens psbtInputProxy.get_utxo() to compare witness and non-witness UTXO amounts/scripts, and tightens determine_my_signing_key() to reject witness UTXOs for non-SegWit inputs. UI code now renders ‘Unverified Network Fee’ instead of a numeric fee when fee_is_verified is false, and fee-percentage warnings are suppressed in that case. Unit tests are added to cover these behaviors.
Changed components
ports/stm32/boards/Passport/modules/flows/sign_psbt_common_flow.pyports/stm32/boards/Passport/modules/psbt.pyports/stm32/boards/Passport/modules/tests/test_unit.pyports/stm32/boards/Passport/modules/tests/unit/psbt_fee.pyInspect captured patch +235 / −14
### ports/stm32/boards/Passport/modules/flows/sign_psbt_common_flow.py
@@ -260,7 +260,9 @@ def render_warnings(self):
# gc.collect()
fee = self.psbt.calculate_fee()
- if fee is not None:
+ if not self.psbt.fee_is_verified:
+ msg.write('\n{}\nUnverified '.format(recolor(HIGHLIGHT_TEXT_HEX, 'Network Fee')))
+ elif fee is not None:
amount, units = self.chain.render_value(fee)
msg.write('\n{}\n{} {} '.format(recolor(HIGHLIGHT_TEXT_HEX, 'Network Fee'), amount, units))
### ports/stm32/boards/Passport/modules/psbt.py
@@ -643,19 +643,18 @@ def get_utxo(self, idx):
fd = self.fd
old_pos = fd.tell()
+ witness_utxo = None
if self.witness_utxo:
- # Going forward? Just what we will witness; no other junk
- # - prefer this format, altho does that imply segwit txn must be generated?
- # - I don't know why we wouldn't always use this
- # - once we use this partial utxo data, we must create witness data out
- self.is_segwit = True
+ # Load the compact output. If the full previous transaction is also
+ # present, its hash-bound output is loaded below and must match.
fd.seek(self.witness_utxo[0])
- utxo = CTxOut()
- utxo.deserialize(fd)
- fd.seek(old_pos)
+ witness_utxo = CTxOut()
+ witness_utxo.deserialize(fd)
- return utxo
+ if not self.utxo:
+ fd.seek(old_pos)
+ return witness_utxo
assert self.utxo, 'no utxo'
@@ -685,6 +684,11 @@ def get_utxo(self, idx):
fd.seek(old_pos)
+ if witness_utxo:
+ assert witness_utxo.nValue == utxo.nValue and \
+ witness_utxo.scriptPubKey == utxo.scriptPubKey, \
+ "witness/non-witness UTXO mismatch for input #%d" % idx
+
return utxo
def determine_my_signing_key(self, my_idx, utxo, my_xfp, psbt):
@@ -708,8 +712,18 @@ def determine_my_signing_key(self, my_idx, utxo, my_xfp, psbt):
which_key = None
addr_type, addr_or_pubkey, addr_is_segwit = utxo.get_address()
- if addr_is_segwit and not self.is_segwit:
- self.is_segwit = True
+ self.is_segwit = addr_is_segwit
+
+ if self.witness_utxo and not self.is_segwit:
+ if addr_type == 'p2sh' and self.redeem_script:
+ redeem_script = self.get(self.redeem_script)
+ assert hash160(redeem_script) == addr_or_pubkey, \
+ "redeem script mismatch for input #%d" % my_idx
+ self.is_segwit = len(redeem_script) in {22, 34} and \
+ redeem_script[0] == 0 and redeem_script[1] in {20, 32}
+
+ if not self.is_segwit:
+ raise FatalPSBTIssue("Witness UTXO provided for non-SegWit input #%d" % my_idx)
if addr_type == 'p2sh':
# multisig input
@@ -957,6 +971,7 @@ def __init__(self):
self.lock_time = None
self.total_value_out = None
self.total_value_in = None
+ self.fee_is_verified = True
self.presigned_inputs = set()
self.multisig_import_needs_approval = False
self.self_send = False
@@ -1269,15 +1284,21 @@ def consider_outputs(self):
# print('total_non_change_out={} self.total_value_out={} total_change={}'.format(total_non_change_out,
# self.total_value_out, total_change))
fee = self.calculate_fee()
+ per_fee = None
if self.total_value_out == 0:
per_fee = 100
elif total_non_change_out == 0:
self.self_send = True
- else:
+ elif self.fee_is_verified:
# Calculate fee based on non-change output value
per_fee = (fee / total_non_change_out) * 100
- if self.self_send:
+ if not self.fee_is_verified:
+ self.warnings.append(
+ ('Unverified Fee',
+ 'One or more input amounts were supplied by another wallet, '
+ 'so Passport cannot verify the network fee.'))
+ elif self.self_send:
# self.warnings.append(('Self-Send', 'All outputs are being sent back to this wallet.'))
per_fee = (fee / self.total_value_out) * 100
if per_fee >= 5:
@@ -1427,6 +1448,10 @@ def consider_inputs(self):
# - also finds appropriate multisig wallet to be used
inp.determine_my_signing_key(i, utxo, self.my_xfp, self)
+ if inp.witness_utxo and not inp.utxo and \
+ not (inp.num_our_keys and inp.required_key):
+ self.fee_is_verified = False
+
gc.collect()
# iff to UTXO is segwit, then check it's value, and also
### ports/stm32/boards/Passport/modules/tests/test_unit.py
@@ -38,3 +38,6 @@ def test_psbt_amounts(test):
def test_multisig_xpub_validation(test):
assert test('multisig_xpub_validation.py') == b'OK'
+
+def test_psbt_fee(test):
+ assert test('psbt_fee.py') == b'OK'
### ports/stm32/boards/Passport/modules/tests/unit/psbt_fee.py
@@ -0,0 +1,191 @@
+# SPDX-FileCopyrightText: © 2026 Foundation Devices, Inc. <hello@foundation.xyz>
+# SPDX-License-Identifier: GPL-3.0-or-later
+#
+# Test PSBT input-value validation and fee review rendering.
+
+from uio import BytesIO
+from ustruct import pack
+
+from exceptions import FatalPSBTIssue
+from flows.sign_psbt_common_flow import SignPsbtCommonFlow
+from psbt import psbtInputProxy, psbtObject
+from public_constants import (
+ PSBT_IN_NON_WITNESS_UTXO,
+ PSBT_IN_REDEEM_SCRIPT,
+ PSBT_IN_WITNESS_UTXO,
+)
+from serializations import CTxOut, hash160, ser_compact_size
+
+
+P2WPKH_SCRIPT = b'\x00\x14' + (b'\x11' * 20)
+P2PKH_SCRIPT = b'\x76\xa9\x14' + (b'\x22' * 20) + b'\x88\xac'
+DUMMY_PUBKEY = b'\x02' + (b'\x55' * 32)
+
+
+def psbt_field(key_type, value):
+ return b'\x01' + bytes([key_type]) + ser_compact_size(len(value)) + value
+
+
+def previous_tx(txout):
+ txin = (b'\x00' * 32) + pack('<I', 0) + b'\x00' + pack('<I', 0xffffffff)
+ return pack('<i', 2) + b'\x01' + txin + b'\x01' + txout.serialize() + pack('<I', 0)
+
+
+def make_input(witness_txout, non_witness_txout=None, redeem_script=None):
+ data = b''
+ if non_witness_txout:
+ data += psbt_field(PSBT_IN_NON_WITNESS_UTXO, previous_tx(non_witness_txout))
+ data += psbt_field(PSBT_IN_WITNESS_UTXO, witness_txout.serialize())
+ if redeem_script:
+ data += psbt_field(PSBT_IN_REDEEM_SCRIPT, redeem_script)
+ return psbtInputProxy(BytesIO(data + b'\x00'), 0)
+
+
+def assert_raises(exc_type, callback):
+ try:
+ callback()
+ except exc_type:
+ return
+ raise AssertionError('Expected {}'.format(exc_type))
+
+
+matching = CTxOut(1000, P2WPKH_SCRIPT)
+loaded = make_input(matching, matching).get_utxo(0)
+assert loaded.nValue == matching.nValue
+assert loaded.scriptPubKey == matching.scriptPubKey
+
+amount_mismatch = make_input(CTxOut(999, P2WPKH_SCRIPT), matching)
+assert_raises(AssertionError, lambda: amount_mismatch.get_utxo(0))
+
+script_mismatch = make_input(CTxOut(1000, b'\x00\x14' + (b'\x33' * 20)), matching)
+assert_raises(AssertionError, lambda: script_mismatch.get_utxo(0))
+
+legacy = make_input(CTxOut(1000, P2PKH_SCRIPT))
+legacy.subpaths[DUMMY_PUBKEY] = []
+assert_raises(FatalPSBTIssue, lambda: legacy.determine_my_signing_key(0, CTxOut(1000, P2PKH_SCRIPT), 0, None))
+
+native_segwit = make_input(CTxOut(1000, P2WPKH_SCRIPT))
+native_segwit.subpaths[DUMMY_PUBKEY] = []
+native_segwit.determine_my_signing_key(0, CTxOut(1000, P2WPKH_SCRIPT), 0, None)
+assert native_segwit.is_segwit
+
+wrapped_program = b'\x00\x14' + (b'\x44' * 20)
+wrapped_script = b'\xa9\x14' + hash160(wrapped_program) + b'\x87'
+wrapped_segwit = make_input(CTxOut(1000, wrapped_script), redeem_script=wrapped_program)
+wrapped_segwit.subpaths[DUMMY_PUBKEY] = []
+wrapped_segwit.determine_my_signing_key(0, CTxOut(1000, wrapped_script), 0, None)
+assert wrapped_segwit.is_segwit
+
+
+class FakeInput:
+ def __init__(self, owned):
+ self.owned = owned
+ self.fully_signed = False
+ self.witness_utxo = True
+ self.utxo = None
+ self.required_key = None
+ self.num_our_keys = 1 if owned else 0
+ self.is_segwit = False
+ self.subpaths = {}
+ self.tap_subpaths = {}
+
+ def has_utxo(self):
+ return True
+
+ def get_utxo(self, _idx):
+ return CTxOut(2000, P2WPKH_SCRIPT)
+
+ def determine_my_signing_key(self, _idx, utxo, _xfp, _psbt):
+ self.amount = utxo.nValue
+ self.required_key = b'key' if self.owned else None
+
+
+class FakePrevout:
+ n = 0
+
+
+class FakeTxIn:
+ prevout = FakePrevout()
+
+
+class FakeInputPSBT:
+ def __init__(self, owned):
+ self.inputs = [FakeInput(owned)]
+ self.my_xfp = 0
+ self.total_value_in = None
+ self.fee_is_verified = True
+ self.presigned_inputs = set()
+ self.num_inputs = 1
+ self.warnings = []
+
+ def input_iter(self):
+ yield 0, FakeTxIn()
+
+
+external_input_psbt = FakeInputPSBT(False)
+psbtObject.consider_inputs(external_input_psbt)
+assert not external_input_psbt.fee_is_verified
+
+owned_input_psbt = FakeInputPSBT(True)
+psbtObject.consider_inputs(owned_input_psbt)
+assert owned_input_psbt.fee_is_verified
+
+
+class FakeOutputProxy:
+ is_change = False
+
+ def validate(self, _idx, _txout, _xfp, _active_multisig):
+ pass
+
+
+class FakeOutputPSBT:
+ def __init__(self, fee_is_verified):
+ self.outputs = [FakeOutputProxy()]
+ self.total_value_out = 1000
+ self.total_value_in = 5000
+ self.fee_is_verified = fee_is_verified
+ self.self_send = False
+ self.warnings = []
+ self.my_xfp = 0
+ self.active_multisig = None
+
+ def output_iter(self):
+ yield 0, CTxOut(self.total_value_out, P2WPKH_SCRIPT)
+
+ def calculate_fee(self):
+ return self.total_value_in - self.total_value_out
+
+ def consider_dangerous_change(self, _xfp):
+ pass
+
+
+unverified_fee_psbt = FakeOutputPSBT(False)
+psbtObject.consider_outputs(unverified_fee_psbt)
+assert unverified_fee_psbt.warnings[0][0] == 'Unverified Fee'
+assert all(label not in {'Big Fee', 'Huge Fee'} for label, _text in unverified_fee_psbt.warnings)
+
+verified_fee_psbt = FakeOutputPSBT(True)
+psbtObject.consider_outputs(verified_fee_psbt)
+assert verified_fee_psbt.warnings[0][0] == 'Huge Fee'
+
+
+class FakeChain:
+ def render_value(self, value):
+ return str(value), 'sats'
+
+
+class FakeFlow:
+ chain = FakeChain()
+
+ def __init__(self, psbt):
+ self.psbt = psbt
+
+
+review = SignPsbtCommonFlow.render_warnings(FakeFlow(unverified_fee_psbt))
+assert 'Unverified' in review
+assert '4000 sats' not in review
+
+review = SignPsbtCommonFlow.render_warnings(FakeFlow(verified_fee_psbt))
+assert '4000 sats' in review
+
+return_value.write(b'OK')Why this scored 66/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.