Skip to content

Zoom-to-transcribe for seed QRs + BIP-85 encoding choice#354

Open
polto wants to merge 2 commits into
cryptoadvance:masterfrom
polto:feature/qr-zoom-transcribe
Open

Zoom-to-transcribe for seed QRs + BIP-85 encoding choice#354
polto wants to merge 2 commits into
cryptoadvance:masterfrom
polto:feature/qr-zoom-transcribe

Conversation

@polto
Copy link
Copy Markdown
Contributor

@polto polto commented May 19, 2026

Summary

Adds a Transcribe button to the seed-class QR exports (main mnemonic, BIP-85 derived mnemonic, SLIP-77 blinding key) that opens a full-screen zoomed view of one zone of the QR at a time, so the user can hand-copy the code onto paper. Also adds the SeedQR / Compact SeedQR / Plaintext encoding menu to BIP-85 mnemonic exports, matching what the main recovery-phrase flow already offers.

Why

Seed QRs are dense and copying one onto paper without a magnifier is error-prone. The previous "Toggle transcribe" feature only widened the cell spacing slightly; the user still had to read the whole code from a single 320 px display.

The new screen splits the QR into an adaptive N×N grid (default chosen so each zone holds ~12-18 modules per side) and renders one zone at a time as discrete black-on-white dots with row/column axis labels. The user pans through the grid with a D-pad and tracks position via a mini-map.

For BIP-85 specifically, the QR export used to show only the plaintext mnemonic — the main seed flow already offers SeedQR (digits) and Compact SeedQR (binary) as alternatives. The PR brings the BIP-85 flow to parity.

What changes

Commit 1: Add QR zoom-to-transcribe screen for hand-copying seed-class QRs

  • src/qr_transcribe_logic.py (new) — pure logic, no LVGL imports: adaptive_grid_size, zone_bounds, module_at, next_zone, iter_zone_modules, clamp_zone. All six covered by unit tests in test/tests/test_qr_transcribe.py.
  • src/gui/screens/qr_transcribe.py (new) — QRTranscribeScreen: 400×400 zone area rendered as an lv.obj widget grid (1 px gap per cell so adjacent dark modules appear as discrete dots), absolute QR row/col axis labels (1..S), D-pad navigation with edge-clamping, mini-map, N controls (2..6), Done button.
  • src/gui/screens/qralert.py — replaces "Toggle transcribe" with a "Transcribe" button that swaps to the new screen and restores the QRAlert on Done.
  • src/apps/blindingkeys/app.py — passes transcribe=True to the SLIP-77 QRAlert.
  • Tests: 8 new unit tests covering all six logic functions including the uneven-last-zone case.

Commit 2: BIP-85: offer SeedQR / Compact SeedQR / Plaintext on the QR export

  • src/apps/bip85.py — prompts the user to pick an encoding format (same menu and same encoding math as keystore/ram.py:show_mnemonic), then renders the resulting QR with transcribe=True so the new Transcribe button is reachable on any format.

Implementation notes

  • An lv.obj widget grid (one widget per QR module) is used instead of lv.canvas because lv.canvas is not rendered by the SDL backend in the unix simulator build. The widget approach works on both SDL and the F469 LTDC framebuffer.
  • The whole zone subtree is rebuilt on every zone change / N change. For seed-class QRs (zone sizes typically 10-15) that's 100-225 widgets per render — well within performance budget on the F469.
  • All button callbacks go through the existing on_release decorator, so every press still feeds touch entropy into the RNG pool.
  • The main-seed QRAlert in keystore/ram.py already passed transcribe=True before this PR, so no change there.

Screenshots

v2-grid-fresh v2-globnum-row1col3 v2-grid-nplus2 v2-globnum-row3col3

Known limitations / discussion

  • No error handling if qrcode.encode() raises on a payload that doesn't fit in v40. Today only seed-class flows pass transcribe=True and those always fit, but a guard + user-facing Alert would be a nice safety net.
  • _transcribe_loop calls lv.scr_load directly rather than going through AsyncGUI.show_screen, so gui.scr stays pointed at the underlying QRAlert while the transcribe screen is on top. Harmless for the seed-class flows (no concurrent UI activity) but happy to refactor if maintainers prefer.

Built on top of master at v1.10.3.

polto added 2 commits May 18, 2026 01:20
When the device shows a seed-class QR (main mnemonic, BIP-85 derived
mnemonic, SLIP-77 blinding key) the QR codes are dense enough that
copying them by hand is error-prone. This commit adds a "Transcribe"
button to the QRAlert that opens a full-screen zoom view of one
adaptive grid zone at a time.

