Skip to content

fix(auth): destructive Forgot-PIN recovery (Telegram bugs 7, 8)#272

Merged
RaheemJnr merged 1 commit into
mainfrom
fix/forgot-pin-destructive-recovery
May 22, 2026
Merged

fix(auth): destructive Forgot-PIN recovery (Telegram bugs 7, 8)#272
RaheemJnr merged 1 commit into
mainfrom
fix/forgot-pin-destructive-recovery

Conversation

@RaheemJnr
Copy link
Copy Markdown
Owner

@RaheemJnr RaheemJnr commented May 21, 2026

Summary

Two Telegram bug reports fixed with one flow change.

# Reported Severity
8 Forgot PIN → enter seed → "already imported", recovery impossible CRITICAL
7 Back arrow on the Forgot-PIN landing page doesn't work HIGH

Both root-caused to a single line in NavGraph.kt:

```kotlin
onForgotPin = {
navController.navigate(Screen.MnemonicImport.route) {
popUpTo(Screen.Auth.route) { inclusive = true } // ← trapped the back arrow
}
}
```

The destination was the regular import screen, which refused already-imported phrases (bug 8). The popUpTo removed Auth from the back stack so the back arrow had nothing to pop (bug 7).

What ships

  • ForgotPinScreen + ForgotPinViewModel — confirmation surface that explains the destructive cost ("every local wallet, key, and cache is wiped; only the recovery phrase can restore access") before any state is touched.
  • WalletRepository.factoryReset() — iterates parents + sub-accounts, reusing the existing per-wallet deleteWallet so key + cache cleanup runs inside transactions (a bulk DELETE would orphan EncryptedSharedPreferences / key_material). The active-wallet guard is bypassed once, on purpose, by clearing the active-wallet pref + deactivateAll-ing the DB rows first.
  • WalletPreferences.clearActiveWalletId() — small helper for the bypass.
  • NavGraph wiringonForgotPin now routes to Screen.ForgotPin without popping Auth. Back arrow restored.
  • Reset sequence — mirrors GatewayRepository.switchNetwork: ProcessPhoenix-style relaunch + Process.killProcess. The user lands on the onboarding entry point with a clean slate and can re-import via their recovery phrase.

Test plan

  • ./gradlew :app:compileDebugKotlin -x cargoBuild BUILD SUCCESSFUL
  • ./gradlew :app:testDebugUnitTest -x cargoBuild BUILD SUCCESSFUL
  • Manual: tap Forgot PIN on the auth screen → confirmation screen appears, back arrow works, Cancel returns to PIN entry
  • Manual: tap Erase → app restarts on the onboarding flow, importing the previously-imported seed succeeds (no "already imported" error)
  • Manual: tap Forgot PIN on a multi-wallet device → all wallets and sub-accounts gone after restart

Refs Telegram bug report (Radoslav, 2026-05-21)

Summary by CodeRabbit

  • New Features
    • Added "Forgot PIN" recovery option allowing users to reset the app when their PIN is forgotten, clearing all local data and returning to the initial setup state for a fresh start.

Review Change Stack

Two Telegram-reported bugs fixed with one flow change.

Bug 8 (CRITICAL): Forgot PIN navigated to the regular MnemonicImport
screen, which refuses already-imported seed phrases. A user who
had genuinely forgotten their PIN entered their seed, hit Import,
and got "already imported" — a permanent lockout.

Bug 7 (HIGH): The same nav call used
`popUpTo(Auth) { inclusive = true }`, so the back arrow on the
MnemonicImport screen had nothing to pop back to. Even users who
remembered their PIN after tapping Forgot PIN were trapped.

What this PR does

- New `ForgotPinScreen` + `ForgotPinViewModel` that surface the
  destructive cost up front: every local wallet, key, and cache is
  wiped; only the recovery phrase can restore access. After the
  user confirms, the ViewModel runs `factoryReset` and restarts
  the process so the JNI light client comes back up clean.

