Skip to content

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

Open
llbartekll wants to merge 4 commits into
v3.1-devfrom
delete-wallet
Open

feat(swift-sdk): deleteWallet wipes full wallet footprint#3653
llbartekll wants to merge 4 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: users can delete a wallet which fully removes associated identities, keys, persisted data, and metadata.
    • Deletion now clears keychain/private keys and ensures wallet state is removed so it no longer appears or syncs.
  • Behavior Changes

    • Persistance now supports “retiring” wallets so removed wallets no longer accept non-registration writes or trigger flushes.
    • Identity sync avoids persisting balances for unregistered identities.
  • Tests

    • Added tests validating deletion, data cleanup, retire behavior, and sync/no-persist after unregistering.

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 tidy the floor,
Deletion hops — all tests pass sure!

🚥 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 PR title directly describes the main feature: deleteWallet API that completely removes a wallet's footprint across Rust manager, SwiftData, Keychain, and sync state.
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

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.

Without a `ModelContainer`-backed persistence handler, the per-identity
Keychain sweep can't resolve the wallet's identity ids and the loop
silently skips, leaving `privkey_<identityHex>_*` and
`specialkey_<identityHex>_*` rows behind in the Keychain. Guard at
the top of `deleteWallet`, before the destructive FFI call, so the
caller gets a clear `invalidHandle` error instead of a partially
completed wipe.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@llbartekll llbartekll marked this pull request as ready for review May 17, 2026 17:07
@thepastaclaw
Copy link
Copy Markdown
Collaborator

thepastaclaw commented May 17, 2026

Review Gate

Commit: 29d2bafe

  • Debounce: 756m ago (need 30m)

  • CI checks: build failure: Swift SDK build / Swift SDK and Example build (warnings as errors)

  • CodeRabbit review: comment found

  • Off-peak hours: off-peak (10:13 PM PT Sunday)

  • Run review now (check to override)

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: 1

🤖 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/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift`:
- Around line 350-361: The PlatformWalletManager is removing the wallet via
platform_wallet_manager_remove_wallet and updating wallets before retrieving
per-identity IDs, which can leave a partial state if
persistenceHandler.identityIdsForWallet(walletId:) later throws; move the try
persistenceHandler.identityIdsForWallet(walletId: walletId) call to occur before
calling platform_wallet_manager_remove_wallet and before
wallets.removeValue(forKey:), so identityIds are resolved (and any errors
surface) prior to the FFI removal and in-memory mutation in the removeWallet
flow.
🪄 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: b3bbda40-bf4e-4618-8b7b-b6176b0cf78a

📥 Commits

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

📒 Files selected for processing (1)
  • packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift

Comment on lines +350 to +361
try walletId.withUnsafeBytes { raw in
guard let base = raw.baseAddress?.assumingMemoryBound(to: FFIByteTuple32.self) else {
throw PlatformWalletError.nullPointer(
"wallet_id buffer base address was nil"
)
}
try platform_wallet_manager_remove_wallet(handle, base).check()
}

wallets.removeValue(forKey: walletId)

let identityIds = try persistenceHandler.identityIdsForWallet(walletId: 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

Resolve identityIds before the destructive FFI removal.

If Line 361 throws after Line 356 succeeds, the wallet is already gone from Rust and wallets, but the per-identity keychain sweep never runs. Fetch the identity ids first so SwiftData read failures fail fast instead of turning into a partial wipe.

Suggested change
+        let identityIds = try persistenceHandler.identityIdsForWallet(walletId: walletId)
+
         try walletId.withUnsafeBytes { raw in
             guard let base = raw.baseAddress?.assumingMemoryBound(to: FFIByteTuple32.self) else {
                 throw PlatformWalletError.nullPointer(
                     "wallet_id buffer base address was nil"
                 )
             }
             try platform_wallet_manager_remove_wallet(handle, base).check()
         }
 
         wallets.removeValue(forKey: walletId)
-
-        let identityIds = try persistenceHandler.identityIdsForWallet(walletId: walletId)
         for identityId in identityIds {
             try KeychainManager.shared.deleteAllKeychainItems(forIdentityId: identityId)
         }
🤖 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 350 - 361, The PlatformWalletManager is removing the wallet via
platform_wallet_manager_remove_wallet and updating wallets before retrieving
per-identity IDs, which can leave a partial state if
persistenceHandler.identityIdsForWallet(walletId:) later throws; move the try
persistenceHandler.identityIdsForWallet(walletId: walletId) call to occur before
calling platform_wallet_manager_remove_wallet and before
wallets.removeValue(forKey:), so identityIds are resolved (and any errors
surface) prior to the FFI removal and in-memory mutation in the removeWallet
flow.

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