Skip to content

feat(swift-sdk): deleteWallet wipes full wallet footprint#3653

Draft
llbartekll wants to merge 3 commits into
v3.1-devfrom
delete-wallet
Draft

feat(swift-sdk): deleteWallet wipes full wallet footprint#3653
llbartekll wants to merge 3 commits into
v3.1-devfrom
delete-wallet

Conversation

@llbartekll
Copy link
Copy Markdown
Contributor

@llbartekll llbartekll commented May 15, 2026

Issue being fixed or feature implemented

A wallet deletion through the SDK left orphan data behind: SwiftData rows the @Relationship cascade graph doesn't reach (PersistentTransaction, PersistentPendingInput, PersistentTokenBalance), in-memory Rust manager state, per-identity Keychain entries, and the shared network sync checkpoint. The next wallet created on the same network observed ghost rows from the deleted one. The same gap was present in the SDK's own example app — its WalletDetailView.deleteWallet() carried a TODO acknowledging the missing PlatformWalletManager removal API.

What was done?

Adds PlatformWalletManager.deleteWallet(walletId:) throws, a single SDK call that wipes a wallet's complete footprint:

  • Rust manager state: a new platform_wallet_manager_remove_wallet FFI drops the wallet from the in-memory map, snapshots its identities, and unregisters them from IdentitySyncManager so per-identity token-balance polling stops.
  • Stale-callback fence: the FFI persister now has a retire_wallet concept — non-registration writes for retired wallet ids are dropped at the Rust→Swift boundary. Registration changesets auto-clear the retired flag, so re-importing the same seed works. This closes the race where Rust tasks already in flight when the delete fired could resurrect rows in SwiftData via ensureWalletRecord.
  • Identity-sync race: IdentitySyncManager::apply_fresh_balances re-checks the live state under its write lock before persisting, so a balance fetched mid-sync for a just-unregistered identity is dropped on the floor.
  • SwiftData wipe: cascades the wallet's identities (schema rule is .nullify; this explicit path takes them down), sweeps PersistentPendingInput by walletId denorm, sweeps PersistentTransaction rows now reachable by nothing, sweeps PersistentTokenBalance for the cascaded identity ids, and deletes the network sync state row only when no sibling wallet remains on that network.
  • Keychain wipe: per-identity privkey_* + specialkey_* rows and wallet-derived identity_privkey.<path> rows, then WalletStorage metadata then mnemonic last (mnemonic-as-retry-anchor — if any earlier step fails, the pre-flight on retry still sees it).

The example app's WalletDetailView becomes the first consumer; dashwallet-ios follows.

How Has This Been Tested?

  • New WalletDeletionTests (Swift, against in-memory ModelContainer): cascade + orphan-tx sweep, idempotency under double-call, network-sync conditional cleanup (both last-wallet and sibling-remains branches), and per-scheme Keychain sweeps with an isolated keychain service.
  • New persister unit tests in rs-platform-wallet-ffi: retired_wallet_drops_non_registration_store_and_flush and registration_store_clears_retirement pin the retire/auto-unretire semantics.
  • cargo check -p platform-wallet, cargo clippy -p platform-wallet-ffi clean.
  • build_ios.sh --target sim --profile dev succeeds with -warnings-as-errors.
  • All WalletDeletionTests pass under xcodebuild test.

Worth a simulator smoke pass before merging: create a wallet, sync a bit, delete it, create another on the same network, confirm StorageExplorerView shows zero ghosts for the deleted walletId across every Persistent* table.

Breaking Changes

KeychainManager.deleteAllPrivateKeys(for:) and KeychainManager.deleteAllIdentityPrivateKeys(forWalletId:) are now throws (were -> Bool previously, swallowing keychain errors). External consumers — including dashwallet-ios — will need to try the calls.

Conventional-commit type is feat, not feat!, because the breaking surface is limited to two helper methods whose previous Bool return value didn't distinguish "nothing to delete" from "delete failed" — call sites that relied on the boolean were already silently wrong. Happy to retitle to feat! if reviewers prefer.