Architecture
------------
src/qr_transcribe_logic.py is a pure module (no LVGL imports) with
six small functions that drive the screen: adaptive_grid_size picks
an N x N grid based on the QR size so each zone holds ~12-18 modules
per side, zone_bounds slices the bitmap, module_at reads a single bit
from the packed qrcode.encode buffer, next_zone clamps directional
moves to the grid edges, iter_zone_modules yields per-cell coords for
the screen renderer, and clamp_zone snaps the current zone back into
range when the user shrinks N. All six are covered by unit tests in
test/tests/test_qr_transcribe.py.

The screen src/gui/screens/qr_transcribe.py is an LVGL Screen that
renders one zone as a grid of small lv.obj cells (black for dark
modules, white for light) inside a fixed 400x400 white panel. Each
cell carries a 1-pixel gap so adjacent dark modules read as discrete
dots rather than a connected blob, which is the property the user
needs when copying onto paper. Axis labels along the top and left
edges show the absolute QR row / column indices (1..S) so the user
always knows where the zone sits in the full matrix. A widget grid
is used rather than lv.canvas because lv.canvas is not rendered by
the SDL backend in the unix simulator build.

The bottom area carries:
  - a D-pad (50 x 50 buttons) on the left for zone navigation, with
    edge buttons greyed out via lv.btn.STATE.INA when the user is at
    a grid edge
  - a mini-map on the right that highlights the current zone within
    the N x N grid
  - a centred Done button at y=720 that returns the user to the
    underlying QRAlert
A "-" / "+" pair in the title row lets the user pick N between 2 and
6; clamp_zone keeps the (zone_r, zone_c) coordinate valid when N
drops, and the mini-map rebuilds to the new grid size.

All button callbacks go through the existing on_release decorator so
each press feeds the touch point into the RNG entropy pool, matching
the rest of the codebase.

Wiring
------
QRAlert (src/gui/screens/qralert.py) replaces the previous "Toggle
transcribe" spacing toggle with a "Transcribe" button. Tapping it
schedules an asyncio task that constructs QRTranscribeScreen with the
underlying payload (via QRCode.get_text, which returns the original
message rather than the current bcur frame), swaps to it, and on
Done restores the QRAlert. transcribe=True is enabled on the SLIP-77
blinding-key export site (apps/blindingkeys/app.py); the main-seed
QRAlert in keystore/ram.py already passed it before this commit so
no change there. The BIP-85 export site is wired in the next commit
together with an encoding-format menu.
When the user exports a BIP-85 derived mnemonic as a QR, the device
used to render the plaintext words straight away. The main recovery
phrase flow in keystore/ram.py has long offered three encoding
formats — SeedQR (4-digit-zero-padded word indices), Compact SeedQR
(the raw 16/24/32 entropy bytes, displayed as hex on screen and
encoded as binary in the QR), and Plaintext (the mnemonic string) —
so derived mnemonics now get the same menu before the QR is shown.

The encoding choice also passes through to the underlying QRAlert as
transcribe=True so the new Transcribe button (introduced in the
previous commit) is reachable for any of the three formats. If the
user backs out of the encoding menu, the BIP-85 flow returns to its
own derivation menu instead of producing an empty QR.

The encoding logic is byte-for-byte identical to show_mnemonic in
keystore/ram.py so the resulting payloads round-trip the same way as
the main seed; a 12-word SeedQR is 48 numeric chars, a 12-word
Compact SeedQR is 16 bytes (32 hex chars on screen), etc.
@netlify
Copy link
Copy Markdown

netlify Bot commented May 19, 2026

Deploy Preview for specter-diy-docs failed.

Name Link
🔨 Latest commit 4c37764
🔍 Latest deploy log https://app.netlify.com/projects/specter-diy-docs/deploys/6a0c75ee1d2993000833b814

1 similar comment
@netlify
Copy link
Copy Markdown

netlify Bot commented May 19, 2026

Deploy Preview for specter-diy-docs failed.

Name Link
🔨 Latest commit 4c37764
🔍 Latest deploy log https://app.netlify.com/projects/specter-diy-docs/deploys/6a0c75ee1d2993000833b814

@Schnuartz
Copy link
Copy Markdown
Contributor

I think we should keep it exactly like with SeedSigner

So no custom n but the blocks SeedSigner Uses. A1, B3
( cf. https://github.com/SeedSigner/seedsigner/blob/dev/docs/seed_qr/printable_templates/ )

Like on the preset I have.

In this turn we should also show the standard SeedQR in a grid like SeedSigner uses.
photo_2026-05-19_16-55-47

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants