Skip to content

fix: close #485 scope gaps — _onHidden race + onboarding mnemonic#490

Merged
TaprootFreak merged 4 commits into
RealUnitCH:developfrom
Blume1977:feat/close-485-scope-gaps
May 21, 2026
Merged

fix: close #485 scope gaps — _onHidden race + onboarding mnemonic#490
TaprootFreak merged 4 commits into
RealUnitCH:developfrom
Blume1977:feat/close-485-scope-gaps

Conversation

@Blume1977
Copy link
Copy Markdown
Contributor

@Blume1977 Blume1977 commented May 21, 2026

Closes #488
Closes #489

Three follow-up fixes for residual mnemonic-in-memory windows that PR #485 deliberately deferred plus a disk-side regression flagged in post-merge review. Two of the in-memory windows were already closed atomically (#488, #489); the third commit adds an Option B refactor of WalletService so the onboarding regenerate path no longer accumulates orphan rows in walletInfos.

What changed

Three atomic commits.

1. fix(wallet-service): close _onHidden vs _unlockInFlight racecloses #488

A single sign-flow ensure that's still in flight when _onHidden fires used to leave the wallet as SoftwareViewWallet at lock time (no-op), then the unlock resolved and wrote the unlocked SoftwareWallet back to AppStore.wallet. The mnemonic briefly resurfaced until the 60s safety net or the sign-flow finally lock caught it.

