Skip to content

fix(wallet): prevent multi-wallet global wipe - #1014

Merged
llbartekll merged 2 commits into
developfrom
codex/fix-multi-wallet-wipe-safety
Aug 17, 2026
Merged

fix(wallet): prevent multi-wallet global wipe#1014
llbartekll merged 2 commits into
developfrom
codex/fix-multi-wallet-wipe-safety

Conversation

@QuantumExplorer

@QuantumExplorer QuantumExplorer commented Aug 13, 2026

Copy link
Copy Markdown
Member

Summary

  • require strict Keychain enumeration before a Wallets-screen removal may enter the global reset flow
  • require a recovery phrase to match every stored SDK wallet before authorizing a global wipe
  • allow the plain wipe shortcut only when exactly one wallet ID is stored
  • keep forgotten-PIN recovery non-destructive by using a separate any-wallet ownership check

Root cause

The Wallets screen treated its rendered row count as the complete wallet inventory, but the global reset deletes SDK mnemonics across networks, including wallets not loaded in the current view. The recovery flow also accepted a phrase matching any persisted wallet, and its zero-balance shortcut checked only the active wallet. Together, those paths could authorize deleting a distinct second wallet.

Safety behavior

Destructive authorization now fails closed if wallet enumeration or any mnemonic read fails. Multiple network-scoped IDs remain valid when they all resolve to the same recovery phrase.

Verification

  • canonical dashpay arm64 simulator build passed
  • changed Swift sources passed swiftc -parse
  • Xcode project passed plutil -lint
  • staged patch passed git diff --check
  • unit tests were not run because the repository's test target is documented as pre-existing broken

Summary by CodeRabbit

  • Security Improvements

    • Strengthened authorization for full wallet resets by validating all stored wallet recovery phrases.
    • Destructive reset actions now fail safely when wallet information is missing, unreadable, hidden, or inconsistent.
    • Updated recovery checks to account for multiple stored wallets and distinct recovery phrases.
  • Bug Fixes

    • Reset PIN verification now uses the correct recovery-phrase validation rules.
    • Improved handling of wallet-empty checks and recovery flows when wallet data cannot be confirmed.

@coderabbitai

coderabbitai Bot commented Aug 13, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@QuantumExplorer, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 13 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 0ae39021-ea86-4976-954d-b81e1fc673fe

📥 Commits

Reviewing files that changed from the base of the PR and between fe9d675 and 6ffdfda.

📒 Files selected for processing (10)
  • DASHSYNC_KEY_MIGRATION.md
  • DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKHost.swift
  • DashWallet/Sources/UI/Menu/Security/SecurityMenuViewModel.swift
  • DashWallet/Sources/UI/Menu/Security/Wallets/WalletsScreen.swift
  • DashWallet/Sources/UI/Menu/Security/Wallets/WalletsViewModel.swift
  • DashWallet/Sources/UI/Setup/RecoverWallet/DWRecoverModel+Mnemonic.swift
  • DashWallet/Sources/UI/Setup/RecoverWallet/DWRecoverModel.m
  • DashWallet/Sources/UI/Setup/RecoverWallet/DWRecoverViewController.m
  • DashWallet/Sources/UI/Setup/RecoverWallet/Views/DWRecoverContentView.h
  • DashWallet/Sources/UI/Setup/RecoverWallet/Views/DWRecoverContentView.m

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 90731e4d-1c7a-464b-83e2-888153c548a1

📥 Commits

Reviewing files that changed from the base of the PR and between ddec2a4 and fe9d675.

📒 Files selected for processing (7)
  • DASHSYNC_KEY_MIGRATION.md
  • DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKHost.swift
  • DashWallet/Sources/UI/Menu/Security/Wallets/WalletsViewModel.swift
  • DashWallet/Sources/UI/Setup/RecoverWallet/DWRecoverModel+Mnemonic.swift
  • DashWallet/Sources/UI/Setup/RecoverWallet/DWRecoverModel.h
  • DashWallet/Sources/UI/Setup/RecoverWallet/DWRecoverModel.m
  • DashWallet/Sources/UI/Setup/RecoverWallet/Views/DWRecoverContentView.m

📝 Walkthrough

Walkthrough

Changes

Wallet authorization flows