Checklist:

  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have added or updated relevant unit/integration/functional/e2e tests
  • I have added "!" to the title and described breaking changes in the corresponding section if my code contains any
  • I have made corresponding changes to the documentation if needed

For repository code-owners and collaborators only

  • I have assigned this pull request to a milestone

Summary by CodeRabbit

  • New Features

    • Added wallet deletion functionality allowing users to remove wallets from the app.
    • Wallet deletion now automatically cleans up all associated data, including identities, security keys, and transaction history.
    • Platform state is properly managed to prevent lingering wallet references after deletion.
  • Tests

    • Added comprehensive test suite validating wallet deletion behavior and data cleanup.

Review Change Stack

llbartekll and others added 3 commits May 15, 2026 14:31
`PlatformWalletManager.deleteWallet(walletId:)` wipes the wallet's Rust
manager state, SwiftData rows (including orphans the `@Relationship`
graph misses: `PersistentTransaction`, `PersistentPendingInput`,
`PersistentTokenBalance`), identity-keyed and wallet-keyed Keychain
entries, and the network sync state when no sibling wallets remain.

Closes the data-leakage path where `modelContext.delete(wallet)` alone
left orphan rows on the same network for the next wallet to see — the
bug that prompted this on dashwallet-ios and was present in the SDK's
own example app.

Rust-side adds:
- `platform_wallet_manager_remove_wallet` FFI (idempotent on missing).
- `PlatformWalletPersistence::retire_wallet` trait method (default no-op).
- `FFIPersister` retired set: drops non-registration `store`/`flush`
  for retired wallets and auto-clears on a `wallet_metadata`-bearing
  changeset so a same-session reimport recovers.
- `PlatformWalletManager::remove_wallet` snapshots the wallet's
  identity ids and `IdentitySyncManager::unregister_identity`s each
  before dropping the wallet, so per-identity token sync stops.
- `IdentitySyncManager::apply_fresh_balances` re-checks the live
  state under its write lock before persisting, closing the race
  where a mid-sync unregister could still emit a token balance.

Swift-side adds:
- `PlatformWalletPersistenceHandler.deleteWalletData` + companion
  `identityIdsForWallet` for the orchestration.
- `KeychainManager.deleteAllKeychainItems(forIdentityId:)` (sweeps
  both `privkey_*` and `specialkey_*` schemes) and
  `deleteAllIdentityPrivateKeysForWallet(walletId:)` throwing variants
  so partial-failure surfaces instead of silently passing.

Tests:
- `WalletDeletionTests` (Swift, in-memory `ModelContainer`): cascade
  + orphan-tx sweep, idempotency, network-sync conditional cleanup
  (both branches), and per-scheme Keychain sweeps.
- `retired_wallet_drops_non_registration_store_and_flush` and
  `registration_store_clears_retirement` (Rust): persister retirement
  semantics.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Removes the `do { try X; return true } catch { return false }` bridge
pattern around `deleteAllPrivateKeys(for:)` and
`deleteAllIdentityPrivateKeys(forWalletId:)`. The throwing variants
take over the canonical names; callers `try` directly. Drops the
parallel `*Throwing` / `*ForWallet` names.

Replaces seven inline `map { String(format: "%02x", $0) }.joined()`
hex-format duplications with the existing `Data.toHexString()`
extension, and removes the same-shape `hex(_:)` helper from
`WalletDeletionTests` in favor of the same extension.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot added this to the v3.1.0 milestone May 15, 2026
@llbartekll llbartekll marked this pull request as draft May 15, 2026 13:40
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 15, 2026

📝 Walkthrough

Walkthrough

This pull request implements complete wallet deletion across the platform. The Rust backend introduces a retirement mechanism to gate stale persistence writes, coordinates identity unregistration with wallet removal, and robustifies identity syncing to handle unregistered identities. The Swift layer adds deletion APIs for SwiftData and Keychain, and orchestrates the full cleanup workflow including FFI calls and in-memory state updates.

