Skip to content

fix(unlock+bitbox): #461 follow-ups — finish routing, robustness, tests, idle-lock#472

Closed
Blume1977 wants to merge 14 commits into
RealUnitCH:developfrom
Blume1977:fix/pr461-followups
Closed

fix(unlock+bitbox): #461 follow-ups — finish routing, robustness, tests, idle-lock#472
Blume1977 wants to merge 14 commits into
RealUnitCH:developfrom
Blume1977:fix/pr461-followups

Conversation

@Blume1977
Copy link
Copy Markdown
Contributor

@Blume1977 Blume1977 commented May 20, 2026

Closes #468.

Follow-up to the merged #461. Picks up the items the reviewer (#468) flagged as Critical / High / Medium / Low, plus the test gaps. Open as Draft — no manual QA yet.

Critical (1.x) — routing & silent-failure modes

  • 1.1 SellButton routes bitboxDisconnected → reconnect sheet. Matches Buy / SellBitbox / SettingsUserData.
  • 1.2 WalletLockedException deleted. Every sign path now goes through WalletService.ensureCurrentWalletUnlocked() first, so a locked-credentials sign is a programmer error: assert(false, ...) + throw StateError(...) on _LockedCredentials.sign* + SoftwareViewWalletAccount.signMessage. The typed Exception that nothing caught is gone.
  • 1.3 Silent AppStore.ensureUnlocked no-op gone. Lifecycle moved into WalletService.ensureCurrentWalletUnlocked() directly — AppStore is back to a pure state container, no _unlocker callback to forget.
  • 1.4 SettingsSeedView no longer crashes on first render with a SoftwareViewWallet. The cubit's initial empty seed used to flow straight into MnemonicReadOnlyField's length == 12 assert; the view now shows a CupertinoActivityIndicator until the async unlock fills in the 12 words. Regression test added (SoftwareViewWallet shows spinner, then SeedBlurCard).

High (2.x) — onboarding latency + extra-press UX

  • 2.1 ensureSignatureFor is now fire-and-forget in ConnectBitboxCubit / CreateWalletCubit / RestoreWalletCubit. A 20 s HTTP timeout on the warm-up no longer blocks the success-state emit.
  • 2.2 New i18n key connectBitboxSignInHint (en + de, alphabetically inserted between connectBitboxFailed and connectBitboxTitle) shown on the channel-hash verify screen, so the user knows a second BitBox confirmation is coming after the code match. The warm-up failure path now logs at SEVERE so it lands in support telemetry instead of silently degrading the next auth call.
  • 2.3 warmAuthSignature extracted to a top-level helper in dfx_auth_service.dart. The three cubits used to each carry a near-duplicate _warmAuthSignature, two of which logged at default level while only the BitBox one was SEVERE. One helper, SEVERE everywhere — the PR-body claim now matches reality.

Medium (3.x) — BitBox robustness

  • 3.1 _runOrThrowDisconnect narrows to on Exception catch — TypeErrors / asserts from a genuine sign-internal bug stay their own error instead of being relabelled as a disconnect. Original error + stack are logged on the rewrap path.
  • 3.2 Snapshot semantics in every sign method. bitboxManager + derivationPath copied to locals up-front so the disconnect observer nulling them mid-sign produces BitboxNotConnectedException, not a NoSuchMethodError.
  • 3.3 BitboxService._pendingDisconnect Future is awaited at the top of init() so a rapid disconnect-then-reconnect can't race two ops on the SDK manager singleton.
  • 3.4 setBitbox() preserves derivationPath. The reconnect path no longer silently reverts a non-default path on every re-attach; clearBitbox() keeps the path so the next setBitbox() restores it.
  • 3.5 _credentialsByAddress: Map<String, BitboxCredentials> replaces the single _credentials field. Multi-wallet would orphan the previous wallet's reference otherwise.
  • 3.6 Observer device-loss path guards against tick overlap. Two back-to-back periodic ticks straddling the getAllUsbDevices() await used to both enter the empty-list branch and clobber _pendingDisconnect, racing two disconnect() calls on the SDK manager. An early if (!_isConnected) return; at the top of the branch makes the second tick bail.

