Skip to content

Prevent lockup state#421

Merged
bordalix merged 9 commits intomasterfrom
prevent-lockup-state
Mar 23, 2026
Merged

Prevent lockup state#421
bordalix merged 9 commits intomasterfrom
prevent-lockup-state

Conversation

@pietro909
Copy link
Contributor

@pietro909 pietro909 commented Mar 13, 2026

  • Separated wallet auth state from backend initialization so passwordless wallets no longer fall into Unlock when startup fails. Unlock is now shown only when credentials are actually required; passwordless wallets auto-boot in the background and stay on Loading if the backend is still unavailable.
  • Also added a one-shot recovery path: if passwordless auto-init fails, the app logs the error and triggers a single automatic reload after 1 second, guarded per session to avoid loops.
  • Added regression tests for locked vs passwordless startup routing and the single-reload behavior.

Summary by CodeRabbit

  • New Features

    • Passwordless auto-initialization with one-time retry and background unlocking.
    • Explicit unlock flow with improved unlocking feedback.
  • Bug Fixes

    • Prevents repeated passwordless retries and navigation loops.
    • More reliable startup routing between Loading, Unlock, and app screens.
  • Refactor

    • Wallet auth state and unlock API added to wallet context; NeedsPassword accepts async handlers.
  • Tests

    • Added comprehensive startup routing tests covering passwordless, unlock, and reload behaviors.

@cloudflare-workers-and-pages
Copy link

cloudflare-workers-and-pages bot commented Mar 13, 2026

Deploying wallet-bitcoin with  Cloudflare Pages  Cloudflare Pages

Latest commit: dbabd54
Status: ✅  Deploy successful!
Preview URL: https://90dd4792.wallet-bitcoin.pages.dev
Branch Preview URL: https://prevent-lockup-state.wallet-bitcoin.pages.dev

View logs

@cloudflare-workers-and-pages
Copy link

cloudflare-workers-and-pages bot commented Mar 13, 2026

Deploying wallet-mutinynet with  Cloudflare Pages  Cloudflare Pages

Latest commit: dbabd54
Status: ✅  Deploy successful!
Preview URL: https://bcff9677.arkade-wallet.pages.dev
Branch Preview URL: https://prevent-lockup-state.arkade-wallet.pages.dev

View logs

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 13, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Adds WalletAuthState and unlockWallet to the Wallet provider, implements a passwordless auto-init/unlock flow and one-time reload in App, refactors the Unlock screen to call unlockWallet, and extends tests/mocks and test matchMedia stubbing.

Changes