Changes

Wallet Deletion Feature

Layer / File(s) Summary
Rust persistence retirement mechanism
packages/rs-platform-wallet-ffi/src/persistence.rs, packages/rs-platform-wallet/src/changeset/traits.rs
FFIPersister gains a retired set protected by RwLock<BTreeSet<WalletId>>. The store method skips non-registration writes for retired wallets and clears retirement on registration. The flush method no-ops for retired wallets. A new PlatformWalletPersistence::retire_wallet trait method provides the hook; FFIPersister::retire_wallet removes pending changesets and marks the wallet as retired. Tests verify retirement prevents staging and suppresses flush notifications while registration clears the retired flag.
Rust identity sync robustness
packages/rs-platform-wallet/src/manager/identity_sync.rs
sync_identity refactored to delegate post-fetch handling to apply_fresh_balances helper, which now checks if the identity still exists before persisting and rebuilding cache. If the identity was unregistered mid-sync, it returns early to skip writes. Token cache is rebuilt from existing cached tokens using fresh balances to update/drop/preserve rows. Tests include a RecordingPersister and unregistered_identity_does_not_persist_fresh_balances test validating the early-exit path.
Rust wallet removal coordination
packages/rs-platform-wallet-ffi/src/manager.rs, packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs
New platform_wallet_manager_remove_wallet FFI function wraps manager.remove_wallet(...) with idempotent error handling (missing wallet = success). The internal remove_wallet now calls persister.retire_wallet() before in-memory removal, collects all owned identity IDs from wallet metadata, and asynchronously unregisters each identity via identity_sync_manager.unregister_identity().
Swift persistence and keychain cleanup
packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift, packages/swift-sdk/Sources/SwiftDashSDK/Security/KeychainManager.swift
PlatformWalletPersistenceHandler adds identityIdsForWallet(walletId:) to fetch linked identity IDs and deleteWalletData(walletId:) to cascade-delete wallet entities (identities, balances, pending inputs, orphan transactions) with conditional sync-state cleanup when no sibling wallets remain. The onQueue helper now throws to propagate errors. KeychainManager refactors deleteAllPrivateKeys(for:) to throw and adds deleteAllKeychainItems(forIdentityId:) to delete both privkey_* and specialkey_* entries, plus deleteAllIdentityPrivateKeys(forWalletId:) to sweep wallet-scoped keys. Helpers use consistent hex formatting via toHexString().
Swift platform wallet deletion API
packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift
New deleteWallet(walletId:) validates wallet ID length, calls Rust FFI to remove the wallet, updates in-memory wallets map, deletes keychain identity keys and wallet private keys, clears SwiftData via deleteWalletData, and deletes wallet metadata and mnemonic from local storage.
Swift UI and test coverage
packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift, packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/WalletDeletionTests.swift, packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/KeyManagerTests.swift
WalletInfoView injects walletManager and delegates deleteWallet() to the manager instead of direct SwiftData deletion. WalletDeletionTests adds four comprehensive tests: identity ID lookup, cascade deletion with live transaction preservation, multi-wallet sync-state handling, and keychain sweeping. Includes test helpers for sync-state scope ID computation and model fetching. KeyManagerTests updated to use network: .testnet parameter.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐰 A wallet goes to rest,
Its identities find their nest,
Keys retire from the store,
Swift and Rust dance once more,
Clean deletion, tested best!

🚥 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 'feat(swift-sdk): deleteWallet wipes full wallet footprint' clearly summarizes the main feature addition of a deleteWallet API that comprehensively removes wallet data across multiple systems (Rust, Swift, Keychain, sync state). It directly reflects the primary objective stated in the PR description.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
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 delete-wallet

Tip

💬 Introducing Slack Agent: The best way for teams to turn conversations into code.

Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.

  • Generate code and open pull requests
  • Plan features and break down work
  • Investigate incidents and troubleshoot customer tickets together
  • Automate recurring tasks and respond to alerts with triggers
  • Summarize progress and report instantly