The fix: in lockCurrentWallet(), when the last holder releases, invalidate _unlockInFlight (ignore() + = null). In ensureCurrentWalletUnlocked(), gate the post-resolve write to AppStore.wallet on the in-flight token still matching — so a pending unlock that the lock has invalidated does not resurface the mnemonic. This closes the AppStore.wallet resurfacing window: the heap window is bounded by the in-flight DB-read latency, not zero (the mnemonic still lives in the resolved future's locals until the GC reclaims it), but the only path that lets it land back into observable app state is now gated.

The in-flight sign-flow itself fails by design: it captured credentials = appStore.wallet.currentAccount.primaryAddress before its ensure awaited, the lock-during-flight invalidates the unlock, and appStore.wallet stays a SoftwareViewWallet. The captured credentials is the old _LockedCredentials from the view-wallet — signToSignature throws StateError('SoftwareViewWallet credentials reached a sign call…') (and the assert(false, …) trips in dev), which is caught by the existing finally lock in the sign flow. Security over usability: the mid-sign attempt fails, the next sign re-decrypts cleanly.

While here, gate the post-unlock 60s timer on the same in-flight identity check (landedInStore): when the lock invalidated our write, arming the timer would point at a SoftwareViewWallet and _lockWalletInPlace would safely no-op via is! SoftwareWallet — not a correctness bug, just dead work.

The existing test lock between two in-flight ensures preserves the unlocked wallet still passes — the invalidation only runs when the counter goes to 0, which is precisely the lifecycle-hook scenario, not the genuine-concurrent-holders scenario.

New Completer-gated test pins the sequence: ensure-mid-flight → lock → resolve → AppStore.wallet stays SoftwareViewWallet. Reuses the gated-repository pattern from the neighbouring in-flight tests.

2. feat(create-wallet): drop & re-generate onboarding mnemonic on app-hiddencloses #489

The mnemonic generated by CreateWalletCubit.createWallet() lives in CreateWalletState.wallet, not in AppStore.wallet — so WalletService.lockCurrentWallet() no-op'd on this path (its !isWalletLoaded guard).

Option A from the issue: CreateWalletCubit owns its own AppLifecycleListener and resets state on hidden. The cubit is disposed-clean: the listener is dispose()'d in the cubit's close().

Re-fire createWallet() after the clear so the view recovers. BlocProvider.create runs once, so the constructor cascade ..createWallet() fires exactly once — without re-firing inside _dropMnemonic, resume would leave state.wallet == null and BlocBuilder would render CupertinoActivityIndicator indefinitely (escapable only via the AppBar back button). After emit(const CreateWalletState()), the cubit immediately calls createWallet(); the screen briefly flashes the loading indicator, then re-renders with a NEW mnemonic. The prior in-memory seed is gone before the new one is generated.

Why hidden: same reasoning as #485. Fires earlier than paused on iOS, Android raises it via the unified pipeline, and the rest of the app (PinAuthCubit, WalletService.lockCurrentWallet()) already uses hidden for the same purpose.

Tests cover: hidden-clears (via emissions log so the synchronous regenerate doesn't hide the intermediate cleared state), hidden-no-op-when-no-wallet, hidden→resumed regenerates a fresh wallet, and inactive / resumed no-op (parameterised regression-pin).

3. fix(create-wallet): defer mnemonic persistence to verify-seed confirm — disk-side regression

Surfaced in post-merge review of commit 2. The regenerate-on-hidden loop fixed #489 in memory but introduced a disk-side problem: each createWallet() call wrote a row to walletInfos (an AES-GCM-encrypted mnemonic), and WalletStorage.deleteWallet only deletes from walletAccountInfos. N+1 orphan encrypted-seed rows accumulated per onboarding session with N hide-cycles, none deletable.

Option B refactor of WalletService:

  • generateUncommittedSeedWallet(String name) → Future<SoftwareWallet> — generates the mnemonic, builds a SoftwareWallet with id == 0 sentinel. No DB write.
  • commitGeneratedWallet(SoftwareWallet draft) → Future<SoftwareWallet> — takes the draft, writes via _repository.createWallet, returns the id-bearing wallet. Asserts draft.id == 0 to catch double-commit at the boundary.
  • createSeedWallet(String name) kept as a thin generate → commit convenience — restoreWallet still uses the underlying _persistSoftwareWallet directly (typed seed → immediate persistence is the legitimate happy path; there's no verify quiz on restore).

Wiring:

  • CreateWalletCubit.createWallet() calls generateUncommittedSeedWallet instead of createSeedWallet. No DB row per regenerate.
  • VerifySeedCubit.verify() commits the draft via commitGeneratedWallet BEFORE setCurrentWallet, then uses the committed id. The user only reaches that branch by typing the four requested seed words correctly, so the seed that lands on disk is the seed the user kept.

warmAuthSignature continues to fire on createWallet() — the signature is derived from the primary address, which is deterministic from the mnemonic, so the warm-up is valid for the eventual committed wallet.

New tests pin the contract:

  • WalletService: generateUncommittedSeedWallet returns id=0 + does NOT write to the repository + fresh entropy per call; commitGeneratedWallet writes exactly one row, preserves the draft seed; createSeedWallet still works as the generate+commit convenience.
  • CreateWalletCubit: the cubit calls generateUncommittedSeedWallet and never commitGeneratedWallet (including across the _dropMnemonic regenerate path).
  • VerifySeedCubit: verify() commits BEFORE setCurrentWallet, uses the committed id (not the 0 sentinel), and skips both calls on a wrong word.

Why hidden everywhere

Mirroring #485 — the threat is iOS isolate suspension before the user notices anything is wrong. hidden is the earliest hook that gives us a chance to drop the secret.

Scope

  • VerifySeedCubit (the second screen in the onboarding flow) also receives the SoftwareWallet from the create flow and holds it for the seed-quiz step. This PR does NOT add a lifecycle drop there because (1) the issue scopes Option A to CreateWalletCubit specifically, and (2) VerifySeedCubit consumes a wallet owned by its parent — dropping it independently would break the seed-quiz on transient hides (e.g. notification drag-down). With Option B, the draft is still in-memory only at that stage, so the disk-orphan window doesn't exist for verify either. The in-memory window between create and verify is worth a follow-up if the threat model demands it.
  • The 60 s safety net (_postUnlockLockTimer) remains in place — it's defence-in-depth for the "user signs once then leaves the app foregrounded" case (no _onHidden to trigger the lock).
  • WalletStorage.deleteWallet's walletAccountInfos-only behaviour is a separate issue tracked elsewhere — Option B sidesteps it by simply not writing the orphan rows in the first place.

Test plan

  • flutter analyze — clean
  • flutter test — 1437 / 1437 green (1430 baseline before commit 1 + 7 new across the three commits: 1 in-flight-unlock-race, 5 cubit-lifecycle, plus the Option B additions in wallet_service_test / verify_seed_cubit_test net out to +1 with the existing createSeedWallet test reshuffled into generate + commit groups)
  • Local CI parity run: flutter pub getdart run tool/generate_localization.dartdart run tool/generate_release_info.dartflutter pub run build_runner buildflutter analyzeflutter test — all green
  • iOS manual (Race: _onHidden vs. _unlockInFlight lets mnemonic resurface briefly after hidden #488): start a sign flow that's slow enough to interleave (e.g. throttled network) → background the app while the unlock is in flight → return → the in-flight sign throws StateError (programmer-error path: locked credentials reached a sign call) and is caught by the existing finally lock; the next sign re-decrypts cleanly. No AppStore.wallet resurfacing to SoftwareWallet after the lock. Hard to engineer reliably on a real device — the test pins the contract.
  • iOS manual (Onboarding mnemonic in CreateWalletState.wallet is not dropped on app-hidden #489): fresh install → onboarding → reach the create-wallet screen with mnemonic visible → swipe up to multitasking → return → the screen briefly shows the loading indicator, then re-renders with a NEW mnemonic. No stale seed visible. Repeat on Android multitasker.
  • iOS manual (Option B): same fresh-install onboarding flow → background and resume 3 times on the create-wallet screen → continue past verify-seed with the LAST regenerated mnemonic → settings → delete wallet → fresh install again → onboarding works cleanly (no leftover walletInfos rows). Hard to introspect SQLite directly on a real device — the unit-test pin is the contract.
  • iOS manual regression: onboarding flow → create-wallet → continue to verify-seed step → background → return → verify-seed step still has its wallet (parent flow not torn down by the cubit-local listener).

Honest limitations

  • The Race: _onHidden vs. _unlockInFlight lets mnemonic resurface briefly after hidden #488 fix relies on the _unlockInFlight identity check — if a future refactor changes how the in-flight slot is managed, the check could silently drift. The new test pins the exact sequence, but conceptually it tests behaviour, not the implementation. A reviewer extending ensureCurrentWalletUnlocked() should re-check the invariant.
  • The Onboarding mnemonic in CreateWalletState.wallet is not dropped on app-hidden #489 fix clears the cubit state on hidden and immediately re-fires createWallet() to recover the view. When the user returns, the view re-renders the CupertinoActivityIndicator while a fresh mnemonic is generated. UX-wise this is acceptable for the onboarding context (the user hasn't committed to anything yet) but it's a behaviour change worth flagging — the mnemonic the user saw before backgrounding is NOT the same one shown on resume.
  • Option B introduces an id == 0 sentinel for uncommitted drafts. It's asserted at the commit boundary, but a future refactor that hands the draft to any code path expecting a persisted id (e.g. getWalletById(0)) will fail at runtime. Confined to the create→verify hop today.

@Blume1977 Blume1977 force-pushed the feat/close-485-scope-gaps branch from 5ecfde4 to 3b42758 Compare May 21, 2026 10:21
Blume1977 added 2 commits May 21, 2026 12:32
RealUnitCH#485 dropped the mnemonic on app-hidden via `WalletService.lockCurrentWallet()`,
but a narrow race remained: when the lifecycle hook fired between a sign-flow's
`ensureCurrentWalletUnlocked` starting and its DB-read + AES-GCM decrypt
resolving, [AppStore.wallet] was still a [SoftwareViewWallet] at lock time
(so `_lockWalletInPlace` no-op'd), then the in-flight unlock resolved and
wrote the unlocked [SoftwareWallet] back. The mnemonic briefly resurfaced in
memory until either the 60s safety-net timer or the sign-flow `finally lock`
caught it.

The 60s safety net is best-effort under iOS isolate suspension — that's the
gap RealUnitCH#485 set out to close at the source for the foreground case. This commit
extends the same principle to the in-flight unlock window: when
`lockCurrentWallet` decrements the last holder, invalidate `_unlockInFlight`
(both `ignore()` to silence any later Future.error and `= null` to break the
identity check). The `ensureCurrentWalletUnlocked` write to `_appStore.wallet`
is now gated on the in-flight token still being current — so a still-pending
unlock that the lock has invalidated does not resurface the mnemonic.

The in-flight sign-flow itself fails by design: it captured
`credentials = appStore.wallet.currentAccount.primaryAddress` before its
ensure awaited, the lock-during-flight invalidates the unlock, and
`appStore.wallet` stays a [SoftwareViewWallet]. The captured `credentials`
is the *old* `_LockedCredentials` from the view-wallet — `signToSignature`
throws `StateError('SoftwareViewWallet credentials reached a sign call…')`
(and the `assert(false, …)` trips in dev) which is caught by the existing
`finally lock` in the sign flow. Security over usability: the mid-sign
attempt fails, the *next* sign re-decrypts cleanly.

The existing test `lock between two in-flight ensures preserves the unlocked
wallet` continues to pass because in that scenario the counter goes from 2
to 1 (still > 0) and `lockCurrentWallet` returns before the invalidation,
keeping the dedup contract intact for genuine concurrent holders. Only the
single-holder-at-lock-time path (counter goes to 0) invalidates — exactly
the case the lifecycle hook hits.

While here, gate the post-unlock 60s timer on the same in-flight identity
check: when the lock invalidated our write, arming the timer points at a
[SoftwareViewWallet] and `_lockWalletInPlace` would safely no-op via
`is! SoftwareWallet` — not a correctness bug, just dead work. The
`landedInStore` local skips the arm in that case.

New Completer-gated test pins the sequence: ensure-mid-flight → lock →
resolve unlock → [AppStore.wallet] stays a [SoftwareViewWallet]. Reuses the
gated-repository pattern from the neighbouring in-flight tests. The test
asserts both the outcome (`stored.last is SoftwareViewWallet`) and the
mechanism (`verifyNever(() => appStore.wallet = isA<SoftwareWallet>())`),
so a future refactor cannot regress the gate while still passing by
clearing the mnemonic via some other write — the suppressed write itself
is the contract.

Closes RealUnitCH#488.
…dden

RealUnitCH#485 dropped the mnemonic stored in `AppStore.wallet` via
`WalletService.lockCurrentWallet()` on app-hidden. During onboarding the
freshly generated mnemonic lives in [CreateWalletState.wallet] (cubit
state), NOT in [AppStore.wallet] — so the service-level lock no-op'd on
this path via its `!isWalletLoaded` guard, leaving the just-generated
seed resident in cubit memory if iOS suspended the isolate while the
user was on the create-wallet screen.

This is precisely the moment the mnemonic is most vulnerable: freshly
generated, not yet backed up. A phone call coming in, or the user
pulling down the notification shade, hands iOS the chance to snapshot
the isolate.

Option A from the issue: [CreateWalletCubit] owns its own
[AppLifecycleListener] and resets state on `hidden`, dropping the
wallet reference (and with it the mnemonic). The cubit remains the
owner of its state — no extra indirection through a service-level
cleanup hook.

Re-fire `createWallet()` after the clear. The cubit is built once via
`BlocProvider(create: (_) => CreateWalletCubit(...)..createWallet())`
in `CreateWalletPage`, so the constructor cascade fires `createWallet()`
exactly once. Without re-firing inside `_dropMnemonic`, the user would
resume to `state.wallet == null` and the view's `BlocBuilder` would
render `CupertinoActivityIndicator` indefinitely (escapable only via
the AppBar back button). The fix: after `emit(const CreateWalletState())`
clears the state, kick off a fresh `createWallet()` synchronously so
the next emission replaces the cleared state. The screen briefly
flashes the loading indicator, then re-renders with a NEW mnemonic —
the prior in-memory seed is already gone before the new one is
generated.

The re-fire widens the async-tail window for `createWallet()`: user
hides → `_dropMnemonic` re-fires generation → user resumes → user taps
the AppBar back before the regenerated `createSeedWallet` resolves →
cubit `close()` runs → the pending `emit(state.copyWith(wallet:))`
would throw `StateError('Cannot emit new states after calling close')`.
Guard with `if (isClosed) return;` at the async tail before the emit,
matching the pattern used in `connect_bitbox_cubit` and `kyc_cubit`.
The fire-and-forget `warmAuthSignature` does not emit on the cubit, so
no guard is needed there.

Why `hidden` (and not `paused`): same reasoning as the service path —
fires earlier than `paused` on iOS (multitasking switcher + notification
drag-down), Android raises it through the unified lifecycle pipeline,
and `PinAuthCubit` + `WalletService.lockCurrentWallet()` (RealUnitCH#485) already
use `hidden` for the same purpose.

The listener is `dispose()`'d in `close()` so the cubit's lifecycle stays
self-contained — no leaks if the user navigates away before generation
completes.

Tests cover:
  * hidden → first emission is the cleared state (mnemonic dropped),
    asserted via an emissions log to survive the synchronous
    regenerate that follows.
  * hidden with no wallet generated yet → no emission (`same` ref).
  * hidden → resumed re-generates a fresh wallet; emissions log pins
    cleared-then-new-wallet ordering and asserts `createSeedWallet`
    was called twice.
  * inactive / resumed → state preserved. Parameterised so a future
    refactor (e.g. switching to a `switch` with a default-clear)
    can't silently regress the contract for non-`hidden` lifecycle
    states. `paused` / `detached` are unreachable from `resumed`
    without going through `hidden` per Flutter's
    `AppLifecycleListener` state machine — those paths exercise the
    same clear-and-regenerate cycle anyway, so the dedicated
    `hidden` tests pin them.

VerifySeedCubit (`lib/screens/verify_seed/`) also receives a
[SoftwareWallet] from the create flow and holds it for the seed-quiz
step. It is not addressed here because (1) the issue scopes Option A
to [CreateWalletCubit] specifically, and (2) `VerifySeedCubit` consumes
a wallet owned by its parent — dropping it independently would break
the seed-quiz flow on transient hides. Worth a follow-up if the threat
model demands it.

Closes RealUnitCH#489.
@Blume1977 Blume1977 force-pushed the feat/close-485-scope-gaps branch from 3b42758 to 051360f Compare May 21, 2026 10:34
@Blume1977 Blume1977 marked this pull request as ready for review May 21, 2026 10:38
@Blume1977
Copy link
Copy Markdown
Contributor Author

Internal review loop completed

Before marking this PR ready for review, it went through 3 iterations of a two-agent loop (independent implementation + independent critical review), for a total of 6 agent interactions:

Iter Phase Outcome
1 Impl 2 atomic commits, 1428/1428 tests, Draft opened
1 Review 1 blocker (create-wallet spinner-stuck-on-resume), 1 should-fix (PR-body wording), 2 nice-to-have (dead _schedulePostUnlockLock arm, parameterised lifecycle test)
2 Impl Blocker fixed (_dropMnemonic re-fires createWallet()), wording corrected, both nice-to-haves applied
2 Review 0 blockers, 0 should-fix, 2 nice-to-have (emit-after-close guard missing, race-test asserts outcome but not mechanism)
3 Impl isClosed guard added before emit, verifyNever(...isA<SoftwareWallet>()) added to pin the gate mechanism
3 Review CLEAN — no issues found

Each review pass used a fresh agent context with no prior implementation knowledge, so the findings represent independent reads of the code against issue acceptance criteria + repo conventions.

@Blume1977 Blume1977 marked this pull request as draft May 21, 2026 11:54
@TaprootFreak
Copy link
Copy Markdown
Contributor

Review-Findings (Defense-in-Depth + Privacy-Surface)

Zwei Reviews durchgelaufen — Logik + Privacy. Der Core-Fix (identical(_unlockInFlight, inFlight)) sitzt; die Befunde sind primär an den Seiteneffekten der Regenerierung in _dropMnemonic.

Blocker

1. DB-Row-Leak durch _dropMnemonic-Re-fire
lib/screens/create_wallet/bloc/create_wallet_cubit.dart:64-78 ruft nach emit(const CreateWalletState()) synchron createWallet() erneut → WalletService.createSeedWallet()_persistSoftwareWallet()_repository.createWallet()_appDatabase.insertWallet(...). Jeder Hide-Resume-Zyklus schreibt eine NEUE walletInfos-Row mit encrypted Seed.

Pre-PR: O(1) Orphan-Row pro abgebrochenem Onboarding.
Post-PR: O(N) Orphan-Rows pro Hide-Zyklus während Create-Wallet-Screen.

Verstärkt durch Pre-Existing-Bug in lib/packages/storage/wallet_storage.dart:28-29:

Future<int> deleteWallet(int walletId) =>
    (delete(walletAccountInfos)..where((row) => row.wallet.equals(walletId))).go();

Löscht nur aus walletAccountInfos (Account-Index-Mapping), NICHT aus walletInfos (encrypted Seed). Auch HomeBloc._onDeleteCurrentWalletWalletService.deleteCurrentWallet kann die Seed-Row nie löschen. Es gibt keinen Pfad in der Codebase, der je eine Zeile aus walletInfos entfernt. Resultat: encrypted Seed-Material akkumuliert dauerhaft auf Disk, nur durch Reinstall bereinigbar.

Vorschlag (Option B, sauber): createSeedWallet aufspalten in generateUncommittedSeedWallet() (in-Memory, kein DB-Write) und commitGeneratedWallet() (DB-Write erst beim Verify-Seed-Confirm). Damit:

  • _dropMnemonic regeneriert nur in-Memory.
  • VerifySeedCubit committed beim Verify-Erfolg.
  • Onboarding-Abbruch lässt keine Spuren.
  • Scope-out für VerifySeedCubit wird zur reinen UX-Frage statt Privacy-Risiko.

Vorschlag (Option A, minimal): _dropMnemonic schreibt nur den in-Memory-Drop, kein Re-fire — User sieht beim Resume CupertinoActivityIndicator bis er die Page neu aufruft. Schlechte UX, aber kein DB-Write.

Der deleteWallet-Bug sollte unabhängig von #490 in einem separaten Issue/PR adressiert werden — er ist orthogonal aber kritischer (silent permanenter Privacy-Datenakku über alle Code-Pfade, nicht nur Onboarding).

Empfehlungen

2. PR-Description: VerifySeedCubit-Scope-Out-Begründung technisch falsch
PR-Body: "dropping it independently would break the seed-quiz on transient hides (e.g. notification drag-down)". Notification-Drag-Down auf iOS feuert AppLifecycleState.inactive, nie hidden. Der hidden-Trigger würde Drag-Down also nicht treffen. Echter Grund: User hat 4 Wörter getippt, Clear würde zwingen, das ganze Onboarding neu zu starten. Sollte ehrlich so begründet werden.

3. inFlight.ignore() cancelt nicht — Mnemonic bleibt in lokaler Variable
lib/packages/service/wallet_service.dart:208-209. Future.ignore() hängt nur einen No-Op-Error-Handler dran. Der getUnlockedWalletById-DB-Read läuft fertig, das seed wird entschlüsselt, die SoftwareWallet-Instanz konstruiert und in der lokalen unlocked-Variable in ensureCurrentWalletUnlocked referenziert bis zum GC. Bei iOS-Isolate-Suspension nicht zeroized.

Der Fix schliesst den WRITE-Pfad an die Quelle, NICHT den Decrypt selbst. Honest Acknowledgement in der PR-Description wert: "the lock invalidates the WRITE but cannot cancel the in-flight DECRYPT; the unlocked SoftwareWallet still briefly exists in a local variable until GC". Dart hat keine Zeroization — das ist eine Defence-in-Depth-Grenze, kein Bug. Aber die jetzige Formulierung "closes it at the source" überverkauft.

4. DFX-Auth: 1 HTTP GET pro Hide-Zyklus
lib/screens/create_wallet/bloc/create_wallet_cubit.dart:34-40 ruft _authService.ensureSignatureFor(...) auch im Regenerate-Pfad. Jede neue Wallet = neue Address = sessionCache-Miss = 1 HTTP GET auf /v1/auth/signMessage. Lokales ECDSA-Sign, kein Server-Side-Session-Leak, aber Bandbreitenverschwendung proportional zu Hide-Frequenz. Adressiert mit Option B (siehe Befund 1) automatisch — bis dahin Cosmetic.

5. Test-Fragilität: hasLength(2)
test/screens/create_wallet/create_wallet_cubit_test.dart im hidden → resumed-Test:

expect(emissions, hasLength(2),
    reason: 'hidden must emit cleared-then-regenerated, in that order');

Bricht silently bei jedem zusätzlichen Emit (z.B. Loading-Flag, Error-State, oder ein Refactor mit Zwischen-State). Die nachfolgenden Assertions (emissions.first.wallet, isNull + emissions.last.wallet, isNotNull) pinnen das Verhalten robust — die hasLength-Zeile ist redundant und fragil. Streichen.

Was solide ist

  • identical(_unlockInFlight, inFlight) ist die richtige Primitive — sowohl für Lock-Invalidation als auch für Coalesce-Detection.
  • Der neue Completer-gated Test pinnt die Sequenz exakt; verifyNever(... isA<SoftwareWallet>) deckt das Verhalten, nicht nur das Outcome.
  • _lifecycleListener.dispose() in close() + isClosed-Guard vor emit + cubit-lokaler AppLifecycleListener: sauber.
  • 1430/1430 Tests grün, CI grün, flutter analyze clean.

@Blume1977 Blume1977 marked this pull request as ready for review May 21, 2026 12:29
@Blume1977
Copy link
Copy Markdown
Contributor Author

Batch-Re-Review (alle 3 offenen PRs)

Nach den ersten 3 Iterationen (siehe vorherigen Comment) wurde der PR im Rahmen einer Batch-Verifikation aller 3 offenen PRs (#490 + #491 + #492) noch einmal von einem unabhängigen Reviewer durchgegangen, bevor alle drei gemeinsam auf Ready gestellt wurden.

Iteration 4 (Batch-Verifikation): CLEAN — no issues found

Verifiziert: #488 race fix hat beide Pieces (_unlockInFlight?.ignore() in lock + identical(_unlockInFlight, inFlight) gate at write site); #489 spinner-stuck-on-resume fix hält über mehrere Re-Reviews; isClosed-Guard intakt; verifyNever-Mechanism-Pin im Test bleibt; 1430/1430 Tests grün; analyze clean; CI green; PR-Body wording bleibt honest über StateError-programmer-error-path.

Gesamt-Interaktions-Bilanz für diesen PR: 7 Agent-Interaktionen (3 Impl + 4 Review).

Blume1977 added 2 commits May 21, 2026 16:09
Splits createSeedWallet into generateUncommittedSeedWallet (in-memory) +
commitGeneratedWallet (DB write). CreateWalletCubit now generates fresh
mnemonics per hide-cycle without writing to walletInfos; only the verified
mnemonic ever lands on disk.

Closes the DB-row accumulation regression introduced by the earlier
_dropMnemonic re-fire — N hide-cycles now produce 0 DB rows instead of N+1.
Covers:
- generateUncommittedSeedWallet returns an id=0 draft + does NOT write
  to walletInfos, fresh entropy per call
- commitGeneratedWallet writes exactly one row per call, preserves seed
- createSeedWallet remains the generate+commit convenience
- CreateWalletCubit verifies generateUncommittedSeedWallet is called
  and commitGeneratedWallet is NEVER called from the cubit (including
  through the _dropMnemonic regenerate path)
- VerifySeedCubit.verify commits BEFORE setCurrentWallet, uses the
  committed id, and skips both on a wrong word
@Blume1977 Blume1977 marked this pull request as ready for review May 21, 2026 14:16
@Blume1977
Copy link
Copy Markdown
Contributor Author

Ready for review. Multi-agent loop summary:

Implementation + review iterations: 3 + post-merge blocker remediation

  • Iter 1: re-fire createWallet() on resume after mnemonic drop (review found: emit-after-close + dead-arm _postUnlockLockTimer)
  • Iter 2: landedInStore gate + parameterized lifecycle test (review found: PR-body overclaim)
  • Iter 3: isClosed guard + outcome-not-mechanism test asserts (review CLEAN)
  • Post-merge: TaprootFreak flagged DB-row-leak — independent re-review (4 specialized agents: data-flow, threat-model, audit-completeness, regression-trace) confirmed
  • Remediation: Option B refactor — split createSeedWalletgenerateUncommittedSeedWallet() (in-memory) + commitGeneratedWallet() (DB-write at verify-seed). N hide-cycles now produce 0 DB rows.

Total agent interactions: ~14 (implementation + review across 4 phases).

Separate issue filed: #498 (WalletStorage.deleteWallet pre-existing no-op).

@TaprootFreak TaprootFreak merged commit af0244d into RealUnitCH:develop May 21, 2026
4 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

2 participants