Skip to content

fix(zcash): smooth, always-moving progress bar during shielded signing#303

Merged
BitHighlander merged 4 commits into
developfrom
feat/zcash-signing-progress-ux
Jul 14, 2026
Merged

fix(zcash): smooth, always-moving progress bar during shielded signing#303
BitHighlander merged 4 commits into
developfrom
feat/zcash-signing-progress-ux

Conversation

@BitHighlander

Copy link
Copy Markdown
Owner

Problem

Shielded Zcash (Orchard/PCZT) signing feels stuck. The device signs each action's RedPallas signature (fast) but then blocks waiting for the host to generate the next zk-proof (slow). The progress bar was only redrawn on discrete milestones via layoutProgress(), which draws once and returns — nothing animates between calls. Result:

  • The bar sits frozen at 0% through the first (longest) host proof, because the first non-zero value only appears after action 0 is fully received and signed.
  • Progress then jumps in large steps (granularity = n_actions).
  • Transparent-phase handlers reset it back to 0.

Users read the frozen bar as the KeepKey having failed mid-sign.

Fix — timer-driven "trickle" progress

A progress animation that is always visibly moving but never falsely completes:

  • layoutProgressTrickle(desc, base, target) (layout.c) eases the bar asymptotically from the last real milestone toward the next: add = span * t / (t + TAU) — fast at first, slowing as it nears the target, never reaching it. So it moves instantly and continuously, but can't show 100% until signing actually completes.
  • layout_animate_poll() — called from usbPoll() (device usb.c + emulator udp.c) — pumps the animation off the existing ANIMATION_PERIOD (20 ms) timer while the device blocks on host I/O. Gated on a dedicated trickle_active flag, so every other flow is untouched (confirm dialogs, PIN entry, home screen).

Wired into the signing FSM (fsm_msg_zcash.h)

  • zcash_send_action_ack() — the single choke point — arms the trickle toward the milestone the requested action will reach. Covers the initial wait and every inter-action wait uniformly.
  • fsm_msgZcashPCZTAction() stops the trickle on arrival, so the exact per-action milestone (and the final fee confirm) draw cleanly.
  • layout_clear_animations() clears it on every layoutHome()/exit path.

The animation is queued only during the Orchard action-wait windows, so it can never draw over the transparent-output review or fee-confirm dialogs.

Safety / scope

  • No change to any signing logic, digests, or signatures — display only.
  • The pump is a no-op unless trickle_active, so non-Zcash flows are byte-for-byte unaffected.
  • Never shows 100% until the real final milestone, so it can't imply a completed sign prematurely.

Verification

  • lib/board/layout.c, lib/board/udp.c, lib/firmware/fsm.c (which includes fsm_msg_zcash.h) all compile clean with the real emulator toolchain flags (-fsyntax-only -Wall).
  • On-device behaviour to confirm on an rc build: bar moves off 0 immediately, trickles smoothly during each host proof, snaps to each real milestone as actions arrive, and completes to home.

Shielded Zcash (Orchard/PCZT) signing blocks the device on the host while it
generates zk-proofs between action messages. The progress bar was drawn only
on discrete milestones (layoutProgress), so it sat frozen at 0% through the
first — longest — host proof, then jumped in large steps. Users read the
frozen bar as the device having failed.

Add a timer-driven 'trickle' progress animation:
- layoutProgressTrickle(desc, base, target) eases the bar asymptotically from
  the last real milestone toward the next (add = span*t/(t+TAU)): fast at
  first, slowing as it nears the target, never actually reaching it — so it is
  always visibly moving but never falsely shows 100%.
- layout_animate_poll(), called from usbPoll() (device usb.c + emulator udp.c),
  pumps the animation off the existing ANIMATION_PERIOD timer while the device
  blocks on host I/O. Gated on a dedicated trickle_active flag, so every other
  flow (confirm dialogs, PIN entry, home screen) is untouched.

Wire it into the Zcash signing FSM:
- zcash_send_action_ack() arms the trickle toward the milestone the requested
  action will reach — the single choke point covering the initial wait and
  every inter-action wait.
- fsm_msgZcashPCZTAction() stops it on arrival so the exact per-action
  milestone (and the final fee confirm) draw cleanly; layout_clear_animations()
  clears it on every layoutHome/exit path.

The animation is queued only during the Orchard action-wait windows, so it can
never draw over the transparent-output or fee confirm dialogs.
Review catch (P1): trickle_progress_callback() drew via
animating_progress_handler(), which calls layout_clear() ->
layout_clear_animations() -- so every frame dequeued the animation and reset
trickle_active. The bar advanced at most once, then froze; and the initial
layoutProgress(..., 0) issued after arming wiped the trickle before its first
frame.

- Split the renderer: progress_render() clears only the framebuffer
  (layout_clear_static) and redraws, leaving the animation queue intact.
  animating_progress_handler() keeps its historical queue-clearing behaviour
  for one-shot callers by wrapping progress_render(). The trickle callback and
  layoutProgressTrickle() now render via progress_render(), and the arm draws
  an immediate first frame.
- Ordering: draw the initial/transition static layoutProgress("Signing
  Zcash", 0) BEFORE zcash_send_action_ack() at all three sites (SignPCZT and
  the two transparent->action transitions), so arming the trickle is the last
  display action and is never cleared by a following static draw.
Review round 2 (both P2):

- Abort paths left the trickle running: fsm_msgCancel() and
  fsm_msgClearSession() call zcash_signing_abort() without a subsequent
  layoutHome(), so cancelling during an Orchard wait left the animation timer
  and OLED redraw loop active after signing ended. Centralize cleanup:
  zcash_signing_abort() now calls layoutProgressTrickleStop() (idempotent), so
  every abort path kills the animation regardless of caller.

- Long waits eventually looked frozen anyway: the eased fill is floor()'d to
  248 bar pixels, so with the asymptotic curve the final visible pixel
  transition lands after roughly 18-73s per segment and later frames were
  identical. Add a perpetual sweep marker: progress_render_ex() takes a
  0..999 marker phase and draws a small dim block cycling across the unfilled
  region (1.6s period, phase = elapsed % period). The marker loops forever,
  so the display keeps visibly changing for arbitrarily slow proofs; static
  one-shot draws (progress_render / animating_progress_handler) pass -1 and
  are pixel-identical to before.
Review round 3 (P2): the sweep marker was confined to the unfilled region and
gated on span_w > marker_w + 2, so for the final action of a 16-action tx
(base 937 permil) the fill reached 952 permil within ~380ms, span_w hit 10,
and the marker vanished -- after which the eased fill pixel-saturated and the
display froze again.

Sweep the marker across the FULL inner track instead: travel is constant
(~238px) regardless of fill level, so it never disappears. Mid-gray (0x99)
reads as a darker notch over the 0xff fill and a brighter block over the 0x00
remainder, visible at any progress value. The marker shrinks only if the whole
track is narrower than 8px (never on this display).

Also: clang-format pass over the three files CI flagged (comment re-wraps and
pointer-spacing only; no functional change).
@BitHighlander BitHighlander merged commit 2bd300d into develop Jul 14, 2026
17 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.

1 participant