Built for teams:

  • Shared memory across your entire org—no repeating context
  • Per-thread sandboxes to safely plan and execute work
  • Governance built-in—scoped access, auditability, and budget controls

One agent for your entire SDLC. Right inside Slack.

👉 Get started


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.

@github-actions
Copy link
Copy Markdown
Contributor

✅ DashSDKFFI.xcframework built for this PR.

SwiftPM (host the zip at a stable URL, then use):

.binaryTarget(
  name: "DashSDKFFI",
  url: "https://your.cdn.example/DashSDKFFI.xcframework.zip",
  checksum: "6a7d38f3ff70b54502868ce2a3de260b42bce1c295697faebb4d671f9942302c"
)

Xcode manual integration:

  • Download 'DashSDKFFI.xcframework' artifact from the run link above.
  • Drag it into your app target (Frameworks, Libraries & Embedded Content) and set Embed & Sign.
  • If using the Swift wrapper package, point its binaryTarget to the xcframework location or add the package and place the xcframework at the expected path.

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/rs-platform-wallet/src/manager/identity_sync.rs (1)

570-587: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Avoid calling external persistence while holding state write lock.

self.persister.store(...) runs under self.state.write(). Because this is an external call, it can stall or re-enter paths that need state, blocking register/unregister/update_watched_tokens and creating deadlock risk.

Take a snapshot under lock, release lock before store, then reacquire and re-check identity before applying cache updates.

🤖 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 `@packages/rs-platform-wallet/src/manager/identity_sync.rs` around lines 570 -
587, Currently persister.store(...) is called while holding the
self.state.write() lock which can deadlock; instead, inside identity_sync.rs
(around the code using state.get(&identity_id), existing_row, sentinel, and cs)
clone or take a snapshot of the changeset (cs) and any needed existing_row data
while holding the write lock, then drop the lock before calling
self.persister.store(sentinel, cs_snapshot), and after store returns reacquire
the write lock and re-check that the identity still exists
(state.get(&identity_id)) before applying any cache updates or mutations; ensure
you handle and log persister.store errors the same way but performed outside the
lock.
🧹 Nitpick comments (2)
packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/WalletDeletionTests.swift (2)

149-191: ⚡ Quick win

Add a non-matching wallet control for the wallet-scoped key sweep.

This only proves that the targeted walletId row is removed. Because deleteAllIdentityPrivateKeys(forWalletId:) enumerates the entire service, a broken filter that deletes every identity_privkey.* item would still satisfy this test. Seed a second row with a different walletId and assert it survives.

🤖 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
`@packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/WalletDeletionTests.swift`
around lines 149 - 191, The test testThrowingKeychainSweepsUseIsolatedService
currently only inserts one identity private key so
deleteAllIdentityPrivateKeys(forWalletId:) could accidentally delete all
identity_privkey.* rows; insert a second identity private key via
KeychainManager.storeIdentityPrivateKey with a different walletId (e.g.,
walletId2 and publicKey2) before calling
manager.deleteAllIdentityPrivateKeys(forWalletId: walletId) and after the
deletion assert that retrieveIdentityPrivateKey(publicKeyHex: publicKey2) still
returns non-nil while the original publicKey is nil, ensuring the sweep is
scoped to the targeted walletId.

38-120: ⚡ Quick win

Exercise a transaction that becomes orphaned because of the wallet cascade.

orphanTx starts out orphaned before deletion, so this test still passes if deleteWalletData(walletId:) misses transactions that only become orphaned after wallet/account/TXO rows are removed. Please add one wallet-owned TXO/transaction pair and assert that the transaction is swept after the delete.

