Skip to content

Commit 3d1bb8e

Browse files
oren-z0k9ertk9ert
authored
Show Transaction Version, Locktime and inputs' sequences when signing transactions! (#321)
Co-authored-by: k9ert <117085+k9ert@users.noreply.github.com> Co-authored-by: k9ert <kim@swanbitcoin.com>
1 parent 97ab4d4 commit 3d1bb8e

8 files changed

Lines changed: 207 additions & 10 deletions

File tree

src/apps/wallets/liquid/manager.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,8 @@ def preprocess_psbt(self, stream, fout):
276276
"outputs": [{} for i in range(psbtv.num_outputs)],
277277
"issuance": False, "reissuance": False,
278278
"signed_inputs": signed_inputs,
279+
"tx_version": psbtv.tx_version,
280+
"locktime": psbtv.locktime,
279281
}
280282

281283
fingerprint = self.keystore.fingerprint
@@ -377,6 +379,7 @@ def preprocess_psbt(self, stream, fout):
377379
"label": wallet.name if wallet else "Unknown wallet",
378380
"value": value,
379381
"asset": self.asset_label(asset),
382+
"sequence": inp.sequence,
380383
})
381384
if wallet and wallet.is_watchonly:
382385
metainp["label"] += " (watch-only)"

src/apps/wallets/manager.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -641,6 +641,8 @@ def preprocess_psbt(self, stream, fout):
641641
"outputs": [{} for i in range(psbtv.num_outputs)],
642642
"default_asset": "BTC" if self.network == "main" else "tBTC",
643643
"signed_inputs": signed_inputs,
644+
"tx_version": psbtv.tx_version,
645+
"locktime": psbtv.locktime,
644646
}
645647

646648
fingerprint = self.keystore.fingerprint
@@ -699,6 +701,7 @@ def preprocess_psbt(self, stream, fout):
699701
metainp.update({
700702
"label": wallet.name if wallet else "Unknown wallet",
701703
"value": value,
704+
"sequence": inp.sequence,
702705
})
703706
if wallet and wallet.is_watchonly:
704707
metainp["label"] += " (watch-only)"

src/gui/screens/transaction.py

Lines changed: 108 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import lvgl as lv
2+
import platform
3+
from helpers import conv_time
24
from .prompt import Prompt
35
from ..common import add_label, format_addr
46
from ..decorators import on_release
57

6-
78
class TransactionScreen(Prompt):
89
def __init__(self, title, meta):
910
self.default_asset = meta.get("default_asset", "BTC")
@@ -54,9 +55,15 @@ def __init__(self, title, meta):
5455
style_warning.text.color = lv.color_hex(0xFF9A00)
5556
style_warning.text.font = lv.font_roboto_22
5657

58+
style_gray = lv.style_t()
59+
lv.style_copy(style_gray, self.message.get_style(0))
60+
style_gray.text.color = lv.color_hex(0x999999)
61+
style_gray.text.font = lv.font_roboto_22
62+
5763
self.style = style
5864
self.style_secondary = style_secondary
5965
self.style_warning = style_warning
66+
self.style_gray = style_gray
6067

6168
num_change_outputs = 0
6269
for out in meta["outputs"]:
@@ -66,13 +73,14 @@ def __init__(self, title, meta):
6673
continue
6774
obj = self.show_output(out, obj)
6875

69-
if meta.get("fee"):
76+
fee = meta.get("fee")
77+
if fee:
7078
if send_amount > 0:
71-
fee_percent = meta["fee"] * 100 / send_amount
72-
fee_txt = "%d satoshi (%.2f%%)" % (meta["fee"], fee_percent)
79+
fee_percent = fee * 100 / send_amount
80+
fee_txt = "%d satoshi (%.2f%%)" % (fee, fee_percent)
7381
# back to wallet
7482
else:
75-
fee_txt = "%d satoshi" % (meta["fee"])
83+
fee_txt = "%d satoshi" % (fee,)
7684
fee = add_label("Fee: " + fee_txt, scr=self.page)
7785
fee.set_style(0, style)
7886
fee.align(obj, lv.ALIGN.OUT_BOTTOM_MID, 0, 30)
@@ -85,7 +93,8 @@ def __init__(self, title, meta):
8593
self.warning.set_style(0, style_warning)
8694
self.warning.align(obj, lv.ALIGN.OUT_BOTTOM_MID, 0, 30)
8795