Layer / File(s) Summary
Strict persisted mnemonic reader
DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKHost.swift
Adds a strict mnemonic reader that returns all persisted wallet data and propagates Keychain enumeration or read failures.
Destructive wallet authorization
DashWallet/Sources/UI/Menu/Security/Wallets/WalletsViewModel.swift, DashWallet/Sources/UI/Setup/RecoverWallet/DWRecoverModel+Mnemonic.swift, DASHSYNC_KEY_MIGRATION.md
Global wipe routing and phrase authorization now validate every persisted wallet, require normalized non-empty phrases, and fail closed when data is missing or unreadable.
PIN reset authorization integration
DashWallet/Sources/UI/Setup/RecoverWallet/Views/DWRecoverContentView.m, DashWallet/Sources/UI/Setup/RecoverWallet/DWRecoverModel+Mnemonic.swift, DashWallet/Sources/UI/Setup/RecoverWallet/DWRecoverModel.h, DashWallet/Sources/UI/Setup/RecoverWallet/DWRecoverModel.m
Reset PIN uses canResetPinWithPhrase: and documents its separate authorization behavior.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Mergeability Score: ⚪ Minimal · up to fe9d6

The PR tightens wallet enumeration and recovery authorization before destructive resets; no actionable merge-blocking risk remains, so it is merge-ready after normal checks and review.

Suggested reviewers: llbartekll

Sequence Diagram(s)

sequenceDiagram
  participant WalletsViewModel
  participant SwiftDashSDKHost
  participant Keychain
  participant DWRecoverModelMnemonic
  WalletsViewModel->>SwiftDashSDKHost: request strict persisted mnemonics
  SwiftDashSDKHost->>Keychain: enumerate wallet IDs and read mnemonics
  Keychain-->>SwiftDashSDKHost: complete wallet set or read error
  SwiftDashSDKHost-->>WalletsViewModel: return mnemonics or fail
  WalletsViewModel->>DWRecoverModelMnemonic: validate recovery phrase across wallet set
  DWRecoverModelMnemonic-->>WalletsViewModel: authorize or deny destructive flow
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: preventing unsafe global wipes in multi-wallet scenarios.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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.
✨ 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 codex/fix-multi-wallet-wipe-safety

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

@QuantumExplorer
QuantumExplorer marked this pull request as ready for review August 13, 2026 17:05

@QuantumExplorer QuantumExplorer left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Automated deep review (10 finder angles + adversarial verification). The core fail-closed logic is sound: normalization is applied on both sides of every comparison, the strict enumeration genuinely closes the partial-read hole, and no fail-open path was found in the changed code. But the routing change creates one real functional regression, plus several smaller issues. Inline comments cover the findings anchored to changed lines; findings in files this PR doesn't touch are below. A follow-up commit on this branch applies fixes for most of these.

Findings outside the diff:

  1. Removal dead-end / removeWallet guard scope (WalletsViewModel.swift:322): when removalRoutesToFullReset returns false for the sole rendered row (distinct-phrase wallet stored only on the other network, or any keychain failure), beginRemove opens the per-wallet remove sheet, the user's phrase verifies (recoveryPhraseMatches derives locally, no keychain), and then removeWallet refuses with "Cannot remove the last wallet here." — a false message, after walking the user through phrase entry. The reset flow holding the strong accept-phrase override is only presented when routing returns true, so the documented "global emergency override" is unreachable exactly when routing denies it. Repro: wallet A on mainnet → switch to testnet (mirror persists A) → add distinct-phrase wallet B → remove testnet-A → switch back to mainnet → try to remove A. Also, the last-wallet guard fires only when walletId == activeWalletId() (a UserDefaults registry read); a sole non-active row would be hard-deleted via deleteWalletFromSDK with none of the reset teardown.

  2. WalletsScreen.swift:249-251 comments are now stale: "The last remaining wallet routes to the existing full reset flow…" — post-PR, routing can return false for the last rendered wallet, so the comments (and the removeWallet precondition doc at WalletsViewModel.swift:309-310) no longer state what the code does.

  3. Pre-existing, surfaced because this PR's doc claims set-wide authorization for "the global wipe": two phrase-less routes reach DWSwiftDashSDKWalletWiper.wipeWallet(removingPin:) with no set-wide check — (a) the lock-screen wipe offered after 6 failed PIN attempts (DWLockScreenViewController.m:172-238, unauthenticated, alert copy says "erase this wallet" singular while erasing every wallet on both networks); (b) "Reset Wallet (Debug)" (SecurityMenuViewModel.swift:121-127) is appended with no #if gate and its tap path skips viewModel.authenticate, so a Release-build user can trigger an immediate global wipe with no PIN and no phrase. The wiper boundary itself accepts no authorization evidence — a new entry point added tomorrow silently regains the vulnerability; longer-term, set-wide authorization belongs in a single boundary in front of the wiper.

  4. WalletsViewModel.hasSingleWallet (:128) now has zero consumers yet preserves verbatim the "rendered row count = last-wallet test" this PR declares unsafe — worth deleting before a future caller reaches for it.

🤖 Generated with Claude Code

/// last-wallet test. Prove from Keychain ground truth that every stored
/// wallet id resolves to the same recovery phrase as the target. Any
/// enumeration/read failure returns false (fail closed).
func removalRoutesToFullReset(walletId: Data) -> Bool {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Routing regression — sole rendered wallet can dead-end. false from this method no longer implies "≥2 removable wallets on this network": it is also returned for the sole rendered row when a distinct-phrase wallet exists only on the other network, or when the strict enumeration throws. In those states the screen opens the per-wallet remove sheet, the phrase verifies, and removeWallet then refuses ("Cannot remove the last wallet here.") — a guaranteed dead-end with a false error, and the reset flow carrying the strong accept-phrase override is unreachable. Suggest refusing up front in beginRemove with honest copy when routing is false and no sibling row exists.

let entries = try SwiftDashSDKHost.strictlyPersistedMnemonics()
guard let target = entries.first(where: { $0.walletId == walletId }) else { return false }
let targetMnemonic = Mnemonic.normalizePhrase(target.mnemonic)
return !targetMnemonic.isEmpty && entries.allSatisfy {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Reuse: this normalize-and-compare-all predicate now exists three times (here, canWipeWithPhrase, canResetPinWithPhrase) with already-divergent guard/logging styles (do/catch+log here, silent try? there). The Remove-routing gate and the wipe-authorization gate are two halves of one policy — divergence between them is exactly the bug class this PR closes. Suggest one host-level helper next to strictlyPersistedMnemonics() (e.g. allStoredMnemonicsMatch(phrase:)), consumed by both.

@objc(isWalletEmpty)
func isWalletEmpty() -> Bool {
guard let entries = try? SwiftDashSDKHost.strictlyPersistedMnemonics(),
entries.count == 1 else { return false }

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Three issues in this gate:

  1. Wrong-network emptiness (residual hole): the surviving arm certifies emptiness from SwiftDashSDKWalletState.shared.balanceactive-network state. Single stored id created while on testnet, seed also holds mainnet DASH: testnet balance 0 + syncDone → plain-"wipe" deletes the only copy of a mainnet-funded seed with no phrase. Consider requiring WalletEnvironment.isMainnet as well.

  2. False refusal reason: the id-count gate is intentionally conservative (correctly so — don't relax it to phrase-count), but the network-switch mirror permanently stores a second id for the same seed (SwiftDashSDKHost.recoverPersistedWallet re-stores under the new network's id; nothing prunes it), so any single-seed user who ever toggled networks is refused here — and the alert then claims "This wallet is not empty or sync has not finished", which is false. The caller should distinguish the multi-id refusal and say so.

  3. Efficiency/secret hygiene: this materializes every mnemonic String via N secret-returning keychain reads just to evaluate count == 1, and does so before the cheap in-memory balance/sync checks. WalletStorage.listWalletIdsWithMnemonic() is attributes-only, throws on failure, and exactly implements the documented "exactly one stored wallet id" gate.

func canWipeWithPhrase(_ phrase: String) -> Bool {
let typed = Mnemonic.normalizePhrase(phrase)
guard !typed.isEmpty else { return false }
guard let entries = try? SwiftDashSDKHost.strictlyPersistedMnemonics(),

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Swallowed error + false user-facing copy. try? collapses three distinct causes — keychain read failure, a second distinct stored wallet, and a genuinely wrong phrase — into one false, surfaced as "Recovery phrase doesn't match" (DWRecoverViewController.m:217) with no log line (contrast removalRoutesToFullReset, which logs the same failure; the pre-PR lenient path also logged enumeration failures). In the wipe contexts not pre-gated by the Wallets screen (jailbreak-check wipe, setup recover-with-wallet), a multi-wallet user typing a phrase that correctly matches one wallet is told it doesn't match — they'll conclude their backup is wrong. Suggest: do/catch with logging, and a distinct "multiple wallets stored" alert (with the accept-phrase override hint) when the phrase matches ≥1 but not all.

/// wallet's BIP32/BIP44 xpub comparison — because BIP39 phrase→seed is
/// deterministic, but derives no key material. No persisted SDK mnemonic
/// ⇒ false (fail-closed; the strong accept-phrase still allows a wipe).
/// Authorizes the GLOBAL wipe with a recovery phrase (C6-D): the typed

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Nit (CLAUDE.md guardrail #6): the rewritten doc comment re-commits the plan-stage label "C6-D" (here and in DWRecoverModel.m:53), which is defined nowhere in the repo — the comment rewrite was the moment to drop it.

Comment thread DASHSYNC_KEY_MIGRATION.md Outdated
that route.

Recovery-phrase authorization inside the global wipe is set-wide: the typed
phrase must match every strictly readable stored mnemonic (multiple

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Two spec-accuracy issues in this paragraph:

  1. "must match every strictly readable stored mnemonic" reads as subset-matching (unreadable entries exempt), but the code refuses outright when any entry is unreadable — and "strictly readable" is defined nowhere. A future implementer "fixing" the code to match this text would reopen the partially-readable-keychain hole this PR closes. State explicitly: any unreadable entry denies authorization.

  2. "authorization inside the global wipe" / "the global emergency override" over-claims: enforcement lives at the entry points this PR touched, not in DWSwiftDashSDKWalletWiper — and phrase-less routes to the wiper remain (lock-screen wipe after 6 failed PINs; Reset Wallet (Debug), currently Release-visible). The doc should name where enforcement lives and enumerate the unphrased routes, or the enforcement should move to the wiper boundary.

- refuse a sole rendered wallet's removal up front (honest copy) instead of
  walking the user through phrase verification into a guaranteed refusal when
  reset routing is denied (distinct cross-network wallet / keychain failure)
- make removeWallet's last-wallet bail independent of the active-wallet
  registry so a stale registry can never hard-delete the only rendered wallet
- restrict the plain-"wipe" shortcut to mainnet: a zero testnet balance
  cannot prove the seed's mainnet balance is empty
- explain wipe denials honestly: new alerts for "phrase matches one wallet
  of several" and the multi-id / testnet shortcut refusals, instead of the
  false "Recovery phrase doesn't match" / "not empty" copy
- log keychain failures in the wipe gates (do/catch) instead of silent try?
- consolidate the set-wide phrase predicate into SwiftDashSDKHost helpers
  (allStoredMnemonicsMatch / anyStoredMnemonicMatches / persistedWalletIdCount)
  consumed by routing, wipe auth, and forgot-PIN
- count wallet ids attributes-only in isWalletEmpty (no mnemonic secret
  reads just to count), cheap in-memory checks first
- compile-gate Reset Wallet (Debug) out of Release (#if DEBUG || DASH_TESTNET)
- delete unused hasSingleWallet (the row-count last-wallet test this PR
  declares unsafe), refresh stale routing comments, drop C6-D plan labels
- DASHSYNC_KEY_MIGRATION.md: state that unreadable entries deny outright,
  document where enforcement lives and the remaining phrase-less wiper routes

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@QuantumExplorer

Copy link
Copy Markdown
Member Author

Applied fixes for the review findings in 6ffdfda (canonical dashpay arm64 simulator build passes). Not addressed by that commit, by design: the lock-screen phrase-less wipe route (product decision — now documented in DASHSYNC_KEY_MIGRATION.md along with a TODO(wipe-auth) to move set-wide authorization to a single boundary in front of the wiper).

🤖 Generated with Claude Code

@llbartekll llbartekll left a comment

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.

Approved. We have three stacked follow-up PRs covering the remaining work: #1017, #1018, and #1016. They should be reviewed and merged sequentially after this PR.

@llbartekll
llbartekll merged commit 095ce49 into develop Aug 17, 2026
2 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.

2 participants