Cohort / File(s) Summary
Wallet provider (auth state & API)
src/providers/wallet.tsx
Adds WalletAuthState type and authState state + context field; implements unlockWallet(password) and auth-state initialization; lockWallet now sets authState.
App initialization & passwordless boot
src/App.tsx
Adds passwordless auto-init flow with passwordlessBootAttempted, passwordlessReloadTimer, session reload scheduling and appReloader/PASSWORDLESS_AUTO_RELOAD_KEY; updates routing/initialization guards (hasStoredWallet, shouldShowUnlock, shouldHoldOnLoading).
Unlock UI & flow
src/screens/Wallet/Unlock.tsx
Refactors unlock flow to call WalletContext.unlockWallet(password); adds local unlocking state, error handling, and loading UI; navigation guarded on unlock + data readiness.
Tests & test setup
src/test/App.test.tsx, src/test/screens/mocks.ts, src/test/setup.ts
Adds App startup routing tests covering passwordless auto-reload/unlock and sessionStorage flag; extends mock wallet context with authState and unlockWallet; resets window.matchMedia mock before each test.
UI component API
src/components/NeedsPassword.tsx
NeedsPasswordProps.onPassword now accepts `void

Sequence Diagram

sequenceDiagram
    participant App
    participant WalletProvider
    participant WalletService
    participant AppReloader

    App->>WalletProvider: read authState, wallet.pubkey, initialized, dataReady
    WalletProvider->>WalletService: check private key / noUserDefinedPassword
    WalletService-->>WalletProvider: return 'passwordless' | 'locked' | 'authenticated'
    alt authState == "passwordless" and not attempted
        App->>WalletProvider: unlockWallet(defaultPassword)
        WalletProvider->>WalletService: getPrivateKey(defaultPassword)
        alt success
            WalletProvider-->>App: set authState 'authenticated'
            App->>App: proceed to authenticated route
        else failure
            WalletProvider-->>App: set authState 'locked'
            App->>App: set session flag, schedule one-time reload
            App->>AppReloader: reload()
        end
    else authState == "locked"
        App->>App: navigate to Unlock screen
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested reviewers

  • bordalix
  • louisinger
🚥 Pre-merge checks | ✅ 1 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check ❓ Inconclusive The title 'Prevent lockup state' is vague and does not clearly convey the primary change. While related to the changeset (preventing a specific lockup/unlock transition issue), it lacks specificity about what 'lockup state' means and doesn't effectively summarize the main improvement (passwordless wallet handling and authentication state separation). Consider a more specific title such as 'Separate authentication state from backend initialization' or 'Fix passwordless wallet lockup on backend failure' to better communicate the core change to reviewers scanning the history.
✅ Passed checks (1 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch prevent-lockup-state

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@pietro909 pietro909 requested a review from Kukks March 13, 2026 14:39
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (3)
src/test/App.test.tsx (2)

22-22: Duplicated constant creates fragile coupling.

PASSWORDLESS_AUTO_RELOAD_KEY is duplicated from App.tsx. If the value changes in App.tsx, these tests will silently fail. Consider exporting the constant from App.tsx and importing it here.

♻️ Proposed fix

In src/App.tsx:

-const PASSWORDLESS_AUTO_RELOAD_KEY = 'passwordless-auto-reload-attempted'
+export const PASSWORDLESS_AUTO_RELOAD_KEY = 'passwordless-auto-reload-attempted'

In this test file:

-const PASSWORDLESS_AUTO_RELOAD_KEY = 'passwordless-auto-reload-attempted'
+import App, { appReloader, PASSWORDLESS_AUTO_RELOAD_KEY } from '../App'
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/test/App.test.tsx` at line 22, The test duplicates the
PASSWORDLESS_AUTO_RELOAD_KEY constant from App.tsx causing fragile coupling;
instead export PASSWORDLESS_AUTO_RELOAD_KEY from the App.tsx module (add an
export for the constant where it's defined) and update this test file to import
PASSWORDLESS_AUTO_RELOAD_KEY from App.tsx (replace the local const with the
imported symbol) so the test always uses the canonical value.

150-151: Microtask flushing pattern may be fragile.

The sequential await act(async () => {}) followed by await Promise.resolve() to flush microtasks can be flaky depending on the async chain depth. Consider using await waitFor(() => expect(unlockWallet).toHaveBeenCalled()) instead, which is more robust for async operations.

♻️ Proposed fix
-    await act(async () => {})
-    await Promise.resolve()
-    expect(unlockWallet).toHaveBeenCalledWith(defaultPassword)
+    await waitFor(() => expect(unlockWallet).toHaveBeenCalledWith(defaultPassword))
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/test/App.test.tsx` around lines 150 - 151, Replace the fragile
microtask-flushing pattern (await act(async () => {}) followed by await
Promise.resolve()) with a robust wait-for assertion: import and use waitFor from
`@testing-library/react` and replace those two lines with await waitFor(() =>
expect(unlockWallet).toHaveBeenCalled()), or similar waitFor-based assertion
targeting the mock/function under test (unlockWallet) to reliably wait for async
effects.
src/test/setup.ts (1)

46-58: Consider consolidating duplicate matchMedia mock.

The matchMedia mock is defined identically at lines 30-42 (module level) and again in beforeEach. If test isolation is the goal, the module-level definition at lines 30-42 could be removed, keeping only the beforeEach version. Alternatively, if both are intentional, a brief comment explaining why would help future maintainers.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/test/setup.ts` around lines 46 - 58, The file defines identical
window.matchMedia mocks twice (module-level and inside beforeEach), causing
duplication; remove the redundant module-level Object.defineProperty for
matchMedia (or vice versa) and keep a single authoritative mock in beforeEach
called by the existing beforeEach block so tests remain isolated, or if you
intend both, add a short comment above the module-level mock explaining why both
are required; locate the duplicated definitions by searching for
Object.defineProperty(window, 'matchMedia') and the beforeEach block to
consolidate.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/screens/Wallet/Unlock.tsx`:
- Around line 17-31: The handleUnlock function currently sets
setUnlocking(false) only for the 'Invalid password' error and never for other
exceptions, leaving the UI stuck; modify handleUnlock (the try/catch around
await unlockWallet) to ensure setUnlocking(false) is always called for any
failure—either by adding setUnlocking(false) after consoleError in the catch
block or by moving unlocking state cleanup into a finally block that runs after
the try/catch; keep existing behavior for setError('Invalid password') and
setUnlocked(true) intact.
- Line 42: The NeedsPassword component currently types
NeedsPasswordProps.onPassword as (password: string) => void while handleUnlock
is async; update the prop type to (password: string) => void | Promise<void> so
callers can return a Promise and TypeScript will reflect awaiting
semantics—locate the NeedsPasswordProps/interface declaration (and any PropTypes
or FC generic usage) and change the onPassword signature accordingly, then
ensure the NeedsPassword.handleClick caller properly handles the returned
Promise (no behavioral change required if it already calls
onPassword(password)).

---

Nitpick comments:
In `@src/test/App.test.tsx`:
- Line 22: The test duplicates the PASSWORDLESS_AUTO_RELOAD_KEY constant from
App.tsx causing fragile coupling; instead export PASSWORDLESS_AUTO_RELOAD_KEY
from the App.tsx module (add an export for the constant where it's defined) and
update this test file to import PASSWORDLESS_AUTO_RELOAD_KEY from App.tsx
(replace the local const with the imported symbol) so the test always uses the
canonical value.
- Around line 150-151: Replace the fragile microtask-flushing pattern (await
act(async () => {}) followed by await Promise.resolve()) with a robust wait-for
assertion: import and use waitFor from `@testing-library/react` and replace those
two lines with await waitFor(() => expect(unlockWallet).toHaveBeenCalled()), or
similar waitFor-based assertion targeting the mock/function under test
(unlockWallet) to reliably wait for async effects.

In `@src/test/setup.ts`:
- Around line 46-58: The file defines identical window.matchMedia mocks twice
(module-level and inside beforeEach), causing duplication; remove the redundant
module-level Object.defineProperty for matchMedia (or vice versa) and keep a
single authoritative mock in beforeEach called by the existing beforeEach block
so tests remain isolated, or if you intend both, add a short comment above the
module-level mock explaining why both are required; locate the duplicated
definitions by searching for Object.defineProperty(window, 'matchMedia') and the
beforeEach block to consolidate.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 18a54434-c7c9-433d-af5f-b320172a7019

📥 Commits

Reviewing files that changed from the base of the PR and between 624c7ab and 6a48734.

📒 Files selected for processing (7)
  • public/wallet-service-worker.mjs
  • src/App.tsx
  • src/providers/wallet.tsx
  • src/screens/Wallet/Unlock.tsx
  • src/test/App.test.tsx
  • src/test/screens/mocks.ts
  • src/test/setup.ts

@bordalix
Copy link
Collaborator

It doesn't restore swaps in test

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
src/App.tsx (1)

221-223: Keep the passwordless boot guard stable for the whole attempt.

src/providers/wallet.tsx:429-440 flips authState to 'authenticated' before initWallet() settles, so Lines 221-223 clear passwordlessBootAttempted while the first auto-init is still in flight. If the same wallet is reclassified as passwordless again, this effect can start a second unlockWallet() call.

♻️ Make the guard wallet-scoped instead of auth-state-scoped
-  useEffect(() => {
-    passwordlessBootAttempted.current = false
-  }, [wallet.pubkey, authState])
+  useEffect(() => {
+    passwordlessBootAttempted.current = false
+  }, [wallet.pubkey])

Also applies to: 231-248

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/App.tsx` around lines 221 - 223, The passwordless boot guard
passwordlessBootAttempted should be tied to the wallet identity, not cleared
when authState flips: stop resetting passwordlessBootAttempted.current in the
useEffect that depends on [wallet.pubkey, authState]; instead scope the guard to
wallet.pubkey only (or key-derived token) so it persists for the duration of the
initWallet()/unlockWallet() attempt; update both places where you reset the ref
(the effect with passwordlessBootAttempted and the similar block at lines
~231-248) to only reset when wallet.pubkey actually changes (or on unmount), and
ensure unlockWallet() checks and sets passwordlessBootAttempted.current before
starting to prevent duplicate in-flight calls.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/App.tsx`:
- Line 219: The top-level routing gate variable shouldHoldOnLoading currently
includes dataReady which prevents mounting wallet-driven restore flows; remove
dataReady from this global gate so shouldHoldOnLoading is computed as
hasStoredWallet && authState !== 'locked' && !initialized (and mirror this
change for the similar logic around the block mentioned at 250-256). Instead,
keep any dataReady-based spinner local to the wallet screen or provider that
triggers hydration/restore (move the dataReady checks into that
component/provider and render a local loading state there rather than blocking
Pages.Loading globally).

---

Nitpick comments:
In `@src/App.tsx`:
- Around line 221-223: The passwordless boot guard passwordlessBootAttempted
should be tied to the wallet identity, not cleared when authState flips: stop
resetting passwordlessBootAttempted.current in the useEffect that depends on
[wallet.pubkey, authState]; instead scope the guard to wallet.pubkey only (or
key-derived token) so it persists for the duration of the
initWallet()/unlockWallet() attempt; update both places where you reset the ref
(the effect with passwordlessBootAttempted and the similar block at lines
~231-248) to only reset when wallet.pubkey actually changes (or on unmount), and
ensure unlockWallet() checks and sets passwordlessBootAttempted.current before
starting to prevent duplicate in-flight calls.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 8c1c202d-b25c-4eaf-9ea2-f8fe9544ec77

📥 Commits

Reviewing files that changed from the base of the PR and between c0cf07f and 04ae27b.

📒 Files selected for processing (1)
  • src/App.tsx

@pietro909
Copy link
Contributor Author

@bordalix I think this PR is still relevant after #431 because it decouples the error state from the Unlock screen, where a user may end up in a dead end with a reset as the only viable solution.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
src/App.tsx (1)

192-196: Clear pending auto-reload when recovery happens.

The timeout is only cleared on unmount (Line 194). If recovery completes before the 1s timer fires, the app can still reload unexpectedly.

💡 Suggested fix
   useEffect(() => {
     return () => {
       if (passwordlessReloadTimer.current) clearTimeout(passwordlessReloadTimer.current)
     }
   }, [])

+  useEffect(() => {
+    if (!initialized || !passwordlessReloadTimer.current) return
+    clearTimeout(passwordlessReloadTimer.current)
+    passwordlessReloadTimer.current = undefined
+  }, [initialized])

Also applies to: 210-210

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/App.tsx` around lines 192 - 196, The current cleanup only clears
passwordlessReloadTimer.current on unmount; also clear and nullify that timeout
immediately when recovery completes to prevent an unexpected reload—add
clearTimeout(passwordlessReloadTimer.current) and set
passwordlessReloadTimer.current = null in the recovery completion path (e.g.,
inside the function that marks recovery success or the handler that sets
isRecoveryComplete), and apply the same change to the other recovery-related
code block referenced around the second location so both places cancel the
pending auto-reload.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/App.tsx`:
- Line 115: The current conditional `if (!initialized && authState === 'locked')
return navigate(Pages.Unlock)` is fragile; change the routing check to rely only
on `authState === 'locked'` so the Unlock screen is always shown when the app is
locked. Update the two occurrences (the one at line with `if (!initialized &&
authState === 'locked') return navigate(Pages.Unlock'` and the similar check
near line 185) to remove the `!initialized` clause, and ensure no other logic
(e.g., `unlockWallet` which sets `authState('locked')`) is forced to reset
`initialized`; use `authState` alone to determine navigation to `Pages.Unlock`.

---

Nitpick comments:
In `@src/App.tsx`:
- Around line 192-196: The current cleanup only clears
passwordlessReloadTimer.current on unmount; also clear and nullify that timeout
immediately when recovery completes to prevent an unexpected reload—add
clearTimeout(passwordlessReloadTimer.current) and set
passwordlessReloadTimer.current = null in the recovery completion path (e.g.,
inside the function that marks recovery success or the handler that sets
isRecoveryComplete), and apply the same change to the other recovery-related
code block referenced around the second location so both places cancel the
pending auto-reload.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 508f6846-5d6e-453b-9073-64dd24f6c283

📥 Commits

Reviewing files that changed from the base of the PR and between 894f8e6 and e7ddf86.

📒 Files selected for processing (1)
  • src/App.tsx

@arkanaai
Copy link

arkanaai bot commented Mar 19, 2026

Hey @pietro909 — this PR was approved by @Kukks 6 days ago. What's blocking the merge? If it's waiting on something specific, let the team know.

@pietro909 pietro909 force-pushed the prevent-lockup-state branch from 83267a8 to 90164a5 Compare March 20, 2026 10:02
@arkanaai
Copy link

arkanaai bot commented Mar 20, 2026

Followup Review — New commits since last review

Key new commits:

  • cc0edf9b (kukks) — fix: prevent shouldHoldOnLoading from blocking initialized wallet
  • f3086c41 / 90164a52 — Use only authState to drive Unlock screen

Changes assessed:

shouldHoldOnLoading fix: The condition now correctly checks !initialized && authState !== 'locked' instead of the previous !initialized || !dataReady. This prevents the Loading page from blocking navigation after a wallet restore where initialized is already true. Good fix — the old condition was a real navigation deadlock vector.

✅ Unlock screen simplification: The Unlock component now uses unlockWallet directly instead of the previous getPrivateKeyinitWallet chain. The setUnlocking state drives Loading vs form display. Cleaner flow, and the async onPassword type change in NeedsPassword is consistent.

authState effect (wallet.tsx): The noUserDefinedPassword() async check with cancellation guard is clean. Defaults to 'locked' on error — safe fallback. The 'authenticated' state for new wallets (no pubkey) is correct since there's nothing to unlock.

✅ Tests: Comprehensive coverage of the state machine — passwordless boot, locked routing, reload guard with sessionStorage. The appReloader indirection for testability is a nice pattern.

Security note: The defaultPassword usage for passwordless auto-init is expected — this matches the existing pattern where passwordless wallets use a hardcoded default. The one-shot reload guard via sessionStorage prevents infinite reload loops.

No blocking issues.

@pietro909
Copy link
Contributor Author

arkanaai I'm waiting for @bordalix review too

@pietro909 pietro909 force-pushed the prevent-lockup-state branch from 90164a5 to f960ab2 Compare March 20, 2026 15:05
@arkanaai
Copy link

arkanaai bot commented Mar 20, 2026

🔍 Arkana PR Review

Overall: ✅ Solid fix for a real UX problem — passwordless wallets no longer get stuck on the Unlock screen.

Architecture

The core change introduces a WalletAuthState enum (unknown | passwordless | locked | authenticated) that cleanly separates authentication concerns from wallet initialization state. Previously, !initialized always routed to Unlock, even for passwordless wallets where no user input is needed. Now:

  • Passwordless wallets → stay on Loading, auto-boot in background
  • Password-protected wallets → route to Unlock as before
  • Auth state unknown → hold on Loading until determined

This is a meaningful improvement to the startup flow.

Security Review

  1. defaultPassword usage: The auto-init path calls unlockWallet(defaultPassword) for passwordless wallets. This is the existing pattern — defaultPassword is used when no user-defined password exists. The noUserDefinedPassword() check gates this path correctly.

  2. Auth state derivation: The noUserDefinedPassword() async check determines whether to show Unlock or auto-boot. If this check fails (throws), it falls back to locked — correct defensive behavior. An attacker can't bypass the password prompt by forcing this check to fail.

  3. Private key handling in unlockWallet: The getPrivateKey(password) call is wrapped in try/catch, and on failure the state resets to locked. The private key is passed directly to initWallet without intermediate storage. Good.

  4. Session-guarded reload: The one-shot reload uses sessionStorage to prevent loops. The key passwordless-auto-reload-attempted is per-session, so a hard refresh resets it. This is reasonable — a single retry covers transient service worker startup races without creating infinite reload loops.

Potential Concerns

  1. Bundled service worker file (wallet-service-worker.mjs): This PR includes a 55k+ line bundled file added to public/. This appears to be a build artifact — should this be in version control? If it's generated from the SDK, it should probably be in .gitignore and built at CI time instead. Including it bloats the diff and makes future reviews harder.

  2. Race condition window: Between setAuthState('authenticated') and await initWallet(privateKey) in unlockWallet(), there's a brief window where auth state is authenticated but the wallet isn't initialized. The shouldHoldOnLoading check (hasStoredWallet && !initialized && authState !== 'locked') handles this by keeping the user on Loading — good.

  3. navigate in useEffect deps: The comment about navigate being unstable was removed, and navigate is now included in the dependency array. If navigate is still recreated every render, this could cause unnecessary effect re-runs. Worth verifying this doesn't regress.

Tests

Comprehensive test coverage for the new startup routing logic:

  • Passwordless → Loading (not Unlock)
  • Locked → Unlock
  • Locked + initialized → still Unlock
  • Authenticated + uninitialized → Loading
  • Single reload on failure, no double reload

Good coverage of the critical paths.

Summary

The auth state machine is well-designed and the defensive fallbacks are sound. Main concern is the bundled service worker file in VCS. No protocol-level security issues.

@arkanaai
Copy link

arkanaai bot commented Mar 20, 2026

Review — Prevent lockup state (iterative)

New commits since last review: removed compiled service worker + unused import. Core auth state refactor unchanged.

What changed since last review:

  • 2ee3b85: Remove compiled service worker (cleanup)
  • d6b035b: Remove unused import

Architecture recap (for reviewers):
The PR introduces WalletAuthState (unknown | passwordless | locked | authenticated) to replace the boolean initialized flag for driving the Unlock screen. Key flow:

  1. On mount, noUserDefinedPassword() probes whether a user-set password exists
  2. If passwordless → auto-boot with defaultPassword in background, stay on Loading
  3. If password-protected → show Unlock screen
  4. One-shot reload safety net: if passwordless auto-init fails, schedule a single window.location.reload() after 1s, guarded by sessionStorage to prevent loops

Security review:

  • getPrivateKey(password) is the sole decryption path — no change to key handling ✅
  • defaultPassword usage for passwordless wallets is pre-existing behavior, just moved from Unlock screen to App-level effect ✅
  • The sessionStorage guard (passwordless-auto-reload-attempted) prevents infinite reload loops — correct, since sessionStorage clears on tab close ✅
  • authState transitions: unknownpasswordless/locked (from noUserDefinedPassword), then → authenticated (on successful unlock) or stays locked (on failure). Clean state machine ✅

Observations:

  • The shouldHoldOnLoading condition (hasStoredWallet && !initialized && authState !== 'locked') correctly covers the passwordless and authenticated (but not yet initialized) cases
  • unlockWallet sets authState = 'authenticated' before calling initWallet — if initWallet fails after auth succeeds, the user is in authenticated but not initialized state. This is handled by shouldHoldOnLoading keeping them on Loading, which is reasonable
  • Tests are comprehensive: covers passwordless boot, locked routing, authenticated-but-uninitialized, reload scheduling, and reload-once guard
  • The NeedsPassword.onPassword type change to void | Promise<void> is backward-compatible

Cross-repo: No external impact — wallet-internal state management only.

LGTM.

@pietro909 pietro909 force-pushed the prevent-lockup-state branch from d6b035b to 5b3e764 Compare March 23, 2026 05:09
@arkanaai
Copy link

arkanaai bot commented Mar 23, 2026

🔍 Arkana PR Review — wallet#421 (Prevent lockup state)

Commits reviewed up to: 5b3e764

Summary

Separates wallet auth state (unknown | passwordless | locked | authenticated) from backend initialization so passwordless wallets no longer incorrectly land on the Unlock screen when startup fails. Adds a one-shot auto-reload recovery path for passwordless init failures, guarded by sessionStorage to prevent loops.

Architecture ✅

  • Clean separation of concerns: authState now drives routing instead of the overloaded initialized flag. This is a solid fix — the previous !initialized → Unlock path was wrong for passwordless wallets.
  • unlockWallet() centralizes password→key→init logic that was duplicated between App.tsx and Unlock.tsx.
  • The WalletAuthState type is well-defined and covers all states.

Security Review ✅

  • Private key handling: getPrivateKey() is called inside unlockWallet() with proper error handling — on failure, auth state reverts to locked and error propagates. No key material leaks.
  • Auto-reload guard: The PASSWORDLESS_AUTO_RELOAD_KEY sessionStorage check prevents infinite reload loops. One-shot only, per session. Good pattern.
  • defaultPassword usage: Existing behavior for passwordless wallets, just relocated. No change in security posture.
  • appReloader export: Exposed for test mocking only. Acceptable — it's a thin wrapper around window.location.reload().

Observations

  1. noUserDefinedPassword() error path (L153-155 in wallet.tsx): On .catch(), auth state falls back to locked, which means a transient storage error would force the user to enter a password they may not have set. Consider whether passwordless would be a safer fallback, or at minimum log the error for diagnostics.

  2. Cleanup of passwordlessReloadTimer (App.tsx): The cleanup effect unmounts the timer correctly, but passwordlessBootAttempted.current resets on wallet.pubkey or authState change. If authState transitions passwordless → unknown → passwordless (e.g., rapid pubkey changes), the auto-init could fire again. In practice this seems unlikely but worth noting.

  3. navigate in deps (Unlock.tsx L30): Good catch adding navigate to the dependency array — the previous code had an eslint-disable for this. Confirm the navigation provider doesn't recreate navigate every render (the comment in the old code suggested it did).

  4. Test coverage: Solid — covers passwordless boot, locked routing, reload guard, and double-reload prevention. The mock structure in mocks.ts is clean.

Verdict

Well-structured fix with good test coverage. The auth state separation is the right approach. Minor nits above — observation #1 (error fallback) is the most worth discussing with the team.

Reviewed by Arkana 🔍

pietro909 and others added 8 commits March 23, 2026 14:18
The original condition (!initialized || !dataReady) could flip the page
back to Loading after the wallet was already initialized via restore,
breaking navigation to other pages. Only hold on Loading when the
wallet is not yet initialized and authState is not locked.
@pietro909 pietro909 force-pushed the prevent-lockup-state branch from 5b3e764 to f998952 Compare March 23, 2026 13:19
@arkanaai
Copy link

arkanaai bot commented Mar 23, 2026

🔍 Review — Prevent lockup state (iterative, new commits)

What changed since last review: Core architectural shift — wallet auth state is now separated from backend initialization. Passwordless wallets auto-boot in the background instead of routing through Unlock.

Key Changes

  • New WalletAuthState type (unknown | passwordless | locked | authenticated) in wallet provider.
  • noUserDefinedPassword() check runs on mount to determine auth state before any init attempt.
  • unlockWallet() centralized in wallet context — both Unlock screen and auto-init use the same path.
  • One-shot reload guard via sessionStorage prevents retry loops on persistent backend failures.
  • Comprehensive test suite covering all startup routing permutations.

Positives

  • Clean state machine: authState replaces the implicit !initialized → Unlock assumption.
  • Single-reload guard (PASSWORDLESS_AUTO_RELOAD_KEY in sessionStorage) is a pragmatic solution that avoids infinite loops while giving the app one chance to recover.
  • NeedsPassword.onPassword now accepts Promise<void> — small but important for async unlock flows.
  • Test coverage is excellent: passwordless boot, locked routing, reload behavior, guard-prevents-second-reload.

Observations

appReloader indirection (App.tsx):
Exporting appReloader as a mutable object for test spying is functional but somewhat fragile — any code could mutate it. A callback prop or dependency injection would be cleaner. Not blocking.

Race between authState effect and unlockWallet effect (App.tsx):
The noUserDefinedPassword() async check sets authState, and a separate effect watches authState === "passwordless" to call unlockWallet. If the first effect resolves slowly, there is a brief window where the user sees Loading with no feedback. The UX is acceptable (Loading screen), just noting the async dependency chain.

navigate in deps array (Unlock.tsx L27):
Previously excluded with an eslint-disable comment about instability. Now included. If navigate is recreated every render, this could cause unnecessary effect re-runs. Verify navigate is stable (memoized) in NavigationContext.

Missing error feedback for non-password errors in Unlock:
If unlockWallet throws something other than "Invalid password", the catch block logs but shows no user-facing error. The user stays on Unlock with no indication of what went wrong. Consider a generic error state.

Cross-repo impact

None — self-contained wallet app change.

Overall: Strong improvement to startup reliability. The auth state machine is well-designed and the one-shot reload is a practical recovery mechanism.

@arkanaai
Copy link

arkanaai bot commented Mar 23, 2026

🔍 Arkana PR Review — wallet#421 (Prevent lockup state)

Scope: Wallet auth state machine refactor to prevent passwordless wallets from hitting the Unlock screen on startup failure.

Architecture Assessment ✅

The introduction of WalletAuthState (unknown | passwordless | locked | authenticated) is a clean separation of concerns. Previously, the initialized flag was doing double duty — indicating both "backend is ready" and "user has authenticated" — which caused passwordless wallets to incorrectly route to Unlock when the backend failed to initialize.

Key improvements:

  • authState is derived from noUserDefinedPassword() on pubkey change, giving a reliable signal independent of backend health
  • unlockWallet() centralizes the decrypt → init flow and manages auth state transitions
  • The Unlock screen is now only shown when authState === 'locked', not when !initialized

One-Shot Reload Recovery

The sessionStorage-guarded reload after passwordless auto-init failure is well designed:

  • Bounded: max 1 reload per session via PASSWORDLESS_AUTO_RELOAD_KEY
  • Non-blocking: 1-second delay in timer, cleaned up on unmount
  • Fault-tolerant: sessionStorage errors are caught, preventing retry loops
  • appReloader.reload() indirection makes it testable

Potential Issues

  1. Race between authState effect and auto-boot effect: The auto-boot useEffect depends on authState === 'passwordless', while the noUserDefinedPassword() check is async. If the config/ASP checks resolve faster than the password probe, allChecksReady could become true while authState is still 'unknown'. In practice the password check is local/fast so this is unlikely, but worth noting — could the user see a brief flash of the Loading screen during this gap?

  2. useEffect dependency in navigation: The old code had an explicit ESLint disable for navigate being unstable. The new code includes navigate in one deps array ([unlocked, dataReady, navigate] in Unlock.tsx). If navigate is recreated every render (as the old comment suggested), this effect would re-fire every render. Verify that NavigationContext memoizes navigate, or the Unlock screen could have a hot loop.

  3. Error path in unlockWallet: If getPrivateKey succeeds but initWallet throws, authState is already set to 'authenticated' but the wallet isn't initialized. The user would be stuck on Loading with no way to retry. Consider moving setAuthState('authenticated') to after await initWallet(privateKey), or catching initWallet errors to reset auth state.

Test Coverage ✅

Comprehensive tests covering:

  • Passwordless → Loading (not Unlock)
  • Locked → Unlock
  • Locked + initialized → Unlock (regression guard)
  • Authenticated + uninitialized → Loading (no unlock, no auto-init)
  • Single reload on failure
  • No double reload

Good use of vi.useFakeTimers() and sessionStorage manipulation.

Verdict

Solid refactor that correctly solves the lockup state issue. The auth state machine is clean and testable. The three potential issues above are minor but worth reviewing, especially #3 (error path in unlockWallet leaving auth state inconsistent).

@bordalix bordalix merged commit 1691536 into master Mar 23, 2026
5 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.

3 participants