88-
lbl = add_label("%d INPUTS" % len(meta["inputs"]), scr=self.page2)
96+
meta_inputs_len = len(meta["inputs"])
97+
lbl = add_label("%d %s" % (meta_inputs_len, "INPUT" if meta_inputs_len == 1 else "INPUTS"), scr=self.page2)
8998
lbl.align(self.page2, lv.ALIGN.IN_TOP_MID, 0, 30)
9099
obj = lbl
91100
for i, inp in enumerate(meta["inputs"]):
@@ -101,6 +110,33 @@ def __init__(self, title, meta):
101110
lbl.align(idxlbl, lv.ALIGN.IN_TOP_LEFT, 0, 0)
102111
lbl.set_x(60)
103112

113+
# https://learnmeabitcoin.com/technical/transaction/input/sequence
114+
sequence = inp.get("sequence")
115+
if sequence is not None:
116+
seqlbl = lv.label(self.page2)
117+
is_relative_locktime = False
118+
if sequence == 0xFFFFFFFF:
119+
seq_text = "Locktime disabled"
120+
elif sequence == 0xFFFFFFFE:
121+
seq_text = 'RBF "disabled"'
122+
elif sequence == 0xFFFFFFFD:
123+
seq_text = "RBF enabled"
124+
elif meta["tx_version"] >= 2 and sequence <= 0xEFFFFFFF and (sequence | 0x0040FFFF == 0x0040FFFF):
125+
seq_text = "Relative Locktime"
126+
is_relative_locktime = True
127+
else:
128+
seq_text = "Non-standard"
129+
seqlbl.set_text("Seq: 0x%08X (%s)" % (sequence, seq_text))
130+
seqlbl.set_style(0, style_gray)
131+
seqlbl.align(lbl, lv.ALIGN.OUT_BOTTOM_LEFT, 0, 5)
132+
seqlbl.set_x(60)
133+
lbl = seqlbl
134+
if is_relative_locktime:
135+
rltlbl = lv.label(self.page2)
136+
rltlbl.set_style(0, style_gray)
137+
rltlbl.set_text(self.relative_locktime_to_text(sequence))
138+
rltlbl.align(lbl, lv.ALIGN.OUT_BOTTOM_LEFT, 15, 5)
139+
lbl = rltlbl
104140
if inp.get("sighash", ""):
105141
shlbl = lv.label(self.page2)
106142
shlbl.set_long_mode(lv.label.LONG.BREAK)
@@ -112,7 +148,8 @@ def __init__(self, title, meta):
112148
lbl = shlbl
113149
obj = lbl
114150

115-
lbl = add_label("%d OUTPUTS" % len(meta["outputs"]), scr=self.page2)
151+
meta_outputs_len = len(meta["outputs"])
152+
lbl = add_label("%d %s" % (len(meta["outputs"]), "OUTPUT" if meta_outputs_len == 1 else "OUTPUTS"), scr=self.page2)
116153
lbl.align(self.page2, lv.ALIGN.IN_TOP_MID, 0, 0)
117154
lbl.set_y(obj.get_y() + obj.get_height() + 30)
118155
for i, out in enumerate(meta["outputs"]):
@@ -149,11 +186,47 @@ def __init__(self, title, meta):
149186
warning.set_x(60)
150187
lbl = warning
151188

