diff --git a/CHANGELOG.md b/CHANGELOG.md index 67224a796..beeac059a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ # Changelog 26.05.0 - May 2025 ### Stackbit 1248 Vertical Layout -Added vertical layout option for Stackbit 1248 backup display, allowing users to choose between Standard (horizontal) and Vertical (transposed) grid orientations. +- Added vertical layout option for Stackbit 1248 backup display/mnemonic input, allowing users to choose between Standard (horizontal) and Vertical (transposed) grid orientations. ### 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. diff --git a/docs/getting-started/usage/loading-a-mnemonic.en.md b/docs/getting-started/usage/loading-a-mnemonic.en.md index cef1e0af6..d3628fd6d 100644 --- a/docs/getting-started/usage/loading-a-mnemonic.en.md +++ b/docs/getting-started/usage/loading-a-mnemonic.en.md @@ -80,9 +80,16 @@ Enter the BIP39 mnemonic word's numbers (1-2048) in binary format, toggling nece Enter the BIP39 mnemonic word's numbers (1-2048) using the Stackbit 1248 metal plate backup method, where each of the four digits of the word's number is a sum of the numbers marked (punched) 1, 2, 4, or 8. For example, to enter the word "oyster", number 1268, you must punch (1)(2)(2,4)(8). +
+
+
+
+Vertical layout transposes the grid, with rows = weights (1,2,4,8) and columns = digits.
+
### From Storage
+
@@ -91,7 +98,9 @@ You can also retrieve [encrypted mnemonics previously stored](./navigating-the-m
## Confirm Wallet Setup
+
### Confirm Mnemonic Words
+
@@ -106,6 +115,7 @@ If you see an asterisk (`*`) in the header, it means this is a [double mnemonic]
### (Optional) Edit Mnemonic
+
@@ -114,6 +124,7 @@ If you make a mistake while loading a mnemonic, you can easily edit it. Simply t
### Confirm Wallet Attributes
+
@@ -123,41 +134,48 @@ After confirming your mnemonic, a screen with an **information box at the top**
#### The Attributes:
-##### Fingerprint
-* :material-fingerprint: ` 73c5da0a `:
-The [BIP32](https://github.com/bitcoin/bips/blob/master/bip-0032.mediawiki) master wallet's fingerprint helps you make sure you entered the correct mnemonic and passphrase (optional) and will load the expected wallet. The fingerprint is the best checksum you can have, it's good to note it down.
+##### Fingerprint
-##### Network
-* ` Mainnet `:
-Check if you are loading a `Testnet` or `Mainnet` wallet.
+- :material-fingerprint: `73c5da0a`:
+ The [BIP32](https://github.com/bitcoin/bips/blob/master/bip-0032.mediawiki) master wallet's fingerprint helps you make sure you entered the correct mnemonic and passphrase (optional) and will load the expected wallet. The fingerprint is the best checksum you can have, it's good to note it down.
+
+##### Network
+
+- `Mainnet`:
+ Check if you are loading a `Testnet` or `Mainnet` wallet.
##### Policy Type
-* Check the wallet's policy type: `Single-sig`, `Multisig`, `Miniscript`, or `TR Miniscript` (Taproot).
+
+- Check the wallet's policy type: `Single-sig`, `Multisig`, `Miniscript`, or `TR Miniscript` (Taproot).
##### Derivation Path
-* :material-arrow-right-bottom: ` m/84h/0h/0h `:
-The derivation path is a sequence of numbers, or "nodes", that define the script type, network, and account index of your wallet.
- * **Script Type** `84h`: The first number defines the script type. The default is `84h`, corresponding to a Native Segwit wallet. Other values include:
- * `44h` for Legacy
- * `49h` for Nested Segwit
- * `86h` for Taproot
- * `48h` for Multisig
- * **Network** `0h`: The second number defines the network:
- * `0h` for Mainnet
- * `1h` for Testnet
- * **Account Index** `0h`: The third number is the account index, with `0h` being the default.
- * **Additional**: For multisig wallets, a fourth node with the value `2h` is added to the derivation path.
-
- Default Miniscript derivation path is the same as for multisig: ` m/48'/0h/0h/2h `, but they can be fully customized
+
+- :material-arrow-right-bottom: `m/84h/0h/0h`:
+ The derivation path is a sequence of numbers, or "nodes", that define the script type, network, and account index of your wallet.
+ _ **Script Type** `84h`: The first number defines the script type. The default is `84h`, corresponding to a Native Segwit wallet. Other values include:
+ _ `44h` for Legacy
+ _ `49h` for Nested Segwit
+ _ `86h` for Taproot
+ _ `48h` for Multisig
+ _ **Network** `0h`: The second number defines the network:
+ _ `0h` for Mainnet
+ _ `1h` for Testnet
+ _ **Account Index** `0h`: The third number is the account index, with `0h` being the default.
+ _ **Additional**: For multisig wallets, a fourth node with the value `2h` is added to the derivation path.
+
+ Default Miniscript derivation path is the same as for multisig: ` m/48'/0h/0h/2h `, but they can be fully customized
##### Passphrase
-* ` No Passphrase `:
-Informs if the wallet has a passphrase. Adding or changing the passphrase results in a completely different wallet and fingerprint.
+
+- `No Passphrase`:
+ Informs if the wallet has a passphrase. Adding or changing the passphrase results in a completely different wallet and fingerprint.
### Customize Wallet
+
It is possible to change any of the **wallet's attributes** (it will be possible to change them later too, after loading). To load it faster next time, some default wallet attributes can be set in [settings](../settings.md), they are: `Network`, `Policy Type` and `Script Type`.
#### Passphrase
+
@@ -170,6 +188,7 @@ For scanning, you can generate an offline passphrase QR code using the [Datum to
#### Customize
+
diff --git a/docs/img/maixpy_amigo/load-mnemonic-via-stackbit-vertical-filled-300.png b/docs/img/maixpy_amigo/load-mnemonic-via-stackbit-vertical-filled-300.png
new file mode 100644
index 000000000..2b8066a24
Binary files /dev/null and b/docs/img/maixpy_amigo/load-mnemonic-via-stackbit-vertical-filled-300.png differ
diff --git a/docs/img/maixpy_amigo/load-mnemonic-via-stackbit-vertical-initial-300.png b/docs/img/maixpy_amigo/load-mnemonic-via-stackbit-vertical-initial-300.png
new file mode 100644
index 000000000..fba9b6493
Binary files /dev/null and b/docs/img/maixpy_amigo/load-mnemonic-via-stackbit-vertical-initial-300.png differ
diff --git a/docs/img/maixpy_m5stickv/load-mnemonic-via-stackbit-vertical-filled-250.png b/docs/img/maixpy_m5stickv/load-mnemonic-via-stackbit-vertical-filled-250.png
new file mode 100644
index 000000000..b27afa45b
Binary files /dev/null and b/docs/img/maixpy_m5stickv/load-mnemonic-via-stackbit-vertical-filled-250.png differ
diff --git a/docs/img/maixpy_m5stickv/load-mnemonic-via-stackbit-vertical-initial-250.png b/docs/img/maixpy_m5stickv/load-mnemonic-via-stackbit-vertical-initial-250.png
new file mode 100644
index 000000000..50fa64d80
Binary files /dev/null and b/docs/img/maixpy_m5stickv/load-mnemonic-via-stackbit-vertical-initial-250.png differ
diff --git a/simulator/sequences/load-mnemonic-via-stackbit.txt b/simulator/sequences/load-mnemonic-via-stackbit.txt
index 2110f65d5..c4ec9f7ee 100644
--- a/simulator/sequences/load-mnemonic-via-stackbit.txt
+++ b/simulator/sequences/load-mnemonic-via-stackbit.txt
@@ -24,3 +24,27 @@ x5 press BUTTON_B
press BUTTON_A
#screenshot load-mnemonic-via-stackbit-filled.png
+
+# Go back to menu
+press BUTTON_B
+x2 press BUTTON_A
+
+# Navigate to Vertical
+press BUTTON_B
+press BUTTON_A
+
+screenshot load-mnemonic-via-stackbit-vertical-initial.png
+
+# Fill word 1
+press BUTTON_B
+press BUTTON_A
+x2 press BUTTON_B
+press BUTTON_A
+x3 press BUTTON_B
+press BUTTON_A
+x5 press BUTTON_B
+press BUTTON_A
+x2 press BUTTON_B
+press BUTTON_A
+
+screenshot load-mnemonic-via-stackbit-vertical-filled.png
diff --git a/src/krux/display.py b/src/krux/display.py
index fac23bb7f..3d3811c0e 100644
--- a/src/krux/display.py
+++ b/src/krux/display.py
@@ -382,6 +382,12 @@ def fill_rectangle(self, x, y, width, height, color, radius=0):
x -= width
lcd.fill_rectangle(x, y, width, height, color, radius)
+ def draw_circle(self, x, y, radius, color=theme.fg_color):
+ """Draws a filled circle to the screen"""
+ if self.flipped_x_coordinates:
+ x = self.width() - x - 1
+ lcd.draw_circle(x, y, radius, 0, color)
+
def draw_line(self, x_0, y_0, x_1, y_1, color=theme.fg_color):
"""Draws a line to the screen"""
if self.flipped_x_coordinates:
diff --git a/src/krux/pages/mnemonic_loader.py b/src/krux/pages/mnemonic_loader.py
index 8ecbceb4c..30348ea49 100644
--- a/src/krux/pages/mnemonic_loader.py
+++ b/src/krux/pages/mnemonic_loader.py
@@ -257,10 +257,24 @@ def possible_letters(prefix):
def load_key_from_1248(self):
"""Menu handler to load key from Stackbit 1248 sheet metal storage method"""
+ submenu = Menu(
+ self.ctx,
+ [
+ (t("Standard"), self._load_key_from_1248),
+ (t("Vertical"), lambda: self._load_key_from_1248(vertical=True)),
+ ],
+ )
+ index, status = submenu.run_loop()
+ if index == submenu.back_index:
+ return MENU_CONTINUE
+ return status
+
+ def _load_key_from_1248(self, vertical=False):
+ """Load key from Stackbit 1248 layout (horizontal, or vertical when flag set)"""
from .stack_1248 import Stackbit
stackbit = Stackbit(self.ctx)
- words = stackbit.enter_1248()
+ words = stackbit.enter_1248_vertical() if vertical else stackbit.enter_1248()
del stackbit
if words is not None:
return self._load_key_from_words(words)
diff --git a/src/krux/pages/stack_1248.py b/src/krux/pages/stack_1248.py
index a86b17b63..6b62a1d0a 100644
--- a/src/krux/pages/stack_1248.py
+++ b/src/krux/pages/stack_1248.py
@@ -41,9 +41,16 @@
BIT_WEIGHTS = (1, 2, 4, 8)
BIT_LABELS = ("1", "2", "4", "8")
+VERT_N_DIGITS = 4
+VERT_N_BITS = 4
+VERT_N_CELLS = VERT_N_DIGITS * VERT_N_BITS
+VERT_ESC_INDEX = VERT_N_CELLS
+VERT_GO_INDEX = VERT_N_CELLS + 2
+VERT_INVALID_CELLS = tuple(VERT_N_DIGITS * i for i in (2, 3))
+
class Stackbit(Page):
- """Class for handling Stackbit 1248 fomat"""
+ """Class for handling Stackbit 1248 format"""
def __init__(self, ctx):
super().__init__(ctx, None)
@@ -714,3 +721,281 @@ def enter_1248(self):
if len(words) in (12, 24):
return words
return None
+
+ def _layout_vertical(self, pad=DEFAULT_PADDING):
+ """Return geometry constants for the vertical input grid.
+
+ All positions are derived from two base measures so the layout
+ scales cleanly across Amigo (320×480), Dock (240×320) and
+ M5StickV (135×240).
+ """
+ label_w = FONT_WIDTH + pad # breathing room between border and labels
+ gap = FONT_HEIGHT // 2
+
+ # Cell size: largest square that fits both the available width and the
+ # available height, so the grid uses the full screen on every device.
+ available_w = self.ctx.display.width() - 2 * pad - label_w
+ cell_w = available_w // VERT_N_DIGITS
+ max_cell_h = (self.ctx.display.height() - 4 * FONT_HEIGHT - pad - gap) // (
+ VERT_N_BITS + 1
+ )
+ cell_size = min(cell_w, max(max_cell_h, FONT_HEIGHT))
+
+ grid_w = VERT_N_DIGITS * cell_size
+ grid_h = VERT_N_BITS * cell_size
+
+ # Center the full block (labels + grid) horizontally on the display
+ total_w = label_w + grid_w
+ x_label = max(pad, (self.ctx.display.width() - total_w) // 2)
+ x_grid = x_label + label_w
+
+ total_h = 2 * FONT_HEIGHT + grid_h + gap + FONT_HEIGHT + cell_size
+ y_start = max(pad, (self.ctx.display.height() - total_h) // 2)
+ y_grid = y_start + 2 * FONT_HEIGHT
+ y_preview = y_grid + grid_h + gap
+ y_menu = y_preview + FONT_HEIGHT
+
+ return (
+ x_grid,
+ y_grid,
+ y_start,
+ y_menu,
+ cell_size,
+ cell_size,
+ grid_w,
+ x_label,
+ y_preview,
+ )
+
+ def _map_keys_array_vertical(self, x_grid, y_grid, cell_w, cell_h, y_menu):
+ """Set touch regions for the 4 × 4 grid plus the Esc / Go menu row."""
+ if not kboard.has_touchscreen:
+ return
+ self.ctx.input.touch.clear_regions()
+ x = x_grid
+ for _ in range(VERT_N_DIGITS + 1):
+ self.ctx.input.touch.x_regions.append(x)
+ x += cell_w
+ y = y_grid
+ for _ in range(VERT_N_BITS):
+ self.ctx.input.touch.y_regions.append(y)
+ y += cell_h
+ self.ctx.input.touch.y_regions.append(y_menu)
+ self.ctx.input.touch.y_regions.append(y_menu + cell_h)
+
+ def _draw_grid_vertical(self, x_grid, y_grid, cell_w, cell_h, grid_w):
+ """Draw the 4 × 4 cell borders, shading invalid cells in column 0."""
+ grid_h = VERT_N_BITS * cell_h
+ # Outer border
+ self.ctx.display.draw_line(
+ x_grid, y_grid, x_grid + grid_w, y_grid, theme.frame_color
+ )
+ self.ctx.display.draw_line(
+ x_grid, y_grid + grid_h, x_grid + grid_w, y_grid + grid_h, theme.frame_color
+ )
+ self.ctx.display.draw_line(
+ x_grid, y_grid, x_grid, y_grid + grid_h, theme.frame_color
+ )
+ self.ctx.display.draw_line(
+ x_grid + grid_w, y_grid, x_grid + grid_w, y_grid + grid_h, theme.frame_color
+ )
+ # Internal column dividers
+ for col in range(1, VERT_N_DIGITS):
+ x = x_grid + col * cell_w
+ self.ctx.display.draw_line(x, y_grid, x, y_grid + grid_h, theme.frame_color)
+ # Internal row dividers
+ for row in range(1, VERT_N_BITS):
+ y = y_grid + row * cell_h
+ self.ctx.display.draw_line(x_grid, y, x_grid + grid_w, y, theme.frame_color)
+ # Shade cells that are structurally unreachable (first digit, rows 2-3)
+ for row in range(2, VERT_N_BITS):
+ self.ctx.display.fill_rectangle(
+ x_grid, y_grid + row * cell_h, cell_w, cell_h, theme.disabled_color
+ )
+
+ def _draw_punched_vertical(self, digits, x_grid, y_grid, cell_w, cell_h):
+ """Draw filled circles for every bit that is set in the current digits."""
+ radius = max(min(cell_w, cell_h) // 3, 1)
+ for col, digit in enumerate(digits):
+ cx = x_grid + col * cell_w + cell_w // 2
+ for row, bit_val in enumerate(BIT_WEIGHTS):
+ if digit & bit_val:
+ cy = y_grid + row * cell_h + cell_h // 2
+ self.ctx.display.draw_circle(cx, cy, radius, theme.highlight_color)
+
+ def _draw_menu_vertical(self, x_grid, y_menu, grid_w, cell_h):
+ """Draw the Esc and Go buttons below the grid."""
+ label_y = y_menu + (cell_h - FONT_HEIGHT) // 2
+ half_w = grid_w // 2
+ esc_label = t("Esc")
+ go_label = t("Go")
+ esc_x = x_grid + (half_w - len(esc_label) * FONT_WIDTH) // 2
+ go_x = x_grid + half_w + (half_w - len(go_label) * FONT_WIDTH) // 2
+ self.ctx.display.draw_string(esc_x, label_y, esc_label, theme.no_esc_color)
+ self.ctx.display.draw_string(go_x, label_y, go_label, theme.go_color)
+ if kboard.has_touchscreen:
+ mid = x_grid + half_w
+ self.ctx.display.draw_line(
+ x_grid, y_menu, x_grid + grid_w, y_menu, theme.frame_color
+ )
+ self.ctx.display.draw_line(
+ x_grid,
+ y_menu + cell_h,
+ x_grid + grid_w,
+ y_menu + cell_h,
+ theme.frame_color,
+ )
+ self.ctx.display.draw_line(
+ x_grid, y_menu, x_grid, y_menu + cell_h, theme.frame_color
+ )
+ self.ctx.display.draw_line(
+ mid, y_menu, mid, y_menu + cell_h, theme.frame_color
+ )
+ self.ctx.display.draw_line(
+ x_grid + grid_w,
+ y_menu,
+ x_grid + grid_w,
+ y_menu + cell_h,
+ theme.frame_color,
+ )
+
+ def _draw_index_vertical(
+ self, index, x_grid, y_grid, cell_w, cell_h, y_menu, grid_w
+ ):
+ """Outline the currently focused cell or menu button."""
+ if index >= VERT_GO_INDEX:
+ x = x_grid + grid_w // 2
+ self.ctx.display.outline(x, y_menu, grid_w // 2, cell_h, theme.go_color)
+ elif index >= VERT_ESC_INDEX:
+ self.ctx.display.outline(
+ x_grid, y_menu, grid_w // 2, cell_h, theme.no_esc_color
+ )
+ else:
+ col = index % VERT_N_DIGITS
+ row = index // VERT_N_DIGITS
+ self.ctx.display.outline(
+ x_grid + col * cell_w,
+ y_grid + row * cell_h,
+ cell_w,
+ cell_h,
+ theme.fg_color,
+ )
+
+ def _toggle_bit_vertical(self, digits, index):
+ """Toggle the bit addressed by *index*, enforcing first-digit constraints."""
+ # The first BIP39 digit is 0-2, so bit weights 4 (row 2) and 8 (row 3)
+ # are invalid for column 0 and are silently ignored.
+ if index in VERT_INVALID_CELLS:
+ return digits
+ col = index % VERT_N_DIGITS
+ row = index // VERT_N_DIGITS
+ bit_val = BIT_WEIGHTS[row]
+ new_val = digits[col] ^ bit_val
+ if col == 0 and new_val > 2:
+ new_val = 0
+ elif col != 0 and new_val > 9:
+ new_val = 0
+ digits[col] = new_val
+ return digits
+
+ def _index_vertical(self, index, btn):
+ """Advance or rewind the focused cell index, skipping invalid cells."""
+ if btn in (BUTTON_PAGE, FAST_FORWARD):
+ index = 0 if index >= VERT_GO_INDEX else index + 1
+ if index in VERT_INVALID_CELLS:
+ index += 1
+ elif btn in (BUTTON_PAGE_PREV, FAST_BACKWARD):
+ index = VERT_GO_INDEX if index <= 0 else index - 1
+ if index in VERT_INVALID_CELLS:
+ index -= 1
+ return index
+
+ def enter_1248_vertical(self):
+ """UI to manually enter a seed from a vertical Stackbit 1248 card."""
+ x_grid, y_grid, y_start, y_menu, cell_w, cell_h, grid_w, x_label, y_preview = (
+ self._layout_vertical()
+ )
+ index = 0
+ digits = [0, 0, 0, 0]
+ word_index = 1
+ words = []
+ while word_index <= 24:
+ self._map_keys_array_vertical(x_grid, y_grid, cell_w, cell_h, y_menu)
+ self.ctx.display.draw_hcentered_text("Stackbit 1248", y_start)
+ self.ctx.display.fill_rectangle(
+ x_grid, y_start + FONT_HEIGHT, grid_w, FONT_HEIGHT, theme.disabled_color
+ )
+ self.ctx.display.draw_string(
+ x_grid + (grid_w - 2 * FONT_WIDTH) // 2,
+ y_start + FONT_HEIGHT,
+ "%02d" % word_index,
+ theme.fg_color,
+ theme.disabled_color,
+ )
+ for i, label in enumerate(BIT_LABELS):
+ self.ctx.display.draw_string(
+ x_label, y_grid + i * cell_h, label, theme.fg_color
+ )
+ self._draw_grid_vertical(x_grid, y_grid, cell_w, cell_h, grid_w)
+ self._draw_menu_vertical(x_grid, y_menu, grid_w, cell_h)
+ if self.ctx.input.buttons_active:
+ self._draw_index_vertical(
+ index, x_grid, y_grid, cell_w, cell_h, y_menu, grid_w
+ )
+ digits_str = "".join(str(d) for d in digits)
+ word = self.digits_to_word(digits)
+ if word is not None:
+ preview_str = digits_str + ": " + word
+ color = theme.fg_color
+ else:
+ preview_str = digits_str
+ color = theme.error_color
+ self.ctx.display.draw_hcentered_text(
+ preview_str, y_preview, color=color, highlight_prefix=":"
+ )
+ self._draw_punched_vertical(digits, x_grid, y_grid, cell_w, cell_h)
+ btn = self.ctx.input.wait_for_fastnav_button()
+ if btn == BUTTON_TOUCH:
+ btn = BUTTON_ENTER
+ index = self.ctx.input.touch.current_index()
+ if btn == BUTTON_ENTER:
+ if index >= VERT_GO_INDEX:
+ word = self.digits_to_word(digits)
+ if word is not None:
+ prompt_str = (
+ str(word_index)
+ + ".\n\n"
+ + "".join(str(d) for d in digits)
+ + ": "
+ + str(word)
+ + "\n\n"
+ )
+ digits = [0, 0, 0, 0]
+ index = 0
+ self.ctx.display.clear()
+ if self.prompt(
+ prompt_str,
+ self.ctx.display.height() // 2,
+ highlight_prefix=":",
+ ):
+ words.append(word)
+ else:
+ self.ctx.display.clear()
+ continue
+ if word_index == 12:
+ self.ctx.display.clear()
+ if self.prompt(t("Done?"), self.ctx.display.height() // 2):
+ break
+ word_index += 1
+ elif index >= VERT_ESC_INDEX:
+ self.ctx.display.clear()
+ if self.prompt(t("Are you sure?"), self.ctx.display.height() // 2):
+ break
+ else:
+ digits = self._toggle_bit_vertical(digits, index)
+ else:
+ index = self._index_vertical(index, btn)
+ self.ctx.display.clear()
+ if len(words) in (12, 24):
+ return words
+ return None
diff --git a/tests/pages/test_login.py b/tests/pages/test_login.py
index 2023fbab3..7782e9162 100644
--- a/tests/pages/test_login.py
+++ b/tests/pages/test_login.py
@@ -1396,7 +1396,8 @@ def test_load_12w_from_1248(m5stickv, mocker, mocker_printer):
from krux.input import BUTTON_ENTER, BUTTON_PAGE, BUTTON_PAGE_PREV
BTN_SEQUENCE = (
- (
+ [BUTTON_ENTER] # Select "Standard" from Standard/Vertical submenu
+ + (
[BUTTON_ENTER] # 1 press select first column num 1
+ [BUTTON_PAGE_PREV] # 1 press to change to "Go"
+ [BUTTON_ENTER] # 1 press to select Go
diff --git a/tests/pages/test_stackbit.py b/tests/pages/test_stackbit.py
index 7be313213..658a1eddc 100644
--- a/tests/pages/test_stackbit.py
+++ b/tests/pages/test_stackbit.py
@@ -72,11 +72,7 @@ def test_export_mnemonic_stackbit_standard_amigo(mocker, amigo, tdata):
def test_export_mnemonic_stackbit_vertical(mocker, amigo, tdata):
- """Grouped layout on Amigo: 2 words/group, 4 words/page, 6 pages for 24-word mnemonic.
-
- Amigo uses FONT_WIDTH=12, so 3 words/group would overflow the word name text.
- The layout auto-selects 2 words/group → 4 words/page → 6 pages.
- """
+ """Grouped layout on Amigo: 2 words/group, 4 words/page, 6 pages for 24-word mnemonic."""
from krux.pages.home_pages.mnemonic_backup import MnemonicsView
from krux.wallet import Wallet
from krux.input import BUTTON_ENTER, BUTTON_PAGE
@@ -113,10 +109,7 @@ def test_export_mnemonic_stackbit_vertical(mocker, amigo, tdata):
def test_export_mnemonic_stackbit_vertical_compact(mocker, m5stickv, tdata):
- """Dense layout on M5StickV: 6 words per page, 4 pages for 24-word mnemonic.
-
- 2 words side-by-side × 3 rows, no word names or BIP39 codes.
- """
+ """Dense layout on M5StickV: 6 words per page, 4 pages for 24-word mnemonic."""
from krux.pages.home_pages.mnemonic_backup import MnemonicsView
from krux.wallet import Wallet
from krux.input import BUTTON_ENTER, BUTTON_PAGE
@@ -236,6 +229,172 @@ def test_esc_entering_stackbit(amigo, mocker):
assert words == None
+def test_load_key_from_1248_submenu(m5stickv, mocker):
+ """Submenu navigates to _load_key_from_1248 for both Standard and Vertical."""
+ from krux.pages.mnemonic_loader import MnemonicLoader
+ from krux.pages import MENU_CONTINUE
+ from krux.input import BUTTON_ENTER, BUTTON_PAGE
+
+ cases = [
+ # (label, btn_sequence)
+ ("Standard", [BUTTON_ENTER, BUTTON_PAGE, BUTTON_PAGE, BUTTON_ENTER]),
+ ("Vertical", [BUTTON_PAGE, BUTTON_ENTER, BUTTON_PAGE, BUTTON_ENTER]),
+ ]
+
+ for i, (label, btn_sequence) in enumerate(cases):
+ print("Case %d: %s" % (i, label))
+ ctx = create_ctx(mocker, btn_sequence)
+ loader = MnemonicLoader(ctx)
+ mocker.patch.object(loader, "_load_key_from_1248", return_value=MENU_CONTINUE)
+ loader.load_key_from_1248()
+
+ loader._load_key_from_1248.assert_called_once()
+ assert ctx.input.wait_for_button.call_count == len(btn_sequence)
+
+
+def test_enter_stackbit_vertical(m5stickv, mocker, tdata):
+ """Button navigation: enter 'abandon'*11+'about' on a vertical 1248 grid."""
+ from krux.pages.stack_1248 import Stackbit
+ from krux.input import BUTTON_ENTER, BUTTON_PAGE, BUTTON_PAGE_PREV
+
+ ABANDON_SEQ = (
+ # Move to fourth digit, toggle "1"
+ [BUTTON_PAGE] * 3
+ + [BUTTON_ENTER]
+ # Go, confirm word "abandon"
+ + [BUTTON_PAGE_PREV] * 4
+ + [BUTTON_ENTER] * 2
+ )
+ ABOUT_SEQ = (
+ # Move to fourth digit, toggle "4" (cell 8 is skipped as invalid)
+ [BUTTON_PAGE] * 10
+ + [BUTTON_ENTER]
+ # Go, confirm word "about", confirm done
+ + [BUTTON_PAGE_PREV] * 11
+ + [BUTTON_ENTER] * 3
+ )
+ BTN_SEQUENCE = ABANDON_SEQ * 11 + ABOUT_SEQ
+
+ ctx = create_ctx(mocker, BTN_SEQUENCE)
+ stackbit = Stackbit(ctx)
+ words = stackbit.enter_1248_vertical()
+
+ assert ctx.input.wait_for_button.call_count == len(BTN_SEQUENCE)
+ assert " ".join(words) == tdata.SIGNING_MNEMONIC
+
+
+def test_enter_stackbit_vertical_touch(amigo, mocker, tdata):
+ """Touch navigation: enter 'abandon'*11+'about' via touch screen on vertical grid."""
+ from krux.pages.stack_1248 import Stackbit, VERT_GO_INDEX
+ from krux.input import BUTTON_TOUCH
+
+ YES = 1
+ # 11x "abandon": touch "1" of fourth digit, Go, confirm
+ # 1x "about": touch "4" of fourth digit, Go, confirm, done
+ BTN_SEQUENCE = [BUTTON_TOUCH] * 3 * 11 + [BUTTON_TOUCH] * 4
+ TOUCH_SEQUENCE = [3, VERT_GO_INDEX, YES] * 11 + [11, VERT_GO_INDEX, YES, YES]
+
+ ctx = create_ctx(mocker, BTN_SEQUENCE, touch_seq=TOUCH_SEQUENCE)
+ stackbit = Stackbit(ctx)
+ words = stackbit.enter_1248_vertical()
+
+ assert ctx.input.wait_for_button.call_count == len(BTN_SEQUENCE)
+ assert " ".join(words) == tdata.SIGNING_MNEMONIC
+
+
+def test_esc_entering_stackbit_vertical(amigo, mocker):
+ """Pressing Esc from the vertical input grid returns None."""
+ from krux.pages.stack_1248 import Stackbit
+ from krux.input import BUTTON_ENTER, BUTTON_PAGE_PREV
+
+ BTN_SEQUENCE = (
+ # Move to Esc
+ [BUTTON_PAGE_PREV] * 3
+ + [BUTTON_ENTER] # Select "Esc"
+ + [BUTTON_ENTER] # Confirm
+ )
+ ctx = create_ctx(mocker, BTN_SEQUENCE)
+ stackbit = Stackbit(ctx)
+ words = stackbit.enter_1248_vertical()
+
+ assert ctx.input.wait_for_button.call_count == len(BTN_SEQUENCE)
+ assert words is None
+
+
+def test_enter_stackbit_vertical_reject_word(m5stickv, mocker):
+ """Rejecting a word confirmation returns to input without advancing word_index."""
+ from krux.pages.stack_1248 import Stackbit
+ from krux.input import BUTTON_ENTER, BUTTON_PAGE, BUTTON_PAGE_PREV
+
+ BTN_SEQUENCE = (
+ [BUTTON_ENTER] # toggle bit at index 0 (col 0, weight 1)
+ + [BUTTON_PAGE_PREV] # wrap to Go
+ + [BUTTON_ENTER] # press Go
+ + [BUTTON_PAGE] # prompt: No — reject word
+ # continue: digits reset, index reset to 0
+ + [BUTTON_PAGE_PREV] * 3 # navigate 0 -> Go(18) -> 17 -> Esc(16)
+ + [BUTTON_ENTER] # press Esc
+ + [BUTTON_ENTER] # confirm "Are you sure?"
+ )
+
+ ctx = create_ctx(mocker, BTN_SEQUENCE)
+ stackbit = Stackbit(ctx)
+ words = stackbit.enter_1248_vertical()
+
+ assert ctx.input.wait_for_button.call_count == len(BTN_SEQUENCE)
+ assert words is None
+
+
+def test_load_key_from_1248_cancel(m5stickv, mocker):
+ """_load_key_from_1248 returns MENU_CONTINUE when user cancels (both orientations)."""
+ from krux.pages.mnemonic_loader import MnemonicLoader
+ from krux.pages import MENU_CONTINUE
+
+ cases = [
+ # (label, vertical, stackbit_method)
+ ("Standard", False, "enter_1248"),
+ ("Vertical", True, "enter_1248_vertical"),
+ ]
+
+ for i, (label, vertical, stackbit_method) in enumerate(cases):
+ print("Case %d: %s" % (i, label))
+ ctx = create_ctx(mocker, [])
+ loader = MnemonicLoader(ctx)
+ mocker.patch(
+ "krux.pages.stack_1248.Stackbit." + stackbit_method, return_value=None
+ )
+ result = loader._load_key_from_1248(vertical=vertical)
+
+ assert result == MENU_CONTINUE
+
+
+def test_load_key_from_1248_success(m5stickv, mocker):
+ """_load_key_from_1248 calls _load_key_from_words on success (both orientations)."""
+ from krux.pages.mnemonic_loader import MnemonicLoader
+ from krux.pages import MENU_CONTINUE
+
+ TEST_WORDS = ["language"] * 11 + ["auction"]
+ cases = [
+ # (label, vertical, stackbit_method)
+ ("Standard", False, "enter_1248"),
+ ("Vertical", True, "enter_1248_vertical"),
+ ]
+
+ for i, (label, vertical, stackbit_method) in enumerate(cases):
+ print("Case %d: %s" % (i, label))
+ ctx = create_ctx(mocker, [])
+ loader = MnemonicLoader(ctx)
+ mocker.patch(
+ "krux.pages.stack_1248.Stackbit." + stackbit_method,
+ return_value=TEST_WORDS,
+ )
+ mocker.patch.object(loader, "_load_key_from_words", return_value=MENU_CONTINUE)
+ result = loader._load_key_from_1248(vertical=vertical)
+
+ loader._load_key_from_words.assert_called_once_with(TEST_WORDS)
+ assert result == MENU_CONTINUE
+
+
def test_entering_stackbit_buttons_turbo(mocker, m5stickv):
from krux.pages.stack_1248 import Stackbit
from krux.input import PRESSED, FAST_FORWARD, FAST_BACKWARD, Input
@@ -264,3 +423,63 @@ def test_entering_stackbit_buttons_turbo(mocker, m5stickv):
stackbit.enter_1248()
stackbit.index.assert_called_with(0, FAST_BACKWARD)
+
+
+def test_toggle_bit_vertical_invalid_cell(m5stickv, mocker):
+ """_toggle_bit_vertical returns digits unchanged when index is an invalid cell."""
+ from krux.pages.stack_1248 import Stackbit, VERT_INVALID_CELLS
+
+ ctx = create_ctx(mocker, [])
+ stackbit = Stackbit(ctx)
+ digits = [0, 0, 0, 0]
+
+ for invalid_index in sorted(VERT_INVALID_CELLS):
+ result = stackbit._toggle_bit_vertical(list(digits), invalid_index)
+ assert result == digits, (
+ "digits should be unchanged for index %d" % invalid_index
+ )
+
+
+def test_toggle_bit_vertical_clamp(m5stickv, mocker):
+ """_toggle_bit_vertical resets to 0 when a toggle would overflow col-0 (>2) or other cols (>9)."""
+ from krux.pages.stack_1248 import Stackbit
+
+ ctx = create_ctx(mocker, [])
+ stackbit = Stackbit(ctx)
+
+ # Col 0, bit-1 already set; toggle bit-2 (index=4, col=0, row=1) → 1^2=3 > 2 → clamped to 0
+ result = stackbit._toggle_bit_vertical([1, 0, 0, 0], 4)
+ assert result[0] == 0
+
+ # Col 1, bit-8 already set; toggle bit-2 (index=5, col=1, row=1) → 8^2=10 > 9 → clamped to 0
+ result = stackbit._toggle_bit_vertical([0, 8, 0, 0], 5)
+ assert result[1] == 0
+
+
+def test_index_vertical_navigation(m5stickv, mocker):
+ """_index_vertical advances, wraps and skips VERT_INVALID_CELLS correctly."""
+ from krux.pages.stack_1248 import Stackbit, VERT_GO_INDEX, VERT_INVALID_CELLS
+ from krux.input import BUTTON_PAGE, BUTTON_PAGE_PREV
+
+ ctx = create_ctx(mocker, [])
+ stackbit = Stackbit(ctx)
+
+ # Forward: normal advance
+ assert stackbit._index_vertical(5, BUTTON_PAGE) == 6
+
+ # Forward: wrap from VERT_GO_INDEX back to 0
+ assert stackbit._index_vertical(VERT_GO_INDEX, BUTTON_PAGE) == 0
+
+ # Forward: index 7 → 8 (invalid) → skip to 9
+ assert 8 in VERT_INVALID_CELLS
+ assert stackbit._index_vertical(7, BUTTON_PAGE) == 9
+
+ # Forward: index 11 → 12 (invalid) → skip to 13
+ assert 12 in VERT_INVALID_CELLS
+ assert stackbit._index_vertical(11, BUTTON_PAGE) == 13
+
+ # Backward: index 9 → 8 (invalid) → skip to 7
+ assert stackbit._index_vertical(9, BUTTON_PAGE_PREV) == 7
+
+ # Backward: index 13 → 12 (invalid) → skip to 11
+ assert stackbit._index_vertical(13, BUTTON_PAGE_PREV) == 11
diff --git a/tests/test_display.py b/tests/test_display.py
index a814c524c..f163af308 100644
--- a/tests/test_display.py
+++ b/tests/test_display.py
@@ -671,6 +671,35 @@ def test_fill_rectangle_on_inverted_display(mocker, amigo):
)
+def test_draw_circle(mocker, m5stickv):
+ mocker.patch("krux.display.lcd", new=mocker.MagicMock())
+ import krux
+ from krux.display import Display
+
+ d = Display()
+
+ d.draw_circle(50, 50, 10, krux.display.lcd.WHITE)
+
+ krux.display.lcd.draw_circle.assert_called_with(
+ 50, 50, 10, 0, krux.display.lcd.WHITE
+ )
+
+
+def test_draw_circle_on_inverted_display(mocker, amigo):
+ mocker.patch("krux.display.lcd", new=mocker.MagicMock())
+ import krux
+ from krux.display import Display
+
+ d = Display()
+ mocker.patch.object(d, "width", new=lambda: 480)
+
+ d.draw_circle(50, 50, 10, krux.display.lcd.WHITE)
+
+ krux.display.lcd.draw_circle.assert_called_with(
+ 480 - 50 - 1, 50, 10, 0, krux.display.lcd.WHITE
+ )
+
+
def test_draw_string(mocker, m5stickv):
mocker.patch("krux.display.lcd", new=mocker.MagicMock())
import krux