Medium (4.x) — security trade-off revisit

  • 4.1 _pinHashIterations = 250_000 (was 100k). Roughly doubles offline brute-force cost while staying sub-second on a mid-range phone. 100k hashes are now in _legacyIterationCandidates and transparently rehashed on the next successful unlock.
  • 4.2 WalletService arms a 60 s idle-lock timer on ensureCurrentWalletUnlocked(). If a sign path forgets the explicit lockCurrentWallet, the mnemonic is dropped 60 s later anyway — the reviewer's "user sells once then leaves app foregrounded" path now has a hard cap. RealUnitRegistrationService.completeRegistration / registerWallet move their lock-down into try/finally so the throw path locks too.
  • 4.3 ensure / lock are now reentrancy-safe. _activeUnlockHolders counts active holders so a flow A finishing its sign + lock while a parallel flow B is still mid-sign can no longer tear the unlocked wallet out from under B (was: B's next sign hit _LockedCredentialsUnsupportedError). The 60s safety net bypasses the counter via _forceLock so a stuck holder can't pin the mnemonic past the window.
  • 4.4 SettingsSeedCubit._loadSeed bails on isClosed. A user navigating away during the DB-decrypt await used to surface as an unhandled Cannot emit after close async error.

Low (5.x + 6.x)

  • 5.1 Page tests for CreateWallet / RestoreWallet register DfxKycService alongside WalletService so the cubits' DFXAuthService dep resolves. Was latent flaky.
  • 5.2 Unit tests for SoftwareViewWallet's signing surface, _LockedCredentials.sign*, and WalletService.ensureCurrentWalletUnlocked / lockCurrentWallet.
  • 5.3 Race tests in bitbox_credentials_test: disconnect-mid-sign surfaces BitboxNotConnectedException (not the underlying Exception), and a sign called after clearBitbox() doesn't slip through via NoSuchMethod.
  • 5.4 Reentrancy test in wallet_service_test: two parallel ensureCurrentWalletUnlocked + one lockCurrentWallet leaves the wallet unlocked for the second holder.
  • 6.1 attachUnlocker callback gone. Lifecycle owned by WalletService now (see 1.3).
  • 6.2 WalletService is registerLazySingleton instead of factory — ensureUnlocked no longer rebuilds the service on every promote.
  • 6.3 Double-guard on SellBitboxCubit.confirmSwap / confirmDeposit dropped. The existing on BitboxNotConnectedException catch already routes to the reconnect state.

Architectural shape after this PR

  • AppStore is a pure state container. No DI-wired callbacks.
  • WalletService owns the unlock/lock lifecycle (ensureCurrentWalletUnlocked + lockCurrentWallet + post-unlock timer). Every DFX service that signs threads it via the constructor — 12 subclasses updated, no service-locator workarounds.
  • The locked-wallet code path is programmer-error-only — no caller catches WalletLockedException because it doesn't exist.

Test plan

  • dart run tool/generate_localization.dart
  • flutter analyze — clean
  • flutter test — 1410/1410 green
  • iOS manual (BitBox): pair → check the new "additional confirmation" hint is shown before the EIP-191 sign → connection succeeds → first authenticated API call works without a fresh BitBox prompt
  • iOS manual (BitBox): pair → background long enough for BLE to drop → return → tap "Sell" → expect reconnect button (was: generic snackbar)
  • PIN unlock: existing user with 100k hash unlocks once, hash transparently rehashed to 250k, second unlock at the new cost (still sub-second)
  • Software wallet sell: open app → start sell → confirm → wallet auto-locks within 60s if the sign path's lockCurrentWallet didn't fire
  • Software wallet onboarding (slow connection): simulate a 20 s sign-message endpoint → create-wallet UI advances to the success state without blocking on the warm-up
  • Settings → Wallet sichern: open straight from launch (with a SoftwareViewWallet — the default after fix(bitbox): recover from BLE/USB disconnect without losing wallet session #461) → expect a spinner during DB decrypt → 12-word phrase appears under the blur card with no crash

@TaprootFreak TaprootFreak force-pushed the fix/pr461-followups branch from e19daf3 to e18d4dd Compare May 20, 2026 17:45
@TaprootFreak
Copy link
Copy Markdown
Contributor

PR-Body-Behauptungen wurden Punkt für Punkt gegen den Code verifiziert. Aussagen sind ehrlich, Coverage ist breit. Hier die strukturierte Bewertung.


1. Issue #468 Coverage — gegen den Code verifiziert

Issue Status Evidenz
1.1 Sell → reconnect sheet ✅ covered lib/screens/sell/widgets/sell_button.dart:37-49 — Branch bitboxDisconnected ruft showBitboxReconnectSheet, re-runt getPaymentInfo
1.2 WalletLockedException ✅ covered Klasse gelöscht, grep WalletLockedException = 0 hits, _LockedCredentials.sign* macht assert + StateError
1.3 silent ensureUnlocked no-op ✅ covered AppStore ist wieder reiner State-Container, keine _unlocker-Callback-Inversion mehr
2.1 fire-and-forget warmup ✅ covered unawaited(_warmAuthSignature(...)) in create_wallet_cubit:22, restore_wallet_cubit:28, connect_bitbox_cubit:161
2.2 i18n hint + SEVERE-Log ✅ covered connectBitboxSignInHint Key existiert in beiden ARBs auf Zeile 56 (alphabetisch korrekt), level: 1000 Log in connect_bitbox_cubit:190
3.1 catch(_)on Exception ✅ covered bitbox_credentials.dart:165, Original error + stack werden geloggt vor rewrap
3.2 Snapshot Semantik ✅ covered final manager = bitboxManager; final path = derivationPath; in allen 3 sign-Methoden
3.3 _pendingDisconnect Race ✅ covered bitbox.dart:21,38-43,71-73await _pendingDisconnect im init
3.4 derivationPath preserve ✅ covered setBitbox fällt auf existierendes derivationPath zurück, clearBitbox lässt es stehen
3.5 Map statt Single-Field ✅ covered _credentialsByAddress: Map<String, BitboxCredentials> durchgängig iteriert
4.1 PIN iter 100k→250k + Legacy ✅ covered _pinHashIterations = 250000, _legacyIterationCandidates = [600000, 100000, 10000], transparenter Rehash in verifyPin
4.2 Auto-lock + downgrade ✅ covered lockCurrentWallet() schreibt SoftwareViewWallet zurück (wallet_service.dart:140-147), 60s _idleLockTimer
5.1 Page-test DI ✅ covered DfxKycService Stubs in beiden Page-Tests
5.2 Locking-Invariants ✅ covered wallet_test.dart + wallet_service_test.dart mit promote/lock-Cases
5.3 Race-Tests ✅ covered "disconnect mid-sign" + "cleared credentials" in bitbox_credentials_test.dart:206-236
6.1/6.2/6.3 ✅ covered attachUnlocker weg, registerLazySingleton, Doppel-Guard in SellBitboxCubit reduziert
7 PR-Hygiene ✅ covered PR body kreuzt Items sauber gegen Severity-Buckets

Verdict: 🟢 22/22 Items adressiert, keine stillen Skips. Keine überhöhten Claims gefunden.


2. Architektur 🟢

ensureUnlocked von AppStoreWalletService ist die richtige Richtung — AppStore ist jetzt das reine State-Container das Issue 6.1 verlangte. lockCurrentWallet() downgraded _wallet korrekt zurück zu SoftwareViewWallet (Issue 4.2 erfüllt — nicht nur Flag-Flip). Idle-Timer (60s) feuert über Timer, wird sauber auf jedem lock/ensureUnlock resettet. 12 DFX-Service-Subklassen durchgereicht, kein Service-Locator-Workaround. registerLazySingleton statt Factory.

3. Security 🟢

PIN-Bump 100k → 250k mit nachvollziehbarer Begründung im Code-Kommentar (sub-second auf mid-range Phones, Hardware-Boundary + Lockout-Cascade als primäre Verteidigung). Issue 4.1 wollte „measured number" — 250k ist begründet, nicht arbiträr. Mnemonic wird tatsächlich gedroppt (kein Flag-Flip): _appStore.wallet = SoftwareViewWallet(...) ersetzt das SoftwareWallet-Objekt komplett. Idle-Timeout 60s — knapp für „Sell → Banküberweisung tippen → wieder zurück", aber das ist genau die Trade-off-Diskussion die der Reviewer wollte.

🟡 nice-to-have: Timer cancel-on-dispose wäre clean — WalletService ist Lazy-Singleton, lebt Process-lifetime, also irrelevant in der Praxis.

4. Routing 🟢

1.1, 1.2, 1.3 alle drei verifiziert (siehe Tabelle). bitboxDisconnected-Branch in sell_button.dart matched exakt das Pattern aus Buy/SellBitbox.

5. Robustness 🟢

3.1 (on Exception), 3.2 (Snapshot in allen 3 sign-Methoden konsistent), 3.3 (_pendingDisconnect Future awaited), 3.4 (path preserve), 3.5 (Map). Auch _disconnectAndForget() nutzt jetzt on Exception catch — file-weit konsistent.

6. Tests 🟢

wallet_test.dart (94 Zeilen neu) deckt alle 5 sign-Entry-Points von _LockedCredentials + SoftwareViewWalletAccount.signMessage ab, expectiert isA<Error>(). wallet_service_test.dart: promote, no-op-bei-bereits-unlocked, lock-replace, no-op-bei-non-software. bitbox_credentials_test.dart: disconnect-mid-sign + cleared-credentials Races. Tests sind meaningful, nicht box-ticking — die Asserts prüfen das exakte Contract-Bit. 1382/1382 lokal grün nach Rebase.

7. Code-Qualität / Konventionen 🟢

  • mocktail ✅ (kein mockito import)
  • ARB-Keys alphabetisch ✅ (Position 56, zwischen connectBitboxFailed und connectBitboxTitle)
  • Theme.of(context).textTheme.* + CupertinoActivityIndicator
  • Kein S.current in Cubits ✅
  • Named params in State-Klassen ✅
  • flutter analyze clean (33 issues alle in pre-existing lib/generated/intl/* — info-level)

8. Compliance 🟢

  • Draft ✅
  • Keine Claude/Anthropic-Mentions ✅ (git log ... | grep -iE = 0 hits)
  • Branch von develop ✅

9. PR-Body 🟢

Strukturiert nach Severity-Buckets, mapped 1:1 auf Issue #468, hat Test-Plan inkl. manueller iOS-Schritte, referenziert Closes #468. Nichts overstated — alle Claims im Code verifiziert.

🟡 nit: „1 pre-existing failure on settings_seed_page_test" stimmt nicht mehr (war Stand vor Rebase — jetzt 1382/1382 grün).

10. Production-Readiness — ⚠️ approve-with-comments

Code-seitig merge-ready. Es bleiben nur die explizit als Test-Plan TODO markierten manuellen iOS-Pfade (BitBox Pair-Flow, BLE-Drop-Sell, PIN-Rehash, 60s-Auto-Lock, slow-network Onboarding) die echte Hardware brauchen. Sobald diese 5 Checkboxen abgehakt sind: ready to land.

Keine Blocker, keine Scope-Creep, keine versteckten Issue-Skips. Eine ungewöhnlich saubere und ehrliche Follow-up-PR.


Merge-Empfehlung: ⚠️ approve-with-comments

22/22 Items von #468 sauber adressiert, Code matcht den PR-Body, alle 1382 Tests grün; einzig die im Test-Plan ausstehenden Hardware-QA-Schritte fehlen für „ready-to-merge".

@TaprootFreak TaprootFreak marked this pull request as ready for review May 20, 2026 18:26
@TaprootFreak TaprootFreak force-pushed the fix/pr461-followups branch from e18d4dd to 57b2dc5 Compare May 20, 2026 18:28
TaprootFreak pushed a commit to Blume1977/realunit-app that referenced this pull request May 20, 2026
Senior review follow-ups on PR RealUnitCH#472 — four small correctness fixes
around the post-unlock locking contract introduced in RealUnitCH#461.

- DFXAuthService.getSignature now wraps the sign + saveSignature in a
  try/finally so a thrown SigningCancelledException / TimeoutException /
  BitBox disconnect / cache-write failure can't skip lockCurrentWallet()
  and leave the mnemonic resident for the full 60 s window. Matches the
  pattern already used in RealUnitSellPaymentInfoService.confirmPayment
  and RealUnitRegistrationService.completeRegistration / registerWallet.

- WalletService._scheduleIdleLock callback no longer double-nulls the
  timer field. lockCurrentWallet() owns the cancel + null itself, so the
  callback now binds directly to the bound method (Timer(timeout, lock))
  and the two pieces of code stop sharing the invariant.

- WalletService._idleLockTimeout / _idleLockTimer renamed to
  _postUnlockLockTimeout / _postUnlockLockTimer. The timer isn't reset
  on user activity — it caps the lifetime of the in-memory mnemonic
  after each explicit unlock — so "idle" was the wrong noun. Doc comment
  updated to say so.

- BitboxService._credentialsByAddress now goes through a private _key()
  normaliser (lowercase) so a future EIP-55-vs-raw-hex address handoff
  can't fork the map and leak / orphan credentials. Lowercase invariant
  documented at the field declaration.
@TaprootFreak
Copy link
Copy Markdown
Contributor

Re-Verification nach Fix-Pass 45720cf

Zwei-Iterationen-Review-Loop abgeschlossen:

Iteration Findings Status
#1 Deep-Review 1 Bug (Lock-Lifecycle-Contract-Bruch im häufigsten Auth-Pfad) + 3 Should-Fix + 3 Nice-to-have gefunden
Fix-Pass (45720cf) 5 Fixes angewandt, lokal verifiziert erledigt
#2 Re-Review 0 Blocker, 0 Should-fix clean

Was Iteration #1 fand und Fix-Pass behoben hat

  1. DFXAuthService.getSignature ohne try/finally → Mnemonic blieb bei jedem SigningCancelledException / TimeoutException / Cache-Write-Fehler 60s im Heap. Bricht den ganzen 1.3/4.2-Lock-Lifecycle-Contract am am häufigsten ausgelösten Pfad. → Gefixt mit try { sign; saveSignature; return; } finally { lockCurrentWallet; } wie in confirmPayment / completeRegistration / registerWallet.

  2. _idleLockTimer Callback nullte das Feld doppelt → lockCurrentWallet() macht das selbst. → Auf direkte Method-Bindung gewechselt.

  3. BitboxService._credentialsByAddress war case-sensitive → Multi-Wallet-Future (3.5) hätte bei EIP-55-vs-raw-hex Adressen-Mismatch eine zweite Credentials-Instanz angelegt. → _key() Normalizer (lowercase) eingeführt, Invariant dokumentiert.

  4. _idleLockTimeout Naming war irreführend (Timer reset NICHT bei User-Aktivität, nur bei Unlock). → Umbenannt zu _postUnlockLockTimeout mit präziserem Doc-Kommentar.

  5. PR-Body sagte "1373 pass, 1 pre-existing failure" — stimmte nach Rebase nicht mehr. → Aktualisiert auf "1382/1382 green".

Was Iteration #2 verifiziert hat

  • ✅ Alle 5 Fixes korrekt + komplett angewandt
  • ✅ Alle 5 ensureCurrentWalletUnlocked() Call-Sites paaren sauber mit lockCurrentWallet() (4× try/finally in Services, 1× Cubit-close() + _postUnlockLockTimer Safety-Net)
  • ✅ Defense-in-depth bei _LockedCredentials + SoftwareViewWalletAccount: assert(false, ...) Debug + throw StateError(...) Release
  • ✅ Map-Read-Paths sind alle key-agnostisch (.values Iteration) — kein orphan direct-index
  • ✅ Auto-tag / auto-release Workflows unbetroffen
  • flutter analyze clean außerhalb lib/generated/
  • flutter test --coverage 1382/1382 green

Verbleibende 🟡-Items (alle non-blocking / pre-existing)

  • Timer-Callback feuert Future<void> ohne await — lockCurrentWallet ist effektiv non-throwing
  • iOS suspendiert Dart-Timers im Background — 60s-Cap ist Best-Effort, das PIN-Gate (5min Lockout) ist die echte Schutzschicht; pre-existing, nicht durch diese PR verschlechtert
  • App-Lifecycle ruft lockCurrentWallet nicht explizit auf Hide auf — könnte ein eigenes Follow-up-Issue werden

Merge-Empfehlung: ✅ code ready

Code-seitig alles sauber. Architektur-Migration konsistent, alle Issue #468-Items adressiert, Tests grün, kein neuer Defekt eingeführt.

Was noch fehlt für vollen Production-Greenlight (Process, nicht Code):

  • CI-Approval ("Approve and run" für Fork-PR)
  • Code-Review-Approval (reviewDecision: REVIEW_REQUIRED)
  • Manuelle Hardware-QA (5 Checkboxen im Test-Plan)

Blume1977 added 8 commits May 21, 2026 08:17
Issue RealUnitCH#468 follow-ups RealUnitCH#461 — items A (routing) and F (architecture).

Restores AppStore as a pure state container by giving the locking lifecycle
to WalletService directly:

- AppStore: drop _unlocker / attachUnlocker / ensureUnlocked.
- WalletService: take AppStore as a constructor dep, add
  ensureCurrentWalletUnlocked() (1.3) and lockCurrentWallet() (4.2 prep).
- WalletService now a lazySingleton (6.2).
- DFXAuthService and all 12 subclasses thread WalletService through their
  constructors; getSignature() unlocks → signs → locks again.
- SellPaymentInfoService.confirmPayment / RegistrationService.completeRegistration
  / registerWallet lock the wallet in a finally block so a mid-sign throw
  doesn't leave the mnemonic resident.
- SettingsSeedCubit takes WalletService directly; locks again on close().

SellButton routes PaymentInfoError.bitboxDisconnected through the
showBitboxReconnectSheet helper (1.1) — matches the buy / sell-bitbox /
user-data flows.

_LockedCredentials / SoftwareViewWalletAccount drop the typed
WalletLockedException (1.2): every signing path now goes through
ensureCurrentWalletUnlocked() first, so the locked sign call is a
programmer error — surfaced via assert + StateError instead of an exception
nothing catches. WalletLockedException class removed.

SellBitboxCubit drops the redundant credentials.isConnected pre-check in
confirmSwap / confirmDeposit (6.3) — the existing
on BitboxNotConnectedException catch covers it.

Page tests for CreateWallet / RestoreWallet now register DfxKycService
alongside WalletService so the cubit's DFXAuthService dep resolves (5.1).

SettingsSeedCubit._loadSeed uses copyWith so a toggleShowSeed that races
ahead of the unlock isn't dropped when the seed lands.
Issue RealUnitCH#468 follow-ups RealUnitCH#461 — items 2.1 / 2.2 (UX regressions).

ConnectBitboxCubit / CreateWalletCubit / RestoreWalletCubit now wrap the
DFXAuthService.ensureSignatureFor call in unawaited(), so a slow 20 s HTTP
timeout no longer freezes the onboarding UI between create/restore/pair
and the success state. The lazy sign in DFXAuthService.getSignature still
recovers on failure.

ConnectBitboxCubit additionally:
- starts the connection-status observer before the unawaited warm-up so a
  disconnect mid-warm-up is detected.
- raises the warm-up failure to SEVERE so it surfaces in support logs —
  the failure mode it papers over (extra BitBox prompt on first auth) is
  exactly the regression RealUnitCH#461 was fixing.

i18n: new `connectBitboxSignInHint` key (en + de, alphabetically inserted
between connectBitboxFailed and connectBitboxTitle) shown on the
channel-hash verify screen, so the user knows a second BitBox confirmation
is coming after the code match.
Issue RealUnitCH#468 follow-ups RealUnitCH#461 — items 3.1 through 3.5.

- _runOrThrowDisconnect narrows to `on Exception catch` so a TypeError /
  assert from a sign-internal bug stays its own error instead of being
  silently re-labelled as a BitboxNotConnectedException. The original
  error + stack are logged before the rewrap. (3.1)

- Every sign method snapshots `bitboxManager` + `derivationPath` into
  locals up-front, so the disconnect observer nulling them between the
  null-check and the call can no longer NoSuchMethod the bang-operator
  path. (3.2)

- BitboxService tracks an in-flight `_pendingDisconnect` Future and awaits
  it at the top of `init()`, so a rapid disconnect-then-reconnect on the
  same singleton manager no longer races. The disconnect path also moves
  to `on Exception catch` to match the rest of the file. (3.3)

- `BitboxCredentials.setBitbox(connection, [path])` falls back to the
  existing `derivationPath` when no path is passed, and `clearBitbox()`
  no longer wipes the path. Default-path-today, but multi-account would
  silently revert on every reconnect otherwise. (3.4)

- `BitboxService._credentialsByAddress` (a Map) replaces the single
  `_credentials` field, so a wallet switch that hands out fresh
  credentials no longer orphans the previous wallet's reference. The
  reconnect observer + clear paths iterate the map. (3.5)
Issue RealUnitCH#468 follow-ups RealUnitCH#461 — items 4.1 and 4.2.

- SecureStorage._pinHashIterations: 100_000 → 250_000. Roughly halves the
  offline brute-force budget for an attacker who has already broken the
  Keystore/Keychain boundary while staying sub-second on a mid-range phone.
  100_000 hashes are now accepted once via [_legacyIterationCandidates] and
  transparently rehashed up to the new target on the next successful
  unlock. (4.1)

- WalletService.ensureCurrentWalletUnlocked() arms a 60 s idle timer that
  calls lockCurrentWallet() if the caller forgets. Covers the
  "user sells once then leaves the app foregrounded" path the reviewer
  flagged — the mnemonic can't sit on the heap for an hour just because
  one sign path skipped the explicit lock. lockCurrentWallet() cancels
  the timer. (4.2)

- RealUnitRegistrationService.completeRegistration / registerWallet now
  use try/finally so the explicit lock fires on the throw path too.
Issue RealUnitCH#468 follow-ups RealUnitCH#461 — items 5.2 and 5.3.

- $SoftwareViewWallet: assert every sign entry point (signMessage,
  signToSignature, signPersonalMessage, signPersonalMessageToUint8List,
  signToEcSignature) surfaces as an Error subtype — AssertionError in
  debug, StateError in release — so a caller that bypasses
  WalletService.ensureCurrentWalletUnlocked() can't quietly read a wrong-
  type signature.

- $WalletService.ensureCurrentWalletUnlocked: promotes a view wallet to
  a SoftwareWallet via getUnlockedWalletById, no-op for already-unlocked
  wallets.

- $WalletService.lockCurrentWallet: writes back a SoftwareViewWallet,
  no-op for non-software wallets.

- $BitboxCredentials race tests:
  - disconnect-mid-sign: manager.devices returns empty during sign →
    BitboxNotConnectedException (not the underlying Exception leaking).
  - cleared credentials: a sign called after clearBitbox() throws
    BitboxNotConnectedException — exercises the snapshot-on-entry pattern
    so a NoSuchMethodError can't slip through the bang-operator path.
Senior review follow-ups on PR RealUnitCH#472 — four small correctness fixes
around the post-unlock locking contract introduced in RealUnitCH#461.

- DFXAuthService.getSignature now wraps the sign + saveSignature in a
  try/finally so a thrown SigningCancelledException / TimeoutException /
  BitBox disconnect / cache-write failure can't skip lockCurrentWallet()
  and leave the mnemonic resident for the full 60 s window. Matches the
  pattern already used in RealUnitSellPaymentInfoService.confirmPayment
  and RealUnitRegistrationService.completeRegistration / registerWallet.

- WalletService._scheduleIdleLock callback no longer double-nulls the
  timer field. lockCurrentWallet() owns the cancel + null itself, so the
  callback now binds directly to the bound method (Timer(timeout, lock))
  and the two pieces of code stop sharing the invariant.

- WalletService._idleLockTimeout / _idleLockTimer renamed to
  _postUnlockLockTimeout / _postUnlockLockTimer. The timer isn't reset
  on user activity — it caps the lifetime of the in-memory mnemonic
  after each explicit unlock — so "idle" was the wrong noun. Doc comment
  updated to say so.

- BitboxService._credentialsByAddress now goes through a private _key()
  normaliser (lowercase) so a future EIP-55-vs-raw-hex address handoff
  can't fork the map and leak / orphan credentials. Lowercase invariant
  documented at the field declaration.
…removed the class

PR RealUnitCH#478 landed an exception surface test that enumerates every typed
exception. PR RealUnitCH#472 removed WalletLockedException in favour of
assert + StateError (locked-sign is now a programmer error, not a
runtime exception). Remove the obsolete entry + import so the test
compiles against the post-RealUnitCH#472 surface.
…#472 map

PR RealUnitCH#473 pinned the pre-RealUnitCH#472 contract: each getCredentials() returned a
fresh instance. PR RealUnitCH#472's robustness pass replaced the single
_credentials slot with a Map<address, BitboxCredentials> + a per-entry
clear loop in the disconnect observer. Under the new contract every
caller for a given address must see the *same* canonical instance —
otherwise the observer would clear an orphaned reference while the
caller keeps holding the older one. Flip the assertion + rewrite the
defensive-pin comment to match.
@TaprootFreak TaprootFreak force-pushed the fix/pr461-followups branch from 45720cf to a07d7e0 Compare May 21, 2026 06:30
Blume1977 added 6 commits May 21, 2026 09:04
…can't trip MnemonicReadOnlyField's length assert

RealUnitCH#472 left SettingsSeedView wide open to a first-frame crash whenever the
app boots with a SoftwareViewWallet — the default production state after
RealUnitCH#461. The cubit's initial state has an empty seed, the view used to
hand that straight to SeedBlurCard → MnemonicReadOnlyField([]) and the
`length == 12` assert killed the screen on every "Wallet sichern" open
in debug, with a RangeError taking its place in release.

The existing settings_seed_page_test couldn't catch it because the
mocked appStore.wallet was a SoftwareWallet, not a SoftwareViewWallet —
the seed was already in the initial state and the path that crashes in
production never ran.

Show a CupertinoActivityIndicator inside the BlocBuilder until the seed
actually has 12 words. The early-seed optimisation for the
already-unlocked case stays intact (state.seed is filled in the
constructor), so re-opening the screen after one successful unlock
still renders the SeedBlurCard on frame 0.

Regression test added: drives a real SettingsSeedCubit against a
SoftwareViewWallet fixture, holds the unlock future on a Completer,
asserts the first frame shows the spinner with no SeedBlurCard, then
completes the unlock and asserts the SeedBlurCard appears.
…nlock await

`_loadSeed` awaits `ensureCurrentWalletUnlocked` and then casts +
emits. If the user navigates away while the DB decrypt is in flight
the cubit closes mid-await — the post-await emit then throws
`StateError: Cannot emit after close` as an unhandled async error.

Add an `if (isClosed) return;` between the await and the cast so the
cubit closing during the unlock window is a quiet no-op instead of an
uncaught error.
…lows can't tear the unlocked wallet out from under each other

RealUnitCH#472's ensure-then-lock pair was racey for two concurrent sign flows:
flow A unlocks → signs → finally → locks; flow B between A's ensure and
A's lock saw no-op (already unlocked) and proceeded mid-sign. A then
locks, B reads `appStore.wallet.currentAccount.primaryAddress` →
`_LockedCredentials` → `Eip712Signer` throws `UnsupportedError` because
the locked sentinel has no signing surface.

Add an `_activeUnlockHolders` counter: every ensure increments, every
lock decrements and only flips the wallet back when the count hits 0.
The 60s safety net can't respect the counter — a stuck holder would
keep the mnemonic resident past the window — so it now runs through a
dedicated `_forceLock` that zeroes the counter and unconditionally
locks. The explicit `lockCurrentWallet` path still owns the holder
contract.

Test: two parallel ensures + one lock leaves the wallet unlocked
(second holder still active); the second lock flips it back to a
SoftwareViewWallet.
…oss paths can't race two disconnect() calls

The periodic callback's body is async and straddles the
`getAllUsbDevices()` await. On a slow probe two ticks can both reach
the `devices.isEmpty` branch, both run `_pendingDisconnect =
_disconnectAndForget()`, and the second assignment clobbers the first
— now two `disconnect()` calls race on the SDK manager singleton with
nothing left to await on.

Guard the branch with `if (!_isConnected) return;` *before* flipping
`_isConnected = false`. The first tick's flag flip is the single-writer
fence: any second tick that arrived between the two ticks sees the
already-cleared flag and bails before touching credentials, the
observer cancel, or _pendingDisconnect.
…oarding flows log at the same SEVERE level

Three near-duplicate `_warmAuthSignature` helpers existed in
CreateWalletCubit, RestoreWalletCubit and ConnectBitboxCubit. Only the
BitBox one logged at `level: 1000` (SEVERE) — the other two used
default-level developer.log, which doesn't reach support telemetry.
PR body claimed SEVERE everywhere; only one of three actually was.

Move the helper to a top-level `warmAuthSignature(authService,
account, {required loggerName})` in dfx_auth_service.dart. All three
cubits call it; all log at SEVERE; the BitBox-specific copy in the
"next call triggers a fresh confirmation" wording is generalised to
"next authenticated call will trigger a fresh signature request".

create_wallet_page_test: the cubit now reads `wallet.currentAccount`
synchronously before handing it to the unawaited helper, so the
MockWallet has to surface a real account. Stub it (was: relied on the
old helper's try/catch swallowing the unstubbed-null cast).
…amed the method to ensureCurrentWalletUnlocked

Three callsites in the test tree still referenced the old name:
- settings_seed_cubit_test L33 test name + L36 comment
- real_unit_sell_payment_info_service_confirm_test L110 comment

Rename so a future reader searching for the actual method finds the
test, not the drift.
Blume1977 added a commit to Blume1977/realunit-app that referenced this pull request May 21, 2026
Stacked on top of RealUnitCH#472.

The 60 s post-unlock timer in WalletService is a best-effort safety net —
iOS suspends Dart timers in the background, so a backgrounded app could
otherwise keep an unlocked SoftwareWallet (mnemonic in memory) resident
until the OS kills the process. That's exactly the scenario the reviewer
flagged in RealUnitCH#468 (4.2) as the residual gap after the explicit
lockCurrentWallet() pattern landed.

LifecycleInitializer._onHidden now fires WalletService.lockCurrentWallet()
alongside the existing PinAuthCubit.onAppHidden(), so the mnemonic is
dropped as soon as the user covers the app — before iOS suspends the
isolate. The next sign re-decrypts the mnemonic via the OS-keystore-
wrapped key in sub-100 ms, invisible to the user.

Hidden is the right hook: it fires earlier than paused on iOS (covers the
multitasking-switcher and notification-drag-down cases), Android raises
it via the unified lifecycle pipeline, and PinAuthCubit already uses it
for the lockout-time stamp.
TaprootFreak added a commit that referenced this pull request May 21, 2026
…light dedup (#483)

> Stacked on #472 — apply only after that merges, or rebase onto develop
post-merge.
>
> **Base note:** GitHub couldn't host this PR with `base =
fix/pr461-followups` because that branch only exists on Blume1977's fork
(not on DFXswiss). So the base falls back to `develop` and the diff
therefore includes everything from #472 plus the polish delta below.
Once #472 merges, this PR will show only the polish delta.

This collects the iteration-4 audit's "nice-to-have" polish items on top
of #472 (`fix/pr461-followups`). The 4 polish items are each their own
commit and live on top of `7ef2224` (current tip of #472's branch).

## Polish items

### 1. Real reentrancy race test
`test/packages/service/wallet_service_test.dart`

The existing reentrancy test sequentially `await`s both
`ensureCurrentWalletUnlocked()` calls — that exercises the counter, not
a true race. Added a `Completer<WalletInfo>`-gated test that holds two
`ensureCurrentWalletUnlocked()` calls mid-decrypt, fires a
`lockCurrentWallet()` between them, then completes the gate. Asserts the
wallet stays unlocked (lock did not shadow the in-flight unlock) and
locks back only after the remaining holders are drained. The earlier
mechanical test stays in place — they're complementary.

### 2. DRY the `_LockedCredentials` sign-method rationale
`test/packages/wallet/wallet_test.dart`

Five identical ~4-line comments explaining the `assert(false)` +
`StateError` contract were extracted into a top-level
`_viewWalletErrorRationale` constant. Each test now passes that string
as the `reason:` argument (first occurrence) or references the constant
by name in a one-line comment. No test bodies changed.

### 3. Visibility consistency: colocate `SoftwareViewWalletAccount` with
`_LockedCredentials`
`lib/packages/wallet/wallet.dart` +
`lib/packages/wallet/wallet_account.dart`

`DebugWallet`'s analogs (`_DebugCredentials` + `DebugWalletAccount`)
live in the same file (`wallet.dart`). The view-wallet analogs were
split — `_LockedCredentials` in `wallet.dart`,
`SoftwareViewWalletAccount` in `wallet_account.dart`. Moved
`SoftwareViewWalletAccount` next to `_LockedCredentials` in
`wallet.dart` to match the existing pattern. Safe because
`SoftwareViewWalletAccount` is only referenced from `wallet.dart`
(production) and one test file that already imports both files. No
import changes needed elsewhere.

### 4. Track in-flight unlock to dedupe DB decryption
`lib/packages/service/wallet_service.dart`

Two overlapping `ensureCurrentWalletUnlocked()` calls on a
`SoftwareViewWallet` previously each triggered `unlockCurrentWallet()`
(DB read + AES-GCM decrypt). Functionally identical results, but
wasteful. Added a `Future<SoftwareWallet>? _unlockInFlight` field; the
first caller starts the unlock and stores the future, subsequent
overlapping callers await the same future. Cleared in `finally` (only by
the caller that started it, via `identical` check) so the next post-lock
ensure runs a fresh unlock. New test verifies that two parallel ensures
only invoke `repository.getUnlockedWalletById` once.

## Test plan

- [x] `flutter analyze` — only pre-existing `lib/generated/` issues
(33), no new lint warnings
- [x] `flutter test --coverage` — 1412 tests pass (1410 baseline + 2 new
race / dedup tests)
- [ ] Manual smoke: trigger a sign flow, lock, sign again — confirm no
`StateError` from `_LockedCredentials` and the in-memory mnemonic still
drops on the 60s safety net

---------

Co-authored-by: Blume1977 <jana.ruettimann@dfx.swiss>
@TaprootFreak
Copy link
Copy Markdown
Contributor

subsumed by #483

TaprootFreak pushed a commit that referenced this pull request May 21, 2026
Supersedes Blume1977#1 (closed) — same intent, rebased onto
post-#483 develop, addresses TaprootFreak's two review points by pushing
the precondition into `WalletService` instead of catching it at the call
site.

## Why

PR #472 / #483 added an explicit `WalletService.lockCurrentWallet()`
after every sign and a 60 s post-unlock safety-net timer. Reviewer
(#468) flagged the residual gap in the second-pass review:

> 🟡 iOS suspendiert Dart-`Timer`s im Background — 60s-Cap ist
Best-Effort. App-Lifecycle ruft `lockCurrentWallet` nicht explizit auf
Hide auf — könnte ein eigenes Follow-up-Issue werden.

This closes that gap.

## What changed

Three atomic commits:

1. **`feat(app-store): isWalletLoaded getter`** — non-throwing predicate
so services can early-return defensively. Named distinctly from
`WalletService.hasWallet()` (which is persisted state, not in-memory
load state) so the two predicates can't be confused at the call site.

2. **`fix(wallet-service): no-op lockCurrentWallet when no wallet is
loaded`** — Onboarding-path guard inside `WalletService` itself. An
alternative would have been a `try/catch` at the call site, which would
silently mask any future regression in `lockCurrentWallet`. Pushing the
precondition into the service means the call site stays clean and future
extensions (DB write, audit log) won't get their errors quietly caught.
Two regression tests added: the `!isWalletLoaded` no-op, and a
lifecycle-hidden-during-active-sign scenario that pins the
counter-underflow guard.

3. **`feat(lifecycle): lock the wallet on hidden`** —
`LifecycleInitializer._onHidden()` fires
`unawaited(getIt<WalletService>().lockCurrentWallet())`. No try/catch,
no helper method — the service is defensive on its own. Tests cover
happy path + the two no-call states (paused/resumed).

## Why `hidden` (not `paused`)

- Fires **earlier than `paused`** on iOS (covers multitasking switcher +
notification drag-down).
- Android raises it via the unified lifecycle pipeline.
- `PinAuthCubit` already uses `_onHidden` for its lockout-time stamp —
semantically aligned.

## Scope gap (deliberately deferred)

This PR drops the mnemonic stored in `AppStore.wallet`. It does **not**
clear the mnemonic temporarily held in `CreateWalletState.wallet` (a
`Cubit` state inside the create-wallet flow) — that copy lives
independent of `AppStore` and `lockCurrentWallet` is a no-op for it.
Backgrounding during onboarding still leaves the just-generated mnemonic
in cubit memory until the cubit closes. Pre-existing condition, not
introduced here. Worth a follow-up if the threat model demands it.

## Why no test for "lifecycle doesn't `catchError` the unawaited future"

The architecture decision — _the lifecycle caller deliberately does not
attach `.catchError` or wrap in try/catch_ — is locked in by the inline
comment in `_onHidden`, not by a test. Every variant I tried
(`thenThrow`, `thenAnswer((_) async => throw …)`, `Future.error(...)`)
routed the failure through the testWidgets framework's synchronous catch
path rather than the Zone uncaught-error sink that
`tester.takeException()` reads from. The routing differs between Flutter
3.41 (CI) and 3.44 (local), so a test that's green locally would flake
on CI. Brittle false-positives are worse than the source comment for
catching a future regression at the call site — every code reviewer
reads `_onHidden`.

## Reviewer feedback (TaprootFreak)

Iteration after the first review:

- **Naming-Kollision (Blocker 1)** — renamed `hasWallet` to
`isWalletLoaded` on `AppStore` to avoid colliding with
`WalletService.hasWallet()` which has different semantics.
- **Holder-Counter test gap (Blocker 2)** — added a test that simulates
`ensure → unpaired lifecycle lock → finally lock`, verifying the
underflow guard prevents counter drift and the next ensure cycle still
works.
- **Inline comment too long (R3)** — condensed from 15 lines to 7;
rationale moved to commit message + this PR body.
- **Microtask ordering note (R4)** — added a one-line note in the
lifecycle hook for any future contributor who introduces
`ensureCurrentWalletUnlocked()` in `_onResumed`.
- **Test setup drift (R5)** — `isWalletLoaded = true` moved out of the
global `setUp` into per-group `setUp` so unrelated groups don't carry
the stub and the negative path isn't masked.
- **Doc accuracy (R7)** — clarified that both `LoadCurrentWalletEvent`
and `LoadWalletEvent` populate `AppStore.wallet`.

Why the architecture diverges from the original draft (Blume1977#1):
previous incarnation wrapped `lockCurrentWallet()` in a `try { } on
Exception catch (e)` helper in the lifecycle file. Reviewer pushed back
that this would silently swallow future regressions (e.g. DB-write
failures from a logging extension). Single Responsibility: the "is
wallet loaded" precondition belongs to `WalletService`, not its callers.

## UX impact

None visible. The next sign re-decrypts the mnemonic via the
OS-keystore-wrapped key in sub-100 ms.

## Test plan

- [x] \`flutter analyze\` — clean
- [x] \`flutter test --coverage\` — 1424 / 1424 green (1417 develop
baseline + 7 new: 2 isWalletLoaded, 1 no-wallet lock, 1 unpaired-lock
counter race, 3 lifecycle states)
- [x] Local CI parity run: \`flutter pub get\` → \`dart run
tool/generate_localization.dart\` → \`dart run
tool/generate_release_info.dart\` → \`flutter pub run build_runner
build\` → \`flutter analyze\` → \`flutter test --coverage\` → \`lcov\`
filter — all green; \`bitbox-audit\` 0 findings
- [ ] **iOS manual**: sell → swipe up to multitasking → return → next
sign re-decrypts in <100 ms, no PIN re-prompt within PinAuthCubit's
lockoutDuration
- [ ] **iOS manual**: sell → home button → wait 10 min → return → PIN
re-prompt as before (unchanged)
- [ ] **Android manual**: sell → recent-apps switcher → return → same as
iOS path 1
- [ ] **Onboarding manual**: fresh install → reach onboarding screen →
background app → return → no crash, no unhandled async error
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.

Follow-ups from PR #461: BitBox disconnect recovery + lazy unlock

2 participants