Skip to content

feat: give the touch stack three distinct verdict screens - #153

Merged
aido merged 1 commit into
aido:bip85from
buzzromain:feat/three-verdicts-bip85
Aug 5, 2026
Merged

aido merged 1 commit into
aido:bip85from
buzzromain:feat/three-verdicts-bip85

Conversation

@buzzromain

Copy link
Copy Markdown

Why

NBGL's check-result screen shared one title, "Valid Secret\nRecovery Phrase", between two different outcomes: a phrase that is well formed but does not match this device's seed, and a phrase that matches. Only the paragraph underneath said which. The two-button screens already made this distinction: ux_bip39_nomatch_flow and ux_bip39_match_flow (src/bagl/ux_nano.c) carry two different titles, "doesn't match" and "is correct", on two separate flows. NBGL's invalid-phrase screen also gave no advice, where the two-button invalid flows share a line telling the user to check length, order and spelling.

This is the single most security-sensitive screen in the application — the one screen where a fault injection has been shown to be able to flip the displayed result. An ambiguous verdict on it is the most expensive kind of interface bug this project has.

What changed

Two additions to src/common/ui_strings.h: UI_STR_NBGL_RESULT_NOMATCH_TITLE and UI_STR_NBGL_RESULT_INVALID_ADVICE. display_check_result_page() (src/nbgl/ui.c) now indexes a three-title array by the same 0/1/2 outcome it already computed for the verdict icon (previously icons[result + seed_match]; that expression is unchanged, just factored into a named outcome variable also used for the title), instead of pulling a shared title from the two-row body-text table. The invalid case's advice line goes into nbgl_pageInfoDescription_t.centeredInfo.text3.

No two-button file changed — those flows already worked this way.

An SDK pitfall found before it shipped, not after

text3 only renders as its own line under the LARGE_CASE_GRAY_INFO style. Under LARGE_CASE_INFO — what this screen used before — nbgl_layoutAddCenteredInfo() (SDK) maps both text2 and text3 onto the same underlying centeredInfo.description field, so setting text3 would have silently replaced the verdict's own body text rather than adding a line under it. Confirmed by reading the SDK source, then reproduced under Speculos on stax before applying the fix: the body text disappeared, replaced by the advice line, the moment text3 was set under the old style. The style is LARGE_CASE_GRAY_INFO in this PR.

Wording

The new nomatch title is "Mismatched Secret\nRecovery Phrase" — chosen to keep the exact "<Adjective> Secret\nRecovery Phrase" template the other two outcomes already use ("Invalid Secret", "Valid Secret"), including the same line break, after "Secret" rather than after "Recovery". An earlier draft, "Recovery Phrase\nDoesn't Match", was correct in meaning but broke that symmetry — caught by rendering all three screens under Speculos and comparing them side by side, not by re-reading the string in isolation:

Invalid Doesn't match Matches
Invalid Secret / Recovery Phrase Mismatched Secret / Recovery Phrase Valid Secret / Recovery Phrase

Two limits worth stating plainly rather than glossing over:

  • No test with a real, unbriefed user — the wording was reviewed by people who already know what it's supposed to mean, which is a weak check for whether it reads clearly cold.
  • "Invalid" and "Mismatched" currently lead to the exact same next screen (check_result_callback(), unchanged by this PR) — the title tells the user why the phrase was rejected, but the app doesn't yet act differently on the two reasons. Out of scope here; noted for a later step.

Proof that BAGL is untouched

All six targets were built before and after this change (base = the tip of the ui_strings.h extraction this PR builds on, not bip85, to isolate this PR's own diff), with strings extracted from every .elf and diffed:

  • nanos, nanox, nanos+: byte-for-byte identical — text/data/bss and sorted strings output, no difference at all.
  • stax, flex, apex_p: diffs contain exactly the two new strings and the new titles array's debug symbol, nothing else.

Tests

tests/functional/test_bip39_seed_match.py extends from two verdict tests to three (its own docstring predicted this need), and its touch-side helper now asserts the title as well as the body line. One assertion needed a fix along the way: ragger's wait_for_text_on_screen() matches with re.match against each rendered line individually, anchored at that line's start — not a substring search over the whole screen. "not valid" failed because it isn't a line-start in "you have entered is not valid"; fixed to assert the full line. Found by querying Speculos's screen-content API directly rather than guessing.

tests/unit/tests/ui_strings.c's macro enumeration gets the two new macros (it doesn't track the header automatically — a renamed/removed macro is a compile error there, but a newly added one needs a matching line by hand, which this PR includes).