- `WalletRepository.factoryReset()` — iterates parent wallets and
  their sub-accounts, calling the existing `deleteWallet` per row
  so per-wallet key + cache + sub-account cleanup runs inside
  transactions (a bulk DELETE would orphan EncryptedSharedPreferences
  / key_material). The active-wallet guard in `deleteWallet` is
  bypassed once, on purpose, by clearing the active-wallet
  preference and `deactivateAll`-ing the DB rows first.

- `WalletPreferences.clearActiveWalletId()` — small helper so the
  guard above doesn't need to reach into the SharedPreferences
  keys directly.

- NavGraph wiring: `onForgotPin` now routes to `Screen.ForgotPin`
  without `popUpTo(Auth)`. The back arrow works. The confirmation
  screen itself only triggers wipe on the explicit Erase button.

- Reset sequence mirrors `GatewayRepository.switchNetwork`:
  ProcessPhoenix-style relaunch with `FLAG_ACTIVITY_NEW_TASK |
  CLEAR_TASK` before `Process.killProcess`. The user lands on
  the onboarding entry point with a clean slate and can re-import
  via their recovery phrase.

Refs Telegram bug report (Radoslav, 2026-05-21)
@vercel
Copy link
Copy Markdown

vercel Bot commented May 21, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
pocket-node Ready Ready Preview, Comment May 21, 2026 10:34pm

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 21, 2026

📝 Walkthrough

Walkthrough

This PR introduces a complete "Forgot PIN" destructive recovery feature for the Pocket Node Android app. Users can now trigger a factory reset that clears all wallets, caches, and PIN from local storage, then restarts the app. The implementation spans data operations, ViewModel orchestration, and Compose UI with error handling throughout.

Changes

Forgot PIN Destructive Recovery

Layer / File(s) Summary
Wallet wipe operations
android/app/src/main/java/com/rjnr/pocketnode/data/wallet/WalletPreferences.kt, android/app/src/main/java/com/rjnr/pocketnode/data/wallet/WalletRepository.kt
WalletPreferences.clearActiveWalletId() removes the stored active wallet preference. WalletRepository.factoryReset() orchestrates a cascading wipe: clears active wallet, deactivates all wallets, snapshots parent wallets, deletes sub-accounts and parents via deleteWallet() with per-wallet error handling and completion logging.
Reset orchestration and state
android/app/src/main/java/com/rjnr/pocketnode/ui/screens/auth/ForgotPinViewModel.kt, android/app/src/main/res/values/strings.xml
ForgotPinViewModel with UiState exposes isResetting and error flags. executeReset() guards against concurrent resets, calls walletRepository.factoryReset(), clears caches via cacheManager and daoSyncManager, removes PIN with pinManager.removePin(force=true), then starts launch activity and terminates the process. Exceptions are logged and surfaced as error state. String resources provide UI labels for title, heading, body, reassurance, confirm action, resetting status, and cancel.
Confirmation screen UI
android/app/src/main/java/com/rjnr/pocketnode/ui/screens/auth/ForgotPinScreen.kt
ForgotPinScreen Composable renders warning icon, heading/body/reassurance text, optional error message display, and dual action buttons: a primary reset button that triggers viewModel.executeReset() with loading spinner while isResetting, and a secondary cancel button. Top app bar back navigation is disabled during reset.

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant ForgotPinScreen
  participant ForgotPinViewModel
  participant WalletRepository
  participant CacheManager
  participant PinManager
  participant LaunchActivity
  User->>ForgotPinScreen: tap confirm reset
  ForgotPinScreen->>ForgotPinViewModel: executeReset()
  ForgotPinViewModel->>WalletRepository: factoryReset()
  WalletRepository->>WalletRepository: clearActiveWalletId()
  WalletRepository->>WalletRepository: deactivateAllWallets()
  WalletRepository->>WalletRepository: delete sub-accounts & parents
  ForgotPinViewModel->>CacheManager: clear caches
  ForgotPinViewModel->>PinManager: removePin(force=true)
  ForgotPinViewModel->>LaunchActivity: startActivity with clear task flags
  ForgotPinViewModel->>ForgotPinViewModel: killProcess()
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Suggested labels

