Show Transaction Version, Locktime and inputs' sequences when signing transactions! (#321)
What changed, and why it matters
This commit adds more information to the transaction signing screen of the Specter DIY hardware wallet. It now shows the transaction version, locktime, and each input's sequence number, helping users spot unusual transaction settings (such as delayed spending or disabled locktime) before they approve a signature. There is no direct evidence this fixes an active exploit; it is primarily a user-interface hardening change.
No urgent action required. Treat as a normal UI improvement that may reduce social-engineering and transaction-malleability risks by making locktime/sequence/version visible to users. Review the sequence/locktime interpretation logic for correctness, and verify the new `conv_time()` helper behaves correctly on the target embedded platform.
Security signals we found
UI now surfaces transaction version, nLockTime, and input nSequence during signing
Sequence values are interpreted to warn about RBF and relative locktime
Locktime is flagged when all inputs have sequence=0xFFFFFFFF (locktime disabled)
New time-conversion helper added for human-readable locktime display
Test coverage added for the new helper
Evidence from the diff
The patch extends the wallet managers (Bitcoin and Liquid) to pass tx_version, locktime, and per-input sequence into the transaction metadata structure. The GUI’s TransactionScreen then renders these values with explanatory labels: sequence numbers are classified as locktime-disabled, RBF enabled/disabled, relative locktime, or non-standard; locktime is shown as block height, timestamp, or flagged when all inputs have locktime disabled. A new conv_time() helper converts Unix timestamps to UTC date/time, handling MicroPython epoch differences and simulator timezone/DST quirks. Tests are added for the helper and the existing range-proof comparison in signing tests is relaxed to avoid large memory allocations.
Changed components
src/apps/wallets/manager.pysrc/apps/wallets/liquid/manager.pysrc/gui/screens/transaction.pysrc/helpers.pytest/native_support.pytest/tests/test_helpers.pytest/tests/test_sign.pyInspect captured patch +207 / −10
diff --git a/src/apps/wallets/liquid/manager.py b/src/apps/wallets/liquid/manager.py
index 0fd9d64..6bbdf1a 100644
--- a/src/apps/wallets/liquid/manager.py
+++ b/src/apps/wallets/liquid/manager.py
@@ -276,6 +276,8 @@ class LWalletManager(WalletManager):
"outputs": [{} for i in range(psbtv.num_outputs)],
"issuance": False, "reissuance": False,
"signed_inputs": signed_inputs,
+ "tx_version": psbtv.tx_version,
+ "locktime": psbtv.locktime,
}
fingerprint = self.keystore.fingerprint
@@ -377,6 +379,7 @@ class LWalletManager(WalletManager):
"label": wallet.name if wallet else "Unknown wallet",
"value": value,
"asset": self.asset_label(asset),
+ "sequence": inp.sequence,
})
if wallet and wallet.is_watchonly:
metainp["label"] += " (watch-only)"
diff --git a/src/apps/wallets/manager.py b/src/apps/wallets/manager.py
index d924be3..e80ce81 100644
--- a/src/apps/wallets/manager.py
+++ b/src/apps/wallets/manager.py
@@ -641,6 +641,8 @@ class WalletManager(BaseApp):
"outputs": [{} for i in range(psbtv.num_outputs)],
"default_asset": "BTC" if self.network == "main" else "tBTC",
"signed_inputs": signed_inputs,
+ "tx_version": psbtv.tx_version,
+ "locktime": psbtv.locktime,
}
fingerprint = self.keystore.fingerprint
@@ -699,6 +701,7 @@ class WalletManager(BaseApp):
metainp.update({
"label": wallet.name if wallet else "Unknown wallet",
"value": value,
+ "sequence": inp.sequence,
})
if wallet and wallet.is_watchonly:
metainp["label"] += " (watch-only)"
diff --git a/src/gui/screens/transaction.py b/src/gui/screens/transaction.py
index 3a630c0..a93ed07 100644
--- a/src/gui/screens/transaction.py
+++ b/src/gui/screens/transaction.py
@@ -1,9 +1,10 @@
import lvgl as lv
+import platform
+from helpers import conv_time
from .prompt import Prompt
from ..common import add_label, format_addr
from ..decorators import on_release
-
class TransactionScreen(Prompt):
def __init__(self, title, meta):
self.default_asset = meta.get("default_asset", "BTC")
@@ -54,9 +55,15 @@ class TransactionScreen(Prompt):
style_warning.text.color = lv.color_hex(0xFF9A00)
style_warning.text.font = lv.font_roboto_22
+ style_gray = lv.style_t()
+ lv.style_copy(style_gray, self.message.get_style(0))
+ style_gray.text.color = lv.color_hex(0x999999)
+ style_gray.text.font = lv.font_roboto_22
+
self.style = style
self.style_secondary = style_secondary
self.style_warning = style_warning
+ self.style_gray = style_gray
num_change_outputs = 0
for out in meta["outputs"]:
@@ -66,13 +73,14 @@ class TransactionScreen(Prompt):
continue
obj = self.show_output(out, obj)
- if meta.get("fee"):
+ fee = meta.get("fee")
+ if fee:
if send_amount > 0:
- fee_percent = meta["fee"] * 100 / send_amount
- fee_txt = "%d satoshi (%.2f%%)" % (meta["fee"], fee_percent)
+ fee_percent = fee * 100 / send_amount
+ fee_txt = "%d satoshi (%.2f%%)" % (fee, fee_percent)
# back to wallet
else:
- fee_txt = "%d satoshi" % (meta["fee"])
+ fee_txt = "%d satoshi" % (fee,)
fee = add_label("Fee: " + fee_txt, scr=self.page)
fee.set_style(0, style)
fee.align(obj, lv.ALIGN.OUT_BOTTOM_MID, 0, 30)
@@ -85,7 +93,8 @@ class TransactionScreen(Prompt):
self.warning.set_style(0, style_warning)
self.warning.align(obj, lv.ALIGN.OUT_BOTTOM_MID, 0, 30)
- lbl = add_label("%d INPUTS" % len(meta["inputs"]), scr=self.page2)
+ meta_inputs_len = len(meta["inputs"])
+ lbl = add_label("%d %s" % (meta_inputs_len, "INPUT" if meta_inputs_len == 1 else "INPUTS"), scr=self.page2)
lbl.align(self.page2, lv.ALIGN.IN_TOP_MID, 0, 30)
obj = lbl
for i, inp in enumerate(meta["inputs"]):
@@ -101,6 +110,33 @@ class TransactionScreen(Prompt):
lbl.align(idxlbl, lv.ALIGN.IN_TOP_LEFT, 0, 0)
lbl.set_x(60)
+ # https://learnmeabitcoin.com/technical/transaction/input/sequence
+ sequence = inp.get("sequence")
+ if sequence is not None:
+ seqlbl = lv.label(self.page2)
+ is_relative_locktime = False
+ if sequence == 0xFFFFFFFF:
+ seq_text = "Locktime disabled"
+ elif sequence == 0xFFFFFFFE:
+ seq_text = 'RBF "disabled"'
+ elif sequence == 0xFFFFFFFD:
+ seq_text = "RBF enabled"
+ elif meta["tx_version"] >= 2 and sequence <= 0xEFFFFFFF and (sequence | 0x0040FFFF == 0x0040FFFF):
+ seq_text = "Relative Locktime"
+ is_relative_locktime = True
+ else:
+ seq_text = "Non-standard"
+ seqlbl.set_text("Seq: 0x%08X (%s)" % (sequence, seq_text))
+ seqlbl.set_style(0, style_gray)
+ seqlbl.align(lbl, lv.ALIGN.OUT_BOTTOM_LEFT, 0, 5)
+ seqlbl.set_x(60)
+ lbl = seqlbl
+ if is_relative_locktime:
+ rltlbl = lv.label(self.page2)
+ rltlbl.set_style(0, style_gray)
+ rltlbl.set_text(self.relative_locktime_to_text(sequence))
+ rltlbl.align(lbl, lv.ALIGN.OUT_BOTTOM_LEFT, 15, 5)
+ lbl = rltlbl
if inp.get("sighash", ""):
shlbl = lv.label(self.page2)
shlbl.set_long_mode(lv.label.LONG.BREAK)
@@ -112,7 +148,8 @@ class TransactionScreen(Prompt):
lbl = shlbl
obj = lbl
- lbl = add_label("%d OUTPUTS" % len(meta["outputs"]), scr=self.page2)
+ meta_outputs_len = len(meta["outputs"])
+ lbl = add_label("%d %s" % (len(meta["outputs"]), "OUTPUT" if meta_outputs_len == 1 else "OUTPUTS"), scr=self.page2)
lbl.align(self.page2, lv.ALIGN.IN_TOP_MID, 0, 0)
lbl.set_y(obj.get_y() + obj.get_height() + 30)
for i, out in enumerate(meta["outputs"]):
@@ -149,11 +186,47 @@ class TransactionScreen(Prompt):
warning.set_x(60)
lbl = warning
- if meta.get("fee"):
+ if fee:
idxlbl = lv.label(self.page2)
idxlbl.set_text("Fee: " + fee_txt)
idxlbl.align(lbl, lv.ALIGN.OUT_BOTTOM_MID, 0, 30)
idxlbl.set_x(30)
+ lbl = idxlbl
+
+ verlbl = lv.label(self.page2)
+ verlbl.set_style(0, style_gray)
+ verlbl.set_text("Transaction Version: %d" % meta["tx_version"])
+ # If the fee label is present, we want to be close to it. Otherwise, we want a larger margin.
+ verlbl.align(lbl, lv.ALIGN.OUT_BOTTOM_LEFT, 0, 5 if fee else 30)
+ verlbl.set_x(30)
+ locktime = meta["locktime"]
+ if all(inp["sequence"] == 0xFFFFFFFF for inp in meta["inputs"]):
+ # Locktime disabled. See: https://learnmeabitcoin.com/technical/transaction/input/sequence
+ ltlbl = lv.label(self.page2)
+ ltlbl.set_style(0, style_gray)
+ ltlbl.set_text("Locktime: %d" % locktime)
+ ltlbl.align(verlbl, lv.ALIGN.OUT_BOTTOM_LEFT, 0, 5)
+ ltdiabledlbl = lv.label(self.page2)
+ ltdiabledlbl.set_style(0, style_warning)
+ ltdiabledlbl.set_text("All inputs have locktime disabled!" if meta["inputs"] else "No inputs!")
+ ltdiabledlbl.align(ltlbl, lv.ALIGN.OUT_BOTTOM_LEFT, 15, 5)
+ elif locktime <= 499999999:
+ # Block height. See: https://learnmeabitcoin.com/technical/transaction/locktime
+ ltlbl = lv.label(self.page2)
+ ltlbl.set_style(0, style_gray)
+ ltlbl.set_text("Locktime: %d (Block Height)" % locktime)
+ ltlbl.align(verlbl, lv.ALIGN.OUT_BOTTOM_LEFT, 0, 5)
+ else:
+ # Block timestamp. See: https://learnmeabitcoin.com/technical/transaction/locktime
+ ltlbl = lv.label(self.page2)
+ ltlbl.set_style(0, style_gray)
+ ltlbl.set_text("Locktime: %d (Timestamp)" % locktime)
+ ltlbl.align(verlbl, lv.ALIGN.OUT_BOTTOM_LEFT, 0, 5)
+ mp_time = conv_time(locktime)
+ ltdatelbl = lv.label(self.page2)
+ ltdatelbl.set_style(0, style_gray)
+ ltdatelbl.set_text("%04d-%02d-%02d %02d:%02d:%02d UTC" % mp_time[:6])
+ ltdatelbl.align(ltlbl, lv.ALIGN.OUT_BOTTOM_LEFT, 15, 5)
self.toggle_details()
@@ -194,4 +267,30 @@ class TransactionScreen(Prompt):
warning.set_style(0, self.style_warning)
warning.align(obj, lv.ALIGN.OUT_BOTTOM_MID, 0, 10)
obj = warning
- return obj
\ No newline at end of file
+ return obj
+
+ def relative_locktime_to_text(self, sequence):
+ if sequence & 0x00400000:
+ # In units of 512 seconds
+ rlt_total = (sequence & 0xFFFF) * 512
+ rlt_parts = [
+ (amount, unit)
+ for amount, unit in [
+ (rlt_total // 86400, "day"),
+ ((rlt_total // 3600) % 24, "hour"),
+ ((rlt_total // 60) % 60, "minute"),
+ (rlt_total % 60, "second"),
+ ]
+ if amount > 0
+ ]
+ # Break into 2 lines if there are too many parts
+ rlt_lines_parts = [rlt_parts] if len(rlt_parts) < 4 else [rlt_parts[:3], rlt_parts[3:]]
+ return ",\n".join(
+ ", ".join(
+ "%d %s%s" % (amount, unit, "" if amount == 1 else "s")
+ for amount, unit in rlt_line_parts
+ )
+ for rlt_line_parts in rlt_lines_parts
+ )
+ else:
+ return "%d %s" % (sequence, "block" if sequence == 1 else "blocks")
diff --git a/src/helpers.py b/src/helpers.py
index e7da9f1..3f4f134 100644
--- a/src/helpers.py
+++ b/src/helpers.py
@@ -7,6 +7,7 @@ import rng
import platform
from binascii import b2a_base64, a2b_base64
from embit.liquid.networks import NETWORKS
+import utime
AES_BLOCK = 16
IV_SIZE = 16
@@ -175,3 +176,42 @@ def read_write(fin, fout, chunk_size=32):
total += fout.write(chunk)
return total
+# The conv_time() function converts a timestamp measured in seconds from 1970-01-01 00:00:00 UTC to
+# humand-readable parameters (year, month, day, hour, minute, second, second, weekday, yeardate) in UTC.
+# "Time Epoch: Unix port uses standard for POSIX systems epoch of 1970-01-01 00:00:00 UTC.
+# However, embedded ports use epoch of 2000-01-01 00:00:00 UTC."
+# (Source: https://micropython.readthedocs.io/en/latest/library/utime.html)
+# Simulator MicroPython case:
+# utime.mktime() does not exist. utime.localtime() gives result with both timezone offset and DST offset.
+# To remove the timezone offset, we calculate the offset of EPOCH ZERO timestamp (1970-01-01 00:00:00 UTC),
+# substract it from the timestamp, and recall utime.localtime() again.
+# To remove the DST offset, we usually only need to check the dst_offset in the result (in hours), subtract it
+# and call utime.localtime() again. However, in one case (DST start on March) when the local clocks jump forward,
+# there is an hour when we don't need to apply the shift - and we correct it manually.
+# Embedded MicroPython case:
+# utime.gmtime() and utime.localtime() are the same function (in some implementation only utime.localtime()
+# exists, but does not add timezone/dst offsets). However, timestamp zero is not 1970-01-01 00:00:00 UTC,
+# but 2000-01-01 00:00:00 UTC. Therefore we reduce the fixed difference from the timestamp before execution.
+if platform.simulator:
+ def conv_time(t):
+ y, m, d, hh, mm, ss, *_ = utime.localtime(0)
+ tz_offset = hh * 3600 + mm * 60 + ss
+ if (y, m) == (1970, 1):
+ tz_offset += 86400 * (d - 1)
+ elif (y, m) == (1969, 12):
+ tz_offset -= 86400 * (32 - d)
+ else:
+ raise ValueError("Failed to calculate simulator timezone offset")
+ adjusted_t = t - tz_offset
+ dst_offset = utime.localtime(adjusted_t)[8]
+ adjusted_t -= dst_offset * 3600
+ new_localtime = utime.localtime(adjusted_t)
+ if new_localtime[8] == 0 and dst_offset == 1 and new_localtime[3] == 1:
+ return (new_localtime[:3] + (2,) + new_localtime[4:])[:8]
+ return new_localtime[:8]
+ conv_time(0) # Check that the function is working
+else:
+ _UNIX_EPOCH_OFFSET = 946684800
+ _conv_time = utime.gmtime if hasattr(utime, "gmtime") else utime.localtime
+ def conv_time(t):
+ return _conv_time(t - _UNIX_EPOCH_OFFSET)
diff --git a/test/native_support.py b/test/native_support.py
index 4ebb626..c9f00fd 100644
--- a/test/native_support.py
+++ b/test/native_support.py
@@ -171,6 +171,21 @@ def setup_native_stubs():
secp256k1.ecdsa_verify = lambda sig, msg, pub: True
secp256k1.ecdsa_sign_recoverable = lambda msghash, secret: bytes(65)
+ utime = _ensure_module("utime")
+ if not hasattr(utime, "time"):
+ import time as _time
+ utime.time = _time.time
+ utime.sleep = _time.sleep
+ utime.sleep_ms = lambda ms: _time.sleep(ms / 1000.0)
+ utime.sleep_us = lambda us: _time.sleep(us / 1000000.0)
+ utime.ticks_ms = lambda: int(_time.time() * 1000)
+ utime.ticks_us = lambda: int(_time.time() * 1000000)
+ utime.ticks_add = lambda ticks, delta: ticks + delta
+ utime.ticks_diff = lambda ticks1, ticks2: ticks1 - ticks2
+ utime.mktime = _time.mktime
+ utime.localtime = _time.localtime
+ utime.gmtime = _time.gmtime
+
from app import BaseApp
if not hasattr(BaseApp, "_native_original_get_prefix"):
diff --git a/test/tests/__init__.py b/test/tests/__init__.py
index 0efb9cb..e64d9f2 100644
--- a/test/tests/__init__.py
+++ b/test/tests/__init__.py
@@ -3,3 +3,4 @@ from .test_wallets import *
from .test_sign import *
from .test_revault import *
from .test_compatibility import *
+from .test_helpers import *
diff --git a/test/tests/test_helpers.py b/test/tests/test_helpers.py
new file mode 100644
index 0000000..faf712b
--- /dev/null
+++ b/test/tests/test_helpers.py
@@ -0,0 +1,31 @@
+from unittest import TestCase
+from helpers import conv_time
+
+class HelpersTest(TestCase):
+ def test_conv_time(self):
+ """Test conv_time function: used for converting nLocktime to human-readable timestamp"""
+ self.assertEqual(conv_time(0), (1970, 1, 1, 0, 0, 0, 3, 1))
+ # Test day before USA DST start on March 7th 2026
+ for hour in range(24):
+ self.assertEqual(conv_time(1772841600 + hour * 3600), (2026, 3, 7, hour, 0, 0, 5, 66))
+ # Test during USA DST start on March 8th 2026
+ for hour in range(24):
+ self.assertEqual(conv_time(1772928000 + hour * 3600), (2026, 3, 8, hour, 0, 0, 6, 67))
+ # Test day after USA DST start on March 9th 2026
+ for hour in range(24):
+ self.assertEqual(conv_time(1773014400 + hour * 3600), (2026, 3, 9, hour, 0, 0, 0, 68))
+ # Test during Europe DST start on March 29th 2026
+ for hour in range(24):
+ self.assertEqual(conv_time(1774742400 + hour * 3600), (2026, 3, 29, hour, 0, 0, 6, 88))
+ # Test during Europe DST end on October 25th 2026
+ for hour in range(24):
+ self.assertEqual(conv_time(1792886400 + hour * 3600), (2026, 10, 25, hour, 0, 0, 6, 298))
+ # Test day before USA DST end on October 31st 2026
+ for hour in range(24):
+ self.assertEqual(conv_time(1793404800 + hour * 3600), (2026, 10, 31, hour, 0, 0, 5, 304))
+ # Test day during USA DST end on November 1st 2026
+ for hour in range(24):
+ self.assertEqual(conv_time(1793491200 + hour * 3600), (2026, 11, 1, hour, 0, 0, 6, 305))
+ # Test day after USA DST end on November 2nd 2026
+ for hour in range(24):
+ self.assertEqual(conv_time(1793577600 + hour * 3600), (2026, 11, 2, hour, 0, 0, 0, 306))
diff --git a/test/tests/test_sign.py b/test/tests/test_sign.py
index 56b9ba2..8fa1405 100644
--- a/test/tests/test_sign.py
+++ b/test/tests/test_sign.py
@@ -130,7 +130,12 @@ class SignTest(TestCase):
for inp1, inp2 in zip(psbt.inputs, psbt2.inputs):
self.assertEqual(inp1, inp2)
for out1, out2 in zip(psbt.outputs, psbt2.outputs):
- self.assertEqual(out1.range_proof, out2.range_proof)
+ # Compare only length and first/last bytes to avoid large memory allocation
+ self.assertEqual(len(out1.range_proof) if out1.range_proof else 0,
+ len(out2.range_proof) if out2.range_proof else 0)
+ if out1.range_proof:
+ self.assertEqual(out1.range_proof[:100], out2.range_proof[:100])
+ self.assertEqual(out1.range_proof[-100:], out2.range_proof[-100:])
self.assertEqual(out1.asset_commitment, out2.asset_commitment)
self.assertEqual(out1.value_commitment, out2.value_commitment)
self.assertEqual(out1.asset_blinding_factor, out2.asset_blinding_factor)
Why this scored 19/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.