🤖 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
`@packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/WalletDeletionTests.swift`
around lines 38 - 120, Add a transaction+TXO pair that is initially owned by the
wallet and therefore removed by the wallet cascade: create a new
PersistentTransaction named walletOwnedTx and a matching PersistentTxo named
walletOwnedTxo that is associated with the wallet (set whichever field ties a
txo to a wallet in your model—e.g., walletId or an address/ownership field) and
append walletOwnedTxo to walletOwnedTx.outputs, insert both into the context
alongside the existing objects before saving, then after calling
PlatformWalletPersistenceHandler.deleteWalletData(walletId:) assert that the
fetched PersistentTransaction list no longer contains walletOwnedTx (i.e.,
transactions count remains 1 and walletOwnedTx is gone). This ensures
transactions that only become orphaned by the wallet cascade are swept.
🤖 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 `@packages/rs-platform-wallet-ffi/src/persistence.rs`:
- Around line 416-422: store() currently only checks the retired set once and
can persist changes if retire_wallet() runs concurrently, allowing stale writes
for just-retired wallets; modify the persistence path to coordinate retirement
and writes atomically by introducing a per-wallet lifecycle lock or state
machine (e.g., a per-wallet mutex/atomic state) and use it in both store() and
retire_wallet() so that store() acquires the wallet lock/state before running
the callback and verifies retirement state again (or aborts) before committing
pending merges; ensure you reference and protect the same retired set and the
code paths that examine changeset.wallet_metadata and perform pending merges so
no post-retire writes can be committed.

In
`@packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift`:
- Around line 349-353: The deleteWallet(walletId:) flow calls
persistenceHandler?.identityIdsForWallet(...) and proceeds to call
KeychainManager.shared.deleteAllIdentityPrivateKeys(forWalletId:) even when
persistenceHandler is nil (e.g., configure(..., modelContainer: nil)), which can
leave private keys in Keychain; update deleteWallet(walletId:) to guard that
persistenceHandler is present and identityIds were resolved before performing
any destructive Keychain operations—either make persistenceHandler mandatory for
this path (throw an error if nil) or explicitly fail early when identityIds
cannot be retrieved, using the existing persistenceHandler and
identityIdsForWallet(...) check to decide whether to proceed with
KeychainManager.shared.deleteAllKeychainItems(...) and
deleteAllIdentityPrivateKeys(...).

---

Outside diff comments:
In `@packages/rs-platform-wallet/src/manager/identity_sync.rs`:
- Around line 570-587: Currently persister.store(...) is called while holding
the self.state.write() lock which can deadlock; instead, inside identity_sync.rs
(around the code using state.get(&identity_id), existing_row, sentinel, and cs)
clone or take a snapshot of the changeset (cs) and any needed existing_row data
while holding the write lock, then drop the lock before calling
self.persister.store(sentinel, cs_snapshot), and after store returns reacquire
the write lock and re-check that the identity still exists
(state.get(&identity_id)) before applying any cache updates or mutations; ensure
you handle and log persister.store errors the same way but performed outside the
lock.

---

Nitpick comments:
In
`@packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/WalletDeletionTests.swift`:
- Around line 149-191: The test testThrowingKeychainSweepsUseIsolatedService
currently only inserts one identity private key so
deleteAllIdentityPrivateKeys(forWalletId:) could accidentally delete all
identity_privkey.* rows; insert a second identity private key via
KeychainManager.storeIdentityPrivateKey with a different walletId (e.g.,
walletId2 and publicKey2) before calling
manager.deleteAllIdentityPrivateKeys(forWalletId: walletId) and after the
deletion assert that retrieveIdentityPrivateKey(publicKeyHex: publicKey2) still
returns non-nil while the original publicKey is nil, ensuring the sweep is
scoped to the targeted walletId.
- Around line 38-120: Add a transaction+TXO pair that is initially owned by the
wallet and therefore removed by the wallet cascade: create a new
PersistentTransaction named walletOwnedTx and a matching PersistentTxo named
walletOwnedTxo that is associated with the wallet (set whichever field ties a
txo to a wallet in your model—e.g., walletId or an address/ownership field) and
append walletOwnedTxo to walletOwnedTx.outputs, insert both into the context
alongside the existing objects before saving, then after calling
PlatformWalletPersistenceHandler.deleteWalletData(walletId:) assert that the
fetched PersistentTransaction list no longer contains walletOwnedTx (i.e.,
transactions count remains 1 and walletOwnedTx is gone). This ensures
transactions that only become orphaned by the wallet cascade are swept.
🪄 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: 755ba89b-4903-4bcd-a4b0-33c76ed16508

