feat: migrate UR encoding to uUR MicroPython C module
What changed, and why it matters
This commit swaps out the pure-Python QR code encoding/decoding libraries used by the Krux hardware wallet for a new C module called uUR. The stated goal is faster scanning and lower memory use. The change touches code that handles Bitcoin wallet descriptors, seed phrases, and signed transactions (PSBTs). There is no direct evidence in the commit of a security vulnerability, but any rewrite of code that parses cryptographic data deserves careful review because a bug could in theory cause the wallet to misread a transaction or seed. The commit does not describe itself as a security fix.
Review the uUR C module implementation in the updated MaixPy firmware for correct CBOR parsing, bounds checking, and memory safety. Run regression tests that exercise multi-part UR QR scanning, PSBT signing round-trips, and wallet descriptor import/export. Verify that the shim accurately mirrors the C module's behavior, especially for uppercase Bytewords and single-part UR progress reporting.
Security signals we found
Large-scale dependency swap in cryptographic data path (UR/PSBT/wallet descriptors/BIP39 seeds)
New native C module is not visible in this diff; behavior must be trusted to MaixPy firmware image
Shim layer changes string case handling (uppercase Bytewords) and decoder attribute semantics
Type checks changed from `.upper()` comparisons to lowercase exact string matches
Memory management additions (`del raw`, `gc.collect()`) suggest concern about RAM use with large PSBTs
Evidence from the diff
Krux migrates from urtypes/foundation-ur-py to a built-in MicroPython C module uUR. Source files now import uUR.UR, uUR.URDecoder, uUR.UREncoder, and uUR.Types instead of the previous Python packages. A simulator shim (simulator/kruxsim/mocks/uUR.py) re-exports the old Python packages under the new API so tests and the desktop simulator continue to work. The firmware’s MaixPy submodule is bumped to a newer develop commit that presumably contains the C module. The diff also adjusts QR capacity calculations to treat UR as alphanumeric and updates progress-count logic to use new decoder attributes (expected_part_count, processed_parts_count).
Changed components
src/krux/qr.pysrc/krux/psbt.pysrc/krux/wallet.pysrc/krux/pages/datum_tool.pysrc/krux/pages/mnemonic_loader.pyfirmware/MaixPy (submodule)simulator/kruxsim/mocks/uUR.pyInspect captured patch +167 / −76
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e4080e3..f31ecef 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,4 +1,7 @@
-# Changelog XX.XX.X
+# Changelog 26.05.0 - May 2025
+
+### Migrate UR encoding to uUR MicroPython C module
+Switch from the pure-Python urtypes and foundation-ur-py packages to the new uUR C module, allowing faster UR QR codes decoding with a smaller RAM footprint.
### Other Bug Fixes and Improvements
- Improve scan TinySeed and other binary visibility by drawing punches only
diff --git a/Dockerfile b/Dockerfile
index b361be1..ac8d9c4 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -100,12 +100,6 @@ WORKDIR /src
# copy vendor to WORKDIR (src)
COPY ./vendor vendor
-# clean vendor/urtypes
-RUN find vendor/urtypes -type d -name '__pycache__' -exec rm -rv {} + -depth
-
-# clean vendor/foundation-ur-py
-RUN find vendor/foundation-ur-py -type d -name '__pycache__' -exec rm -rv {} + -depth
-
# install vendor/embit
RUN /kruxenv/bin/pip install vendor/embit
# clean vendor/embit
@@ -125,8 +119,6 @@ COPY ./firmware firmware
RUN find firmware -type d -name '__pycache__' -exec rm -rv {} + -depth
# copy all vendors to DEVICE_BUILTIN
-RUN cp -r vendor/urtypes/src/urtypes "${DEVICE_BUILTIN}"
-RUN cp -r vendor/foundation-ur-py/src/ur "${DEVICE_BUILTIN}"
RUN cp -r vendor/embit/src/embit "${DEVICE_BUILTIN}"
# copy Krux (src) to WORKDIR (src)
diff --git a/simulator/kruxsim/mocks/uUR.py b/simulator/kruxsim/mocks/uUR.py
new file mode 100644
index 0000000..9770964
--- /dev/null
+++ b/simulator/kruxsim/mocks/uUR.py
@@ -0,0 +1,101 @@
+# The MIT License (MIT)
+
+# Copyright (c) 2021-2025 Krux contributors
+
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+
+"""CPython shim that mirrors the firmware's uUR C module by re-exporting
+the pure-Python `urtypes` and `foundation-ur-py` packages under the same
+public surface (UR, URDecoder, UREncoder, Types).
+"""
+
+import sys
+
+from ur.ur import UR
+from ur.ur_decoder import URDecoder as _URDecoder
+from ur.ur_encoder import UREncoder as _UREncoder
+
+from urtypes.bytes import Bytes as _Bytes
+from urtypes.crypto.account import Account as _Account
+from urtypes.crypto.bip39 import BIP39 as _BIP39
+from urtypes.crypto.output import Output as _Output
+from urtypes.crypto.psbt import PSBT as _PSBT
+
+
+class UREncoder(_UREncoder):
+ """uUR's encoder emits uppercase Bytewords; the Python encoder emits
+ lowercase. Match firmware behaviour by uppercasing here."""
+
+ def next_part(self):
+ return super().next_part().upper()
+
+
+class URDecoder(_URDecoder):
+ """uUR exposes expected_part_count and processed_parts_count as plain
+ int attributes (zero for single-part URs). Mirror that here so the same
+ qr.py logic works against both decoders."""
+
+ @property
+ def expected_part_count(self):
+ if self.fountain_decoder.expected_part_indexes is None:
+ return 0
+ return len(self.fountain_decoder.expected_part_indexes)
+
+ @property
+ def processed_parts_count(self):
+ return self.fountain_decoder.processed_parts_count
+
+
+class Types:
+ CRYPTO_PSBT_TYPE = "crypto-psbt"
+ CRYPTO_BIP39_TYPE = "crypto-bip39"
+ CRYPTO_OUTPUT_TYPE = "crypto-output"
+ CRYPTO_ACCOUNT_TYPE = "crypto-account"
+
+ @staticmethod
+ def psbt_from_cbor(cbor):
+ return _PSBT.from_cbor(cbor).data
+
+ @staticmethod
+ def psbt_to_cbor(data):
+ return _PSBT(data).to_cbor()
+
+ @staticmethod
+ def bytes_from_cbor(cbor):
+ return _Bytes.from_cbor(cbor).data
+
+ @staticmethod
+ def bytes_to_cbor(data):
+ return _Bytes(data).to_cbor()
+
+ @staticmethod
+ def bip39_words_from_cbor(cbor):
+ return _BIP39.from_cbor(cbor).words
+
+ @staticmethod
+ def output_from_cbor(cbor):
+ return _Output.from_cbor(cbor).descriptor()
+
+ @staticmethod
+ def output_from_cbor_account(cbor):
+ return _Account.from_cbor(cbor).output_descriptors[0].descriptor()
+
+
+if "uUR" not in sys.modules:
+ sys.modules["uUR"] = sys.modules[__name__]
diff --git a/simulator/simulator.py b/simulator/simulator.py
index c0c0932..16e5838 100644
--- a/simulator/simulator.py
+++ b/simulator/simulator.py
@@ -108,6 +108,7 @@ if args.printer:
from kruxsim.mocks import secp256k1
from kruxsim.mocks import qrcode
+from kruxsim.mocks import uUR # noqa: F401
from kruxsim.mocks import sensor
from kruxsim.mocks import shannon
from kruxsim.mocks import ft6x36
diff --git a/src/krux/pages/datum_tool.py b/src/krux/pages/datum_tool.py
index 503d4d9..484c606 100644
--- a/src/krux/pages/datum_tool.py
+++ b/src/krux/pages/datum_tool.py
@@ -78,23 +78,19 @@ SLOW_ENCODING_MAX_SIZE = 2**14 # base43,base58,bech32 not offered above this si
def urobj_to_data(ur_obj):
"""returns flatened data from a UR object. belongs in qr or qr_capture???"""
- from urtypes.crypto.bip39 import BIP39
- from urtypes.crypto.account import Account
- from urtypes.crypto.output import Output
- from urtypes.crypto.psbt import PSBT
- from urtypes.bytes import Bytes
-
- if ur_obj.type.upper() == "CRYPTO-BIP39":
- data = BIP39.from_cbor(ur_obj.cbor).words
+ from uUR import Types
+
+ if ur_obj.type == "crypto-bip39":
+ data = Types.bip39_words_from_cbor(ur_obj.cbor)
data = " ".join(data)
- elif ur_obj.type.upper() == "CRYPTO-ACCOUNT":
- data = Account.from_cbor(ur_obj.cbor).output_descriptors[0].descriptor()
- elif ur_obj.type.upper() == "CRYPTO-OUTPUT":
- data = Output.from_cbor(ur_obj.cbor).descriptor()
- elif ur_obj.type.upper() == "CRYPTO-PSBT":
- data = PSBT.from_cbor(ur_obj.cbor).data
- elif ur_obj.type.upper() == "BYTES":
- data = Bytes.from_cbor(ur_obj.cbor).data
+ elif ur_obj.type == "crypto-account":
+ data = Types.output_from_cbor_account(ur_obj.cbor)
+ elif ur_obj.type == "crypto-output":
+ data = Types.output_from_cbor(ur_obj.cbor)
+ elif ur_obj.type == "crypto-psbt":
+ data = Types.psbt_from_cbor(ur_obj.cbor)
+ elif ur_obj.type == "bytes":
+ data = Types.bytes_from_cbor(ur_obj.cbor)
else:
data = None
return data
@@ -422,9 +418,6 @@ class DatumTool(Page):
"""Reusable handler for viewing a QR code"""
from ..qr import QR_CAPACITY_BYTE, QR_CAPACITY_ALPHANUMERIC, QR_CAPACITY_NUMERIC
from ..bbqr import encode_bbqr
- from urtypes.bytes import Bytes
- from urtypes.crypto.psbt import PSBT
- from ur.ur import UR
# Helper function to check if character is alphanumeric
def is_alnum(c):
@@ -514,11 +507,13 @@ class DatumTool(Page):
encoded = encode_bbqr(encoded, file_type=menu_opts[idx][1][1])
elif qr_fmt == FORMAT_UR:
+ from uUR import UR, Types
+
ur_type = menu_opts[idx][1][1]
if ur_type == "bytes":
- encoded = UR(ur_type, Bytes(encoded).to_cbor())
+ encoded = UR(ur_type, Types.bytes_to_cbor(encoded))
elif ur_type == "crypto-psbt":
- encoded = UR(ur_type, PSBT(encoded).to_cbor())
+ encoded = UR(ur_type, Types.psbt_to_cbor(encoded))
# TODO: other urtypes
try:
diff --git a/src/krux/pages/mnemonic_loader.py b/src/krux/pages/mnemonic_loader.py
index 366b5af..8ecbceb 100644
--- a/src/krux/pages/mnemonic_loader.py
+++ b/src/krux/pages/mnemonic_loader.py
@@ -326,9 +326,9 @@ class MnemonicLoader(Page):
words = []
if qr_format == FORMAT_UR:
- from urtypes.crypto.bip39 import BIP39
+ from uUR import Types
- words = BIP39.from_cbor(data.cbor).words
+ words = Types.bip39_words_from_cbor(data.cbor)
else:
try:
data_str = data.decode() if not isinstance(data, str) else data
diff --git a/src/krux/psbt.py b/src/krux/psbt.py
index f9be6c9..1be4b41 100644
--- a/src/krux/psbt.py
+++ b/src/krux/psbt.py
@@ -21,8 +21,7 @@
# THE SOFTWARE.
import gc
from embit.psbt import PSBT, CompressMode
-from ur.ur import UR
-from urtypes.crypto.psbt import PSBT as URTYPE_PSBT, CRYPTO_PSBT
+from uUR import UR, Types
from .baseconv import base_decode
from .krux_settings import t
from .settings import THIN_SPACE, ELLIPSIS
@@ -92,8 +91,11 @@ class PSBTSigner:
self.base_encoding = 64 # In case it is exported as QR code
elif isinstance(psbt_data, UR):
try:
- self.psbt = PSBT.parse(URTYPE_PSBT.from_cbor(psbt_data.cbor).data)
- self.ur_type = CRYPTO_PSBT
+ raw = Types.psbt_from_cbor(psbt_data.cbor)
+ self.ur_type = Types.CRYPTO_PSBT_TYPE
+ self.psbt = PSBT.parse(raw)
+ del raw
+ gc.collect()
# self.base_encoding = 64
except:
raise ValueError("invalid PSBT")
@@ -552,14 +554,11 @@ class PSBTSigner:
psbt_data = base_encode(psbt_data, self.base_encoding)
- if self.ur_type == CRYPTO_PSBT:
- return (
- UR(
- CRYPTO_PSBT.type,
- URTYPE_PSBT(psbt_data).to_cbor(),
- ),
- self.qr_format,
- )
+ if self.ur_type == Types.CRYPTO_PSBT_TYPE:
+ cbor = Types.psbt_to_cbor(psbt_data)
+ del psbt_data
+ gc.collect()
+ return UR(Types.CRYPTO_PSBT_TYPE, cbor), self.qr_format
return psbt_data, self.qr_format
def xpubs(self):
diff --git a/src/krux/qr.py b/src/krux/qr.py
index d59cb5c..6345b37 100644
--- a/src/krux/qr.py
+++ b/src/krux/qr.py
@@ -139,28 +139,28 @@ class QRPartParser:
def parsed_count(self):
"""Returns the number of parsed parts so far"""
if self.format == FORMAT_UR:
- # Single-part URs have no expected part indexes
- if self.decoder.fountain_decoder.expected_part_indexes is None:
+ # Single-part URs report expected_part_count == 0
+ if self.decoder.expected_part_count == 0:
return 1 if self.decoder.result is not None else 0
completion_pct = self.decoder.estimated_percent_complete()
- return math.ceil(completion_pct * self.total_count() / 2) + len(
- self.decoder.fountain_decoder.received_part_indexes
+ return math.ceil(completion_pct * self.total_count() / 2) + min(
+ self.decoder.processed_parts_count, self.decoder.expected_part_count
)
return len(self.parts)
def processed_parts_count(self):
"""Returns quantity of processed QR code parts"""
if self.format == FORMAT_UR:
- return self.decoder.fountain_decoder.processed_parts_count
+ return self.decoder.processed_parts_count
return len(self.parts)
def total_count(self):
"""Returns the total number of parts there should be"""
if self.format == FORMAT_UR:
- # Single-part URs have no expected part indexes
- if self.decoder.fountain_decoder.expected_part_indexes is None:
+ # Single-part URs report expected_part_count == 0
+ if self.decoder.expected_part_count == 0:
return 1
- return self.decoder.expected_part_count() * 2
+ return self.decoder.expected_part_count * 2
return self.total
def parse(self, data):
@@ -178,7 +178,7 @@ class QRPartParser:
return index - 1
elif self.format == FORMAT_UR:
if not self.decoder:
- from ur.ur_decoder import URDecoder
+ from uUR import URDecoder
self.decoder = URDecoder()
data = data.decode() if isinstance(data, bytes) else data
@@ -256,11 +256,11 @@ def to_qr_codes(data, max_width, qr_format):
code = qrcode.encode(part)
yield (code, num_parts)
elif qr_format == FORMAT_UR:
- from ur.ur_encoder import UREncoder
+ from uUR import UREncoder
encoder = UREncoder(data, part_size, 0)
while True:
- part = encoder.next_part().upper()
+ part = encoder.next_part()
code = qrcode.encode(part)
yield (code, encoder.fountain_encoder.seq_len())
elif qr_format == FORMAT_BBQR:
@@ -317,7 +317,7 @@ def find_min_num_parts(data, max_width, qr_format):
"""Finds the minimum number of QR parts necessary to encode the data in
the specified format within the max_width constraint
"""
- encoding = "alphanumeric" if qr_format == FORMAT_BBQR else "byte"
+ encoding = "alphanumeric" if qr_format in (FORMAT_BBQR, FORMAT_UR) else "byte"
qr_capacity = max_qr_bytes(max_width, encoding)
if qr_format == FORMAT_PMOFN:
data_length = len(data)
diff --git a/src/krux/wallet.py b/src/krux/wallet.py
index 732c323..33e7925 100644
--- a/src/krux/wallet.py
+++ b/src/krux/wallet.py
@@ -423,28 +423,18 @@ def parse_wallet(wallet_data):
# Check if wallet_data is a UR object without loading the UR module
if wallet_data.__class__.__name__ == "UR":
- # Try to parse as a Crypto-Output type
- try:
- from urtypes.crypto.output import Output
-
- output = Output.from_cbor(wallet_data.cbor)
- return Descriptor.from_string(output.descriptor()), None
- except:
- pass
+ from uUR import Types
- # Try to parse as a Crypto-Account type
- try:
- from urtypes.crypto.account import Account
+ if wallet_data.type == "crypto-output":
+ output = Types.output_from_cbor(wallet_data.cbor)
+ return Descriptor.from_string(output), None
- account = Account.from_cbor(wallet_data.cbor).output_descriptors[0]
- return Descriptor.from_string(account.descriptor()), None
- except:
- pass
+ if wallet_data.type == "crypto-account":
+ output = Types.output_from_cbor_account(wallet_data.cbor)
+ return Descriptor.from_string(output), None
# Treat the UR as a generic UR bytes object and extract the data for further processing
- from urtypes.bytes import Bytes
-
- wallet_data = Bytes.from_cbor(wallet_data.cbor).data
+ wallet_data = Types.bytes_from_cbor(wallet_data.cbor)
# Process as a string
wallet_data = (
diff --git a/tests/conftest.py b/tests/conftest.py
index 0a1426d..03e2c66 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,3 +1,11 @@
+import os
+import sys
+
+# Make the simulator's mock packages importable so tests can reuse the uUR shim.
+_SIMULATOR_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "simulator")
+if _SIMULATOR_DIR not in sys.path:
+ sys.path.insert(0, _SIMULATOR_DIR)
+
from Crypto.Cipher import AES
import pytest
from .shared_mocks import (
@@ -37,7 +45,9 @@ def mp_modules(mocker, monkeypatch):
import time
import sys
import hashlib
+ from kruxsim.mocks import uUR as uur_shim
+ monkeypatch.setitem(sys.modules, "uUR", uur_shim)
monkeypatch.setitem(
sys.modules,
"qrcode",
diff --git a/tests/test_qr.py b/tests/test_qr.py
index f9d5381..ccf524c 100644
--- a/tests/test_qr.py
+++ b/tests/test_qr.py
@@ -172,7 +172,7 @@ def test_to_qr_codes(mocker, m5stickv, tdata):
# Test 320 pixels wide display
(FORMAT_NONE, tdata.TEST_DATA_B58, 320, 1),
(FORMAT_PMOFN, tdata.TEST_DATA_B58, 320, 3),
- (FORMAT_UR, tdata.TEST_DATA_UR, 320, 6),
+ (FORMAT_UR, tdata.TEST_DATA_UR, 320, 3),
(FORMAT_BBQR, BBQR_CODE_DATA, 320, 2),
]
for case in cases:
Why this scored 32/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.