Merge pull request #663 from Foundation-Devices/fix/psbt-output-value-bounds
What changed, and why it matters
This update tightens the checks on Bitcoin transaction amounts inside PSBT files handled by the Passport hardware wallet. Before, the firmware did not fully enforce Bitcoin's rule that no single output or input, and no running total, can exceed about 21 million bitcoins (MAX_MONEY). A malicious or buggy companion app could craft a PSBT with oversized, negative, or zero input amounts. The device might then compute a wrong fee or balance, which could trick a user into approving a transaction that sends more than intended, or that hides a large fee. The patch now rejects such PSBTs immediately.
Treat this as a security-hardening fix and include it in the next firmware release. Review whether any other PSBT amount fields (change detection, fee warnings, display formatting) also need MAX_MONEY validation. Run the new unit tests in CI and consider adding integration tests with malformed PSBTs from untrusted host software.
Security signals we found
Bounds checking added for per-output and total output amounts
Bounds checking added for per-input and total input amounts
Replaces a weak assert(utxo.nValue > 0) with proper FatalPSBTIssue error handling
Prevents PSBTs with consensus-invalid amounts from influencing fee/balance calculations
Adds unit tests for amount validation
Evidence from the diff
The commit adds a MAX_MONEY constant (21,000,000 * 100,000,000 satoshis) and validates amounts during PSBT parsing. In psbtObject.output_iter(), each output nValue must satisfy 0 <= nValue <= MAX_MONEY and the running total_out must not exceed MAX_MONEY. In consider_inputs(), each UTXO nValue must satisfy 0 < nValue <= MAX_MONEY and the running total_in must not exceed MAX_MONEY. Previously output values were only summed and input values were only asserted positive. The patch also adds unit tests covering boundary values, negative values, zero inputs, and totals that overflow MAX_MONEY.
Changed components
ports/stm32/boards/Passport/modules/psbt.pyports/stm32/boards/Passport/modules/public_constants.pyports/stm32/boards/Passport/modules/tests/unit/psbt_amounts.pyports/stm32/boards/Passport/modules/tests/test_unit.pyInspect captured patch +150 / −2
### ports/stm32/boards/Passport/modules/psbt.py
@@ -17,7 +17,7 @@
import sys
from sffile import SizerFile
from passport import mem
-from public_constants import MAX_SIGNERS
+from public_constants import MAX_MONEY, MAX_SIGNERS
from multisig_wallet import MultisigWallet, disassemble_multisig_mn
from exceptions import FatalPSBTIssue, FraudulentChangeOutput
from serializations import ser_compact_size, deser_compact_size, hash160, deser_compact_size_bytes
@@ -1004,7 +1004,12 @@ def output_iter(self):
tx_out.deserialize(fd)
+ if not 0 <= tx_out.nValue <= MAX_MONEY:
+ raise FatalPSBTIssue('Invalid amount for output #%d' % idx)
+
total_out += tx_out.nValue
+ if total_out > MAX_MONEY:
+ raise FatalPSBTIssue('Total output amount exceeds maximum at output #%d' % idx)
cont = fd.tell()
yield idx, tx_out
@@ -1409,8 +1414,12 @@ def consider_inputs(self):
# pull out just the CTXOut object (expensive)
utxo = inp.get_utxo(txi.prevout.n)
- assert utxo.nValue > 0
+ if not 0 < utxo.nValue <= MAX_MONEY:
+ raise FatalPSBTIssue('Invalid amount for input #%d' % i)
+
total_in += utxo.nValue
+ if total_in > MAX_MONEY:
+ raise FatalPSBTIssue('Total input amount exceeds maximum at input #%d' % i)
# Look at what kind of input this will be, and therefore what
# type of signing will be required, and which key we need.
### ports/stm32/boards/Passport/modules/public_constants.py
@@ -13,6 +13,9 @@
from constants import PSBT_MAX_SIZE
+# Maximum number of satoshis permitted by Bitcoin consensus.
+MAX_MONEY = const(2100000000000000)
+
# Seed phrase lengths accepted
SEED_LENGTHS = [12, 24]
SEED_WORD_LIST_LENGTH = const(2048)
### ports/stm32/boards/Passport/modules/tests/test_unit.py
@@ -26,3 +26,7 @@ def test_ui(test):
def test_foundation(test):
assert test('foundation.py') == b'OK'
+
+
+def test_psbt_amounts(test):
+ assert test('psbt_amounts.py') == b'OK'
### ports/stm32/boards/Passport/modules/tests/unit/psbt_amounts.py
@@ -0,0 +1,132 @@
+# SPDX-FileCopyrightText: © 2026 Foundation Devices, Inc. <hello@foundation.xyz>
+# SPDX-License-Identifier: GPL-3.0-or-later
+#
+# Test consensus bounds for input and output amounts in unsigned PSBT transactions.
+
+from uio import BytesIO
+
+from exceptions import FatalPSBTIssue
+from psbt import psbtObject
+from public_constants import MAX_MONEY
+from serializations import CTxOut
+
+
+P2WPKH_SCRIPT = b'\x00\x14' + (b'\x11' * 20)
+
+
+class FakePSBT:
+ def __init__(self, values):
+ self.fd = BytesIO(b''.join(CTxOut(value, P2WPKH_SCRIPT).serialize() for value in values))
+ self.vout_start = 0
+ self.num_outputs = len(values)
+ self.total_value_out = None
+
+
+class FakePrevout:
+ n = 0
+
+
+class FakeTxIn:
+ prevout = FakePrevout()
+
+
+class FakeInput:
+ def __init__(self, value):
+ self.value = value
+ self.fully_signed = False
+ self.num_our_keys = 1
+ self.required_key = b'key'
+ self.is_segwit = False
+
+ def has_utxo(self):
+ return True
+
+ def get_utxo(self, _idx):
+ return CTxOut(self.value, P2WPKH_SCRIPT)
+
+ def determine_my_signing_key(self, _idx, _utxo, _xfp, _psbt):
+ pass
+
+
+class FakeInputPSBT:
+ def __init__(self, values):
+ self.inputs = [FakeInput(value) for value in values]
+ self.my_xfp = 0
+ self.total_value_in = None
+ self.fee_is_verified = True
+ self.presigned_inputs = set()
+ self.num_inputs = len(values)
+ self.warnings = []
+
+ def input_iter(self):
+ for idx in range(self.num_inputs):
+ yield idx, FakeTxIn()
+
+
+def read_outputs(values):
+ psbt = FakePSBT(values)
+ parsed = [tx_out.nValue for _, tx_out in psbtObject.output_iter(psbt)]
+ return psbt, parsed
+
+
+def read_inputs(values):
+ psbt = FakeInputPSBT(values)
+ psbtObject.consider_inputs(psbt)
+ return psbt
+
+
+def assert_invalid_outputs(values, invalid_idx):
+ psbt = FakePSBT(values)
+ try:
+ list(psbtObject.output_iter(psbt))
+ except FatalPSBTIssue as exc:
+ assert '#%d' % invalid_idx in str(exc)
+ assert psbt.total_value_out is None
+ return
+ raise AssertionError('Expected invalid output values to be rejected')
+
+
+def assert_invalid_inputs(values, invalid_idx):
+ psbt = FakeInputPSBT(values)
+ try:
+ psbtObject.consider_inputs(psbt)
+ except FatalPSBTIssue as exc:
+ assert '#%d' % invalid_idx in str(exc)
+ assert psbt.total_value_in is None
+ return
+ raise AssertionError('Expected invalid input values to be rejected')
+
+
+zero, parsed = read_outputs([0])
+assert parsed == [0]
+assert zero.total_value_out == 0
+
+maximum, parsed = read_outputs([MAX_MONEY])
+assert parsed == [MAX_MONEY]
+assert maximum.total_value_out == MAX_MONEY
+
+split_maximum, parsed = read_outputs([MAX_MONEY - 1, 1])
+assert parsed == [MAX_MONEY - 1, 1]
+assert split_maximum.total_value_out == MAX_MONEY
+
+assert_invalid_outputs([-1], 0)
+assert_invalid_outputs([MAX_MONEY + 1], 0)
+assert_invalid_outputs([1, -(1 << 63)], 1)
+assert_invalid_outputs([MAX_MONEY, 1], 1)
+
+one_input = read_inputs([1])
+assert one_input.total_value_in == 1
+
+maximum_input = read_inputs([MAX_MONEY])
+assert maximum_input.total_value_in == MAX_MONEY
+
+split_maximum_inputs = read_inputs([MAX_MONEY - 1, 1])
+assert split_maximum_inputs.total_value_in == MAX_MONEY
+
+assert_invalid_inputs([0], 0)
+assert_invalid_inputs([-1], 0)
+assert_invalid_inputs([MAX_MONEY + 1], 0)
+assert_invalid_inputs([1, 1 << 62], 1)
+assert_invalid_inputs([MAX_MONEY, 1], 1)
+
+return_value.write(b'OK')Why this scored 72/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.