Verification

  • Six unit-test-matrix configurations (ASan, UBSan, -O2, -Os, -fsigned-char, -funsigned-char): 80/80 each.
  • Functional tests under Speculos, both stacks: nanox 9 passed / 9 skipped (was 8/9 before this PR's new test); stax 12 passed / 6 skipped (was 11/6). All skips are pre-existing device gating, unrelated to this change.
  • clang-format --dry-run -Werror clean on touched src/ files.
  • Not covered by this repository's CI on a fork PR: functional-test workflows don't run on pull requests from forks (read-only workflow permissions there), so the Speculos runs above are local and are the only evidence for this PR.

NBGL's check-result screen shared one title, "Valid Secret\nRecovery
Phrase", between "the phrase is well formed but doesn't match this
device's seed" and "the phrase matches" -- only the paragraph
underneath said which. The two-button screens already had this right:
ux_bip39_nomatch_flow and ux_bip39_match_flow (src/bagl/ux_nano.c)
carry two different titles, "doesn't match" and "is correct", on two
distinct flows. NBGL's invalid-phrase screen also gave no advice,
where the two-button invalid flows share a line telling the user to
check length, order and spelling.

Both are fixed the same way: two new macros in ui_strings.h
(UI_STR_NBGL_RESULT_NOMATCH_TITLE, UI_STR_NBGL_RESULT_INVALID_ADVICE),
and display_check_result_page() (src/nbgl/ui.c) now indexes a
three-title array by the same 0/1/2 outcome it already computed for
the verdict icon, instead of pulling a title from the two-row body-text
table. The invalid case's advice goes into a third text field
(nbgl_pageInfoDescription_t.centeredInfo.text3), which the SDK only
renders as its own line under LARGE_CASE_GRAY_INFO -- the style this
screen used before, LARGE_CASE_INFO, maps text2 and text3 onto the
same underlying field, so text3 would have silently replaced the
verdict's own body text rather than appending to it
(nbgl_layoutAddCenteredInfo() in the SDK). Confirmed under Speculos on
stax before adopting the fix: the body text disappeared, replaced by
the advice line, the moment text3 was set under the old style.

The new nomatch title, "Mismatched Secret\nRecovery Phrase", keeps the
exact "<Adjective> Secret\nRecovery Phrase" template the other two
outcomes already use ("Invalid Secret", "Valid Secret") -- including
the same line break, after "Secret" rather than after "Recovery". The
three screens read as one answer set with a single word changing,
rather than three differently-shaped messages, which matters most on
the screen the project's own planning doc calls the one where a fault
injection can flip the result. Confirmed by rendering all three
screens under Speculos on stax and reading them side by side, not by
inspecting the string alone.

No two-button file changes: those flows already worked this way.
Confirmed on all six targets, built before and after with strings
extracted and diffed: `nanos`, `nanox` and `nanos+` are byte-for-byte
identical; the touch targets' diffs contain exactly the two new
strings and the new title array's symbol, nothing else.

tests/functional/test_bip39_seed_match.py extends from two verdicts to
three (its own docstring predicted the need), and its touch-side
helper now asserts the title too, not just the body line -- the
touch/two-button split this file already draws is what made checking
"the two stacks answer the same question the same way" for the new
invalid case a small addition rather than a new file. The invalid
case's body-text assertion had to be a full-line prefix rather than a
free substring: ragger's wait_for_text_on_screen() matches per
rendered line with re.match, which anchors at that line's start, and
"you have entered is not valid" (this string's second line) has no
internal break before "is not valid" the way the match/nomatch
strings do before their own verdict word.

Verified: all six unit-test-matrix configurations (80/80, the two new
ui_strings.h macros added to tests/unit/tests/ui_strings.c's own
enumeration); functional suite on both stacks under Speculos (nanox
9 passed/9 skipped, stax 12 passed/6 skipped, all skips pre-existing
device gating); clang-format clean on touched src/ files.
@aido
aido merged commit afeab19 into aido:bip85 Aug 5, 2026
8 checks passed
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