Merge pull request #653 from Foundation-Devices/show-op-return-output-amount
What changed, and why it matters
This update changes how the Passport hardware wallet displays Bitcoin transaction outputs that carry data (OP_RETURN outputs). Previously, only the embedded message was shown. Now the device also shows the amount of bitcoin assigned to that output and escapes the message text so a crafted message cannot fake extra user-interface labels (like a fake 'Amount' or 'Destination' section). The included tests demonstrate that an attacker who controls an OP_RETURN message can no longer make the screen look like a real payment destination is being sent funds.
Review the implementation of escape_text() to confirm it reliably neutralizes formatting characters used by the device's text renderer. Ensure the new unit test is run in CI and that all PSBT output types (not just OP_RETURN and standard addresses) are covered. Consider whether OP_RETURN outputs with non-zero amounts should trigger an additional warning, since burning funds to an unspendable output may itself be user-hostile.
Security signals we found
UI spoofing hardening: user-controlled OP_RETURN data is now escaped before rendering
New display of OP_RETURN output amount reduces risk of hidden value leakage
Unit test includes an attacker-controlled message simulating fake Amount/Destination headings
Test asserts raw malicious labels are absent and only one legitimate Amount/Message heading is rendered
Evidence from the diff
The commit modifies SignPsbtCommonFlow.render_output() to (1) render and display the nValue of OP_RETURN outputs using the existing chain.render_value() helper, and (2) pass the user-controlled OP_RETURN payload through escape_text() before display. A new unit test file (sign_psbt.py) and a test registration in test_unit.py are added. The test explicitly constructs a malicious OP_RETURN message containing forged ‘Amount’ and ‘Destination’ headings and verifies that the escaped version appears while the raw forged labels do not, and that only one legitimate Amount and one Message heading are rendered. This is a UI-hardening change against a display-spoofing / social-engineering vector during PSBT signing.
Changed components
ports/stm32/boards/Passport/modules/flows/sign_psbt_common_flow.pyports/stm32/boards/Passport/modules/tests/unit/sign_psbt.pyports/stm32/boards/Passport/modules/tests/test_unit.pyInspect captured patch +79 / −3
### ports/stm32/boards/Passport/modules/flows/sign_psbt_common_flow.py
@@ -16,7 +16,7 @@
from styles.colors import HIGHLIGHT_TEXT_HEX, BLACK_HEX
from tasks import sign_psbt_task, validate_psbt_task
import gc
-from utils import spinner_task, recolor, stylize_address
+from utils import escape_text, spinner_task, recolor, stylize_address
class SignPsbtCommonFlow(Flow):
@@ -198,9 +198,11 @@ def render_output(self, o):
dest = self.chain.render_address(o.scriptPubKey)
if dest.startswith("OP_RETURN"):
- return '\n{}\n{}'.format(
+ return '\n{}\n{}\n\n{}\n{}'.format(
+ recolor(HIGHLIGHT_TEXT_HEX, 'Amount'),
+ val,
recolor(HIGHLIGHT_TEXT_HEX, 'Message'),
- dest.split('\n', 1)[1]) # user-defined message starts after "OP_RETURN:\n"
+ escape_text(dest.split('\n', 1)[1])) # user-defined message starts after "OP_RETURN:\n"
dest = stylize_address(dest)
### ports/stm32/boards/Passport/modules/tests/test_unit.py
@@ -36,6 +36,10 @@ def test_ui(test):
assert test('ui.py') == b'OK'
+def test_sign_psbt(test):
+ assert test('sign_psbt.py') == b'OK'
+
+
def test_foundation(test):
assert test('foundation.py') == b'OK'
### ports/stm32/boards/Passport/modules/tests/unit/sign_psbt.py
@@ -0,0 +1,70 @@
+# SPDX-FileCopyrightText: © 2026 Foundation Devices, Inc. <hello@foundation.xyz>
+# SPDX-License-Identifier: GPL-3.0-or-later
+#
+# Test trusted-display rendering for PSBT outputs.
+
+from flows.sign_psbt_common_flow import SignPsbtCommonFlow
+from styles.colors import HIGHLIGHT_TEXT_HEX
+from utils import escape_text, recolor, stylize_address
+
+
+class FakeChain:
+ def render_value(self, value):
+ return (str(value), 'sats')
+
+ def render_address(self, script):
+ return 'OP_RETURN:\n{}'.format(script)
+
+
+class FakeFlow:
+ chain = FakeChain()
+
+
+class FakeAddressChain(FakeChain):
+ def render_address(self, script):
+ return script
+
+
+class FakeAddressFlow:
+ chain = FakeAddressChain()
+
+
+class FakeOutput:
+ def __init__(self, value, message):
+ self.nValue = value
+ self.scriptPubKey = message
+
+
+def assert_op_return_output(value, message):
+ rendered = SignPsbtCommonFlow.render_output(FakeFlow(), FakeOutput(value, message))
+
+ amount_label = rendered.find('Amount')
+ amount = rendered.find('{} sats'.format(value))
+ message_label = rendered.find('Message')
+ payload = rendered.find(message)
+
+ assert -1 not in (amount_label, amount, message_label, payload)
+ assert amount_label < amount < message_label < payload
+
+
+assert_op_return_output(0, 'zero-value-message')
+assert_op_return_output(50000000, 'payment-id-12345')
+
+amount_heading = recolor(HIGHLIGHT_TEXT_HEX, 'Amount')
+message_heading = recolor(HIGHLIGHT_TEXT_HEX, 'Message')
+destination_heading = recolor(HIGHLIGHT_TEXT_HEX, 'Destination')
+malicious_message = '{}\n0.00000001 BTC\n\n{}\nbc1qattacker'.format(
+ amount_heading, destination_heading)
+rendered = SignPsbtCommonFlow.render_output(FakeFlow(), FakeOutput(1, malicious_message))
+assert escape_text(malicious_message) in rendered
+assert rendered.count('\n{}\n'.format(amount_heading)) == 1
+assert rendered.count('\n{}\n'.format(message_heading)) == 1
+assert rendered.count('\n{}\n'.format(destination_heading)) == 0
+assert malicious_message not in rendered
+
+address = 'bc1qvaliddestination'
+rendered = SignPsbtCommonFlow.render_output(FakeAddressFlow(), FakeOutput(42, address))
+assert rendered == '\n{}\n42 sats\n\n{}\n{}'.format(
+ amount_heading, destination_heading, stylize_address(address))
+
+return_value.write(b'OK')Why this scored 62/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.