📥 Commits

Reviewing files that changed from the base of the PR and between dfb6d84 and 99c5287.

📒 Files selected for processing (11)
  • packages/rs-platform-wallet-ffi/src/manager.rs
  • packages/rs-platform-wallet-ffi/src/persistence.rs
  • packages/rs-platform-wallet/src/changeset/traits.rs
  • packages/rs-platform-wallet/src/manager/identity_sync.rs
  • packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs
  • packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift
  • packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift
  • packages/swift-sdk/Sources/SwiftDashSDK/Security/KeychainManager.swift
  • packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift
  • packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/KeyManagerTests.swift
  • packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/WalletDeletionTests.swift

Comment on lines +416 to +422
if wallet_id != WalletId::default() {
if changeset.wallet_metadata.is_some() {
self.retired.write().remove(&wallet_id);
} else if self.retired.read().contains(&wallet_id) {
return Ok(());
}
}
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 | 🏗️ Heavy lift

Retirement gate has a race window against concurrent retire_wallet.

store() only checks retired once up front, then runs callback persistence and pending merge later. If retire_wallet() runs between those points, stale writes can still be persisted for a just-retired wallet. This undermines the “drop post-delete writes” guarantee.

Consider a per-wallet lifecycle lock/state machine so retirement check + persistence path are coordinated atomically for non-registration writes.

🤖 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 `@packages/rs-platform-wallet-ffi/src/persistence.rs` around lines 416 - 422,
store() currently only checks the retired set once and can persist changes if
retire_wallet() runs concurrently, allowing stale writes for just-retired
wallets; modify the persistence path to coordinate retirement and writes
atomically by introducing a per-wallet lifecycle lock or state machine (e.g., a
per-wallet mutex/atomic state) and use it in both store() and retire_wallet() so
that store() acquires the wallet lock/state before running the callback and
verifies retirement state again (or aborts) before committing pending merges;
ensure you reference and protect the same retired set and the code paths that
examine changeset.wallet_metadata and perform pending merges so no post-retire
writes can be committed.

Comment on lines +349 to +353
let identityIds = try persistenceHandler?.identityIdsForWallet(walletId: walletId) ?? []
for identityId in identityIds {
try KeychainManager.shared.deleteAllKeychainItems(forIdentityId: identityId)
}
try KeychainManager.shared.deleteAllIdentityPrivateKeys(forWalletId: walletId)
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

Guard the no-persistence path before claiming a full wipe.

configure(..., modelContainer: nil) is still a supported setup, but in that mode identityIds is always empty here, so privkey_* and specialkey_* rows are never swept. The method can therefore succeed while leaving identity key material behind in Keychain. Either make a persistence handler mandatory for deleteWallet(walletId:), or fail before the destructive FFI call when the identity list cannot be resolved.

🤖 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
`@packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift`
around lines 349 - 353, The deleteWallet(walletId:) flow calls
persistenceHandler?.identityIdsForWallet(...) and proceeds to call
KeychainManager.shared.deleteAllIdentityPrivateKeys(forWalletId:) even when
persistenceHandler is nil (e.g., configure(..., modelContainer: nil)), which can
leave private keys in Keychain; update deleteWallet(walletId:) to guard that
persistenceHandler is present and identityIds were resolved before performing
any destructive Keychain operations—either make persistenceHandler mandatory for
this path (throw an error if nil) or explicitly fail early when identityIds
cannot be retrieved, using the existing persistenceHandler and
identityIdsForWallet(...) check to decide whether to proceed with
KeychainManager.shared.deleteAllKeychainItems(...) and
deleteAllIdentityPrivateKeys(...).

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