152-
if meta.get("fee"):
189+
if fee:
153190
idxlbl = lv.label(self.page2)
154191
idxlbl.set_text("Fee: " + fee_txt)
155192
idxlbl.align(lbl, lv.ALIGN.OUT_BOTTOM_MID, 0, 30)
156193
idxlbl.set_x(30)
194+
lbl = idxlbl
195+
196+
verlbl = lv.label(self.page2)
197+
verlbl.set_style(0, style_gray)
198+
verlbl.set_text("Transaction Version: %d" % meta["tx_version"])
199+
# If the fee label is present, we want to be close to it. Otherwise, we want a larger margin.
200+
verlbl.align(lbl, lv.ALIGN.OUT_BOTTOM_LEFT, 0, 5 if fee else 30)
201+
verlbl.set_x(30)
202+
locktime = meta["locktime"]
203+
if all(inp["sequence"] == 0xFFFFFFFF for inp in meta["inputs"]):
204+
# Locktime disabled. See: https://learnmeabitcoin.com/technical/transaction/input/sequence
205+
ltlbl = lv.label(self.page2)
206+
ltlbl.set_style(0, style_gray)
207+
ltlbl.set_text("Locktime: %d" % locktime)
208+
ltlbl.align(verlbl, lv.ALIGN.OUT_BOTTOM_LEFT, 0, 5)
209+
ltdiabledlbl = lv.label(self.page2)
210+
ltdiabledlbl.set_style(0, style_warning)
211+
ltdiabledlbl.set_text("All inputs have locktime disabled!" if meta["inputs"] else "No inputs!")
212+
ltdiabledlbl.align(ltlbl, lv.ALIGN.OUT_BOTTOM_LEFT, 15, 5)
213+
elif locktime <= 499999999:
214+
# Block height. See: https://learnmeabitcoin.com/technical/transaction/locktime
215+
ltlbl = lv.label(self.page2)
216+
ltlbl.set_style(0, style_gray)
217+
ltlbl.set_text("Locktime: %d (Block Height)" % locktime)
218+
ltlbl.align(verlbl, lv.ALIGN.OUT_BOTTOM_LEFT, 0, 5)
219+
else:
220+
# Block timestamp. See: https://learnmeabitcoin.com/technical/transaction/locktime
221+
ltlbl = lv.label(self.page2)
222+
ltlbl.set_style(0, style_gray)
223+
ltlbl.set_text("Locktime: %d (Timestamp)" % locktime)
224+
ltlbl.align(verlbl, lv.ALIGN.OUT_BOTTOM_LEFT, 0, 5)
225+
mp_time = conv_time(locktime)
226+
ltdatelbl = lv.label(self.page2)
227+
ltdatelbl.set_style(0, style_gray)
228+
ltdatelbl.set_text("%04d-%02d-%02d %02d:%02d:%02d UTC" % mp_time[:6])
229+
ltdatelbl.align(ltlbl, lv.ALIGN.OUT_BOTTOM_LEFT, 15, 5)
157230

158231
self.toggle_details()
159232

@@ -194,4 +267,30 @@ def show_output(self, out, obj):
194267
warning.set_style(0, self.style_warning)
195268
warning.align(obj, lv.ALIGN.OUT_BOTTOM_MID, 0, 10)
196269
obj = warning
197-
return obj
270+
return obj
271+
272+
def relative_locktime_to_text(self, sequence):
273+
if sequence & 0x00400000:
274+
# In units of 512 seconds
275+
rlt_total = (sequence & 0xFFFF) * 512
276+
rlt_parts = [
277+
(amount, unit)
278+
for amount, unit in [
279+
(rlt_total // 86400, "day"),
280+
((rlt_total // 3600) % 24, "hour"),
281+
((rlt_total // 60) % 60, "minute"),
282+
(rlt_total % 60, "second"),
283+
]
284+
if amount > 0
285+
]
286+
# Break into 2 lines if there are too many parts
287+
rlt_lines_parts = [rlt_parts] if len(rlt_parts) < 4 else [rlt_parts[:3], rlt_parts[3:]]
288+
return ",\n".join(
289+
", ".join(
290+
"%d %s%s" % (amount, unit, "" if amount == 1 else "s")
291+
for amount, unit in rlt_line_parts
292+
)
293+
for rlt_line_parts in rlt_lines_parts
294+
)
295+
else:
296+
return "%d %s" % (sequence, "block" if sequence == 1 else "blocks")

src/helpers.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import platform
88
from binascii import b2a_base64, a2b_base64
99
from embit.liquid.networks import NETWORKS
10+
import utime
1011

1112
AES_BLOCK = 16
1213
IV_SIZE = 16
@@ -175,3 +176,42 @@ def read_write(fin, fout, chunk_size=32):
175176
total += fout.write(chunk)
176177
return total
177178

179+
# The conv_time() function converts a timestamp measured in seconds from 1970-01-01 00:00:00 UTC to
180+
# humand-readable parameters (year, month, day, hour, minute, second, second, weekday, yeardate) in UTC.
181+
# "Time Epoch: Unix port uses standard for POSIX systems epoch of 1970-01-01 00:00:00 UTC.
182+
# However, embedded ports use epoch of 2000-01-01 00:00:00 UTC."
183+
# (Source: https://micropython.readthedocs.io/en/latest/library/utime.html)
184+
# Simulator MicroPython case:
185+
# utime.mktime() does not exist. utime.localtime() gives result with both timezone offset and DST offset.
186+
# To remove the timezone offset, we calculate the offset of EPOCH ZERO timestamp (1970-01-01 00:00:00 UTC),
187+
# substract it from the timestamp, and recall utime.localtime() again.
188+
# To remove the DST offset, we usually only need to check the dst_offset in the result (in hours), subtract it
189+
# and call utime.localtime() again. However, in one case (DST start on March) when the local clocks jump forward,
190+
# there is an hour when we don't need to apply the shift - and we correct it manually.
191+
# Embedded MicroPython case:
192+
# utime.gmtime() and utime.localtime() are the same function (in some implementation only utime.localtime()
193+
# exists, but does not add timezone/dst offsets). However, timestamp zero is not 1970-01-01 00:00:00 UTC,
194+
# but 2000-01-01 00:00:00 UTC. Therefore we reduce the fixed difference from the timestamp before execution.
195+
if platform.simulator:
196+
def conv_time(t):
197+
y, m, d, hh, mm, ss, *_ = utime.localtime(0)
198+
tz_offset = hh * 3600 + mm * 60 + ss
199+
if (y, m) == (1970, 1):
200+
tz_offset += 86400 * (d - 1)
201+
elif (y, m) == (1969, 12):
202+
tz_offset -= 86400 * (32 - d)
203+
else:
204+
raise ValueError("Failed to calculate simulator timezone offset")
205+
adjusted_t = t - tz_offset
206+
dst_offset = utime.localtime(adjusted_t)[8]
207+
adjusted_t -= dst_offset * 3600
208+
new_localtime = utime.localtime(adjusted_t)
209+
if new_localtime[8] == 0 and dst_offset == 1 and new_localtime[3] == 1:
210+
return (new_localtime[:3] + (2,) + new_localtime[4:])[:8]
211+
return new_localtime[:8]
212+
conv_time(0) # Check that the function is working
213+
else:
214+
_UNIX_EPOCH_OFFSET = 946684800
215+
_conv_time = utime.gmtime if hasattr(utime, "gmtime") else utime.localtime
216+
def conv_time(t):
217+
return _conv_time(t - _UNIX_EPOCH_OFFSET)

test/native_support.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,21 @@ def decrypt(self, data):
171171
secp256k1.ecdsa_verify = lambda sig, msg, pub: True
172172
secp256k1.ecdsa_sign_recoverable = lambda msghash, secret: bytes(65)
173173

174+
utime = _ensure_module("utime")
175+
if not hasattr(utime, "time"):
176+
import time as _time
177+
utime.time = _time.time
178+
utime.sleep = _time.sleep
179+
utime.sleep_ms = lambda ms: _time.sleep(ms / 1000.0)
180+
utime.sleep_us = lambda us: _time.sleep(us / 1000000.0)
181+
utime.ticks_ms = lambda: int(_time.time() * 1000)
182+
utime.ticks_us = lambda: int(_time.time() * 1000000)
183+
utime.ticks_add = lambda ticks, delta: ticks + delta
184+
utime.ticks_diff = lambda ticks1, ticks2: ticks1 - ticks2
185+
utime.mktime = _time.mktime
186+
utime.localtime = _time.localtime
187+
utime.gmtime = _time.gmtime
188+
174189
from app import BaseApp
175190

176191
if not hasattr(BaseApp, "_native_original_get_prefix"):

test/tests/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@
33
from .test_sign import *
44
from .test_revault import *
55
from .test_compatibility import *
6+
from .test_helpers import *

test/tests/test_helpers.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
from unittest import TestCase
2+
from helpers import conv_time
3+
4+
class HelpersTest(TestCase):
5+
def test_conv_time(self):
6+
"""Test conv_time function: used for converting nLocktime to human-readable timestamp"""
7+
self.assertEqual(conv_time(0), (1970, 1, 1, 0, 0, 0, 3, 1))
8+
# Test day before USA DST start on March 7th 2026
9+
for hour in range(24):
10+
self.assertEqual(conv_time(1772841600 + hour * 3600), (2026, 3, 7, hour, 0, 0, 5, 66))
11+
# Test during USA DST start on March 8th 2026
12+
for hour in range(24):
13+
self.assertEqual(conv_time(1772928000 + hour * 3600), (2026, 3, 8, hour, 0, 0, 6, 67))
14+
# Test day after USA DST start on March 9th 2026
15+
for hour in range(24):
16+
self.assertEqual(conv_time(1773014400 + hour * 3600), (2026, 3, 9, hour, 0, 0, 0, 68))
17+
# Test during Europe DST start on March 29th 2026
18+
for hour in range(24):
19+
self.assertEqual(conv_time(1774742400 + hour * 3600), (2026, 3, 29, hour, 0, 0, 6, 88))
20+
# Test during Europe DST end on October 25th 2026
21+
for hour in range(24):
22+
self.assertEqual(conv_time(1792886400 + hour * 3600), (2026, 10, 25, hour, 0, 0, 6, 298))
23+
# Test day before USA DST end on October 31st 2026
24+
for hour in range(24):
25+
self.assertEqual(conv_time(1793404800 + hour * 3600), (2026, 10, 31, hour, 0, 0, 5, 304))
26+
# Test day during USA DST end on November 1st 2026
27+
for hour in range(24):
28+
self.assertEqual(conv_time(1793491200 + hour * 3600), (2026, 11, 1, hour, 0, 0, 6, 305))
29+
# Test day after USA DST end on November 2nd 2026
30+
for hour in range(24):
31+
self.assertEqual(conv_time(1793577600 + hour * 3600), (2026, 11, 2, hour, 0, 0, 0, 306))

test/tests/test_sign.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,12 @@ def test_pset(self):
130130
for inp1, inp2 in zip(psbt.inputs, psbt2.inputs):
131131
self.assertEqual(inp1, inp2)
132132
for out1, out2 in zip(psbt.outputs, psbt2.outputs):
133-
self.assertEqual(out1.range_proof, out2.range_proof)
133+
# Compare only length and first/last bytes to avoid large memory allocation
134+
self.assertEqual(len(out1.range_proof) if out1.range_proof else 0,
135+
len(out2.range_proof) if out2.range_proof else 0)
136+
if out1.range_proof:
137+
self.assertEqual(out1.range_proof[:100], out2.range_proof[:100])
138+
self.assertEqual(out1.range_proof[-100:], out2.range_proof[-100:])
134139
self.assertEqual(out1.asset_commitment, out2.asset_commitment)
135140
self.assertEqual(out1.value_commitment, out2.value_commitment)
136141
self.assertEqual(out1.asset_blinding_factor, out2.asset_blinding_factor)

0 commit comments

Comments
 (0)