codex, aardvark


🐰 A PIN's forgotten, the wallets erased,
Now cleared caches embrace the reset in haste,
From data to view, a destructive ballet,
App's reborn anew—let recovery sway!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and specifically describes the main change: implementing a destructive Forgot-PIN recovery flow to fix the two reported Telegram bugs.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/forgot-pin-destructive-recovery

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.

Copy link
Copy Markdown
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 (1)
android/app/src/main/java/com/rjnr/pocketnode/data/wallet/WalletRepository.kt (1)

373-382: ⚡ Quick win

Avoid VACUUM on every wallet delete during bulk reset.

factoryReset() loops through many deleteWallet() calls, and each call VACUUMs the DB. In this bulk path, VACUUM once at the end to reduce reset time and ANR risk.

Refactor sketch
-suspend fun deleteWallet(walletId: String) {
+suspend fun deleteWallet(walletId: String, vacuumAfterDelete: Boolean = true) {
    ...
-   DatabaseMaintenanceUtil.vacuum(appDatabase)
+   if (vacuumAfterDelete) {
+       DatabaseMaintenanceUtil.vacuum(appDatabase)
+   }
 }

 suspend fun factoryReset() {
    ...
-   runCatching { deleteWallet(sub.walletId) }
+   runCatching { deleteWallet(sub.walletId, vacuumAfterDelete = false) }
    ...
-   runCatching { deleteWallet(parent.walletId) }
+   runCatching { deleteWallet(parent.walletId, vacuumAfterDelete = false) }
    ...
+   DatabaseMaintenanceUtil.vacuum(appDatabase)
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@android/app/src/main/java/com/rjnr/pocketnode/data/wallet/WalletRepository.kt`
around lines 373 - 382, The factoryReset path calls deleteWallet repeatedly
which performs a VACUUM on every delete; change deleteWallet to accept a flag
(e.g., performVacuum: Boolean = true) or split out the VACUUM into a separate
method (e.g., vacuumDatabase()) so bulk callers can skip per-delete VACUUMs;
update factoryReset() to call deleteWallet(sub.walletId, performVacuum = false)
and deleteWallet(parent.walletId, performVacuum = false) for each account, then
call the single vacuumDatabase() (or deleteWallet(..., true) once) after the
loop to run VACUUM only once; ensure the new parameter/method is used
consistently and tests/Log messages remain accurate (referencing factoryReset
and deleteWallet to locate the changes).
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@android/app/src/main/java/com/rjnr/pocketnode/data/wallet/WalletRepository.kt`:
- Around line 376-381: The current factoryReset implementation swallows
per-wallet deletion failures by only logging inside runCatching for
deleteWallet(sub.walletId) and deleteWallet(parent.walletId); update
factoryReset so that any failure from deleteWallet is not suppressed—either
rethrow the caught exception (after logging) or collect failures and throw an
aggregated exception before proceeding with PIN removal/restart; specifically
modify the runCatching blocks around deleteWallet(sub.walletId) and
deleteWallet(parent.walletId) in WalletRepository.factoryReset to propagate
errors instead of merely calling Log.w(TAG, ...).

In
`@android/app/src/main/java/com/rjnr/pocketnode/ui/screens/auth/ForgotPinScreen.kt`:
- Around line 129-135: The inline spinner and label in the button use
Spacer(modifier = Modifier.height(8.dp)) which adds vertical space instead of
horizontal; inside the button content (in ForgotPinScreen.kt near
CircularProgressIndicator and
Text(stringResource(R.string.forgot_pin_resetting))) replace the vertical spacer
with a horizontal spacer (Modifier.width(...), e.g., 8.dp) so there is proper
horizontal spacing between CircularProgressIndicator and the Text.

---

Nitpick comments:
In
`@android/app/src/main/java/com/rjnr/pocketnode/data/wallet/WalletRepository.kt`:
- Around line 373-382: The factoryReset path calls deleteWallet repeatedly which
performs a VACUUM on every delete; change deleteWallet to accept a flag (e.g.,
performVacuum: Boolean = true) or split out the VACUUM into a separate method
(e.g., vacuumDatabase()) so bulk callers can skip per-delete VACUUMs; update
factoryReset() to call deleteWallet(sub.walletId, performVacuum = false) and
deleteWallet(parent.walletId, performVacuum = false) for each account, then call
the single vacuumDatabase() (or deleteWallet(..., true) once) after the loop to
run VACUUM only once; ensure the new parameter/method is used consistently and
tests/Log messages remain accurate (referencing factoryReset and deleteWallet to
locate the changes).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 2b1e6e7a-bc1c-424d-883f-58591e7397da

📥 Commits

Reviewing files that changed from the base of the PR and between dc0e80b and f637be0.

📒 Files selected for processing (6)
  • android/app/src/main/java/com/rjnr/pocketnode/data/wallet/WalletPreferences.kt
  • android/app/src/main/java/com/rjnr/pocketnode/data/wallet/WalletRepository.kt
  • android/app/src/main/java/com/rjnr/pocketnode/ui/navigation/NavGraph.kt
  • android/app/src/main/java/com/rjnr/pocketnode/ui/screens/auth/ForgotPinScreen.kt
  • android/app/src/main/java/com/rjnr/pocketnode/ui/screens/auth/ForgotPinViewModel.kt
  • android/app/src/main/res/values/strings.xml

Comment on lines +376 to +381
runCatching { deleteWallet(sub.walletId) }
.onFailure { Log.w(TAG, "factoryReset: sub ${sub.walletId} delete failed", it) }
}
runCatching { deleteWallet(parent.walletId) }
.onFailure { Log.w(TAG, "factoryReset: parent ${parent.walletId} delete failed", it) }
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Do not swallow per-wallet deletion failures in factory reset.

At Line 376 and Line 379, failures are only logged and factoryReset() still returns successfully. That can continue into PIN removal + restart with a partial wipe.

Proposed fix
 suspend fun factoryReset() {
+    val failures = mutableListOf<String>()
     walletPreferences.clearActiveWalletId()
     walletDao.deactivateAll()
     val parents = walletDao.getAll().filter { it.parentWalletId == null }
     for (parent in parents) {
         val subs = walletDao.getSubAccountsList(parent.walletId)
         for (sub in subs) {
             runCatching { deleteWallet(sub.walletId) }
-                .onFailure { Log.w(TAG, "factoryReset: sub ${sub.walletId} delete failed", it) }
+                .onFailure {
+                    Log.w(TAG, "factoryReset: sub ${sub.walletId} delete failed", it)
+                    failures += "sub:${sub.walletId}"
+                }
         }
         runCatching { deleteWallet(parent.walletId) }
-            .onFailure { Log.w(TAG, "factoryReset: parent ${parent.walletId} delete failed", it) }
+            .onFailure {
+                Log.w(TAG, "factoryReset: parent ${parent.walletId} delete failed", it)
+                failures += "parent:${parent.walletId}"
+            }
     }
+    if (failures.isNotEmpty()) {
+        throw IllegalStateException("factoryReset incomplete (${failures.size} wallet deletions failed)")
+    }
     Log.d(TAG, "factoryReset: ${parents.size} parent wallet(s) wiped")
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
runCatching { deleteWallet(sub.walletId) }
.onFailure { Log.w(TAG, "factoryReset: sub ${sub.walletId} delete failed", it) }
}
runCatching { deleteWallet(parent.walletId) }
.onFailure { Log.w(TAG, "factoryReset: parent ${parent.walletId} delete failed", it) }
}
suspend fun factoryReset() {
val failures = mutableListOf<String>()
walletPreferences.clearActiveWalletId()
walletDao.deactivateAll()
val parents = walletDao.getAll().filter { it.parentWalletId == null }
for (parent in parents) {
val subs = walletDao.getSubAccountsList(parent.walletId)
for (sub in subs) {
runCatching { deleteWallet(sub.walletId) }
.onFailure {
Log.w(TAG, "factoryReset: sub ${sub.walletId} delete failed", it)
failures += "sub:${sub.walletId}"
}
}
runCatching { deleteWallet(parent.walletId) }
.onFailure {
Log.w(TAG, "factoryReset: parent ${parent.walletId} delete failed", it)
failures += "parent:${parent.walletId}"
}
}
if (failures.isNotEmpty()) {
throw IllegalStateException("factoryReset incomplete (${failures.size} wallet deletions failed)")
}
Log.d(TAG, "factoryReset: ${parents.size} parent wallet(s) wiped")
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@android/app/src/main/java/com/rjnr/pocketnode/data/wallet/WalletRepository.kt`
around lines 376 - 381, The current factoryReset implementation swallows
per-wallet deletion failures by only logging inside runCatching for
deleteWallet(sub.walletId) and deleteWallet(parent.walletId); update
factoryReset so that any failure from deleteWallet is not suppressed—either
rethrow the caught exception (after logging) or collect failures and throw an
aggregated exception before proceeding with PIN removal/restart; specifically
modify the runCatching blocks around deleteWallet(sub.walletId) and
deleteWallet(parent.walletId) in WalletRepository.factoryReset to propagate
errors instead of merely calling Log.w(TAG, ...).

Comment on lines +129 to +135
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
color = MaterialTheme.colorScheme.onError,
strokeWidth = 2.dp,
)
Spacer(modifier = Modifier.height(8.dp))
Text(stringResource(R.string.forgot_pin_resetting))
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Use horizontal spacing between spinner and label in the button.

At Line 134, Spacer(height = 8.dp) is inside a row-like button content, so it won’t create the intended gap between spinner and text.

Proposed fix
 import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.height
 import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
 ...
-    Spacer(modifier = Modifier.height(8.dp))
+    Spacer(modifier = Modifier.width(8.dp))
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
color = MaterialTheme.colorScheme.onError,
strokeWidth = 2.dp,
)
Spacer(modifier = Modifier.height(8.dp))
Text(stringResource(R.string.forgot_pin_resetting))
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
color = MaterialTheme.colorScheme.onError,
strokeWidth = 2.dp,
)
Spacer(modifier = Modifier.width(8.dp))
Text(stringResource(R.string.forgot_pin_resetting))
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@android/app/src/main/java/com/rjnr/pocketnode/ui/screens/auth/ForgotPinScreen.kt`
around lines 129 - 135, The inline spinner and label in the button use
Spacer(modifier = Modifier.height(8.dp)) which adds vertical space instead of
horizontal; inside the button content (in ForgotPinScreen.kt near
CircularProgressIndicator and
Text(stringResource(R.string.forgot_pin_resetting))) replace the vertical spacer
with a horizontal spacer (Modifier.width(...), e.g., 8.dp) so there is proper
horizontal spacing between CircularProgressIndicator and the Text.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f637be00c1

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +376 to +380
runCatching { deleteWallet(sub.walletId) }
.onFailure { Log.w(TAG, "factoryReset: sub ${sub.walletId} delete failed", it) }
}
runCatching { deleteWallet(parent.walletId) }
.onFailure { Log.w(TAG, "factoryReset: parent ${parent.walletId} delete failed", it) }
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Fail reset when any wallet deletion fails

factoryReset() currently wraps each deleteWallet() in runCatching and only logs failures, so the function can return success after partial deletion. In the Forgot-PIN flow, executeReset() treats that as success and proceeds to remove the PIN and restart the app, which can leave undeleted wallets/key material while presenting the reset as completed. This is a critical recovery-path inconsistency; the reset should surface failure (or aggregate and throw) when any wallet deletion fails.

Useful? React with 👍 / 👎.

@RaheemJnr RaheemJnr merged commit 4fe2dc8 into main May 22, 2026
7 checks passed
@RaheemJnr RaheemJnr deleted the fix/forgot-pin-destructive-recovery branch May 22, 2026 09:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant