-
Notifications
You must be signed in to change notification settings - Fork 56
feat(swift-sdk): make wallet deletion asynchronous #4170
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: v4.1-dev
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -207,6 +207,12 @@ public class PlatformWalletManager: ObservableObject { | |
| private var modelContainer: ModelContainer? | ||
| private var signerNetwork: Network? | ||
|
|
||
| /// Async deletion yields while the persistence handler sweeps SwiftData. | ||
| /// Reject create/load/another delete during that window so actor | ||
| /// reentrancy cannot recreate a deterministic wallet id that the in-flight | ||
| /// deletion is about to remove from persistence. | ||
| private var walletDeletionInProgress = false | ||
|
|
||
| /// Retained for the lifetime of the FFI handle so the event-handler | ||
| /// context pointer remains valid. | ||
| private var eventHandler: PlatformWalletEventHandler? | ||
|
|
@@ -337,6 +343,7 @@ public class PlatformWalletManager: ObservableObject { | |
| birthHeight: UInt32? = nil | ||
| ) throws -> ManagedPlatformWallet { | ||
| try ensureConfigured() | ||
| try ensureNoWalletDeletionInProgress() | ||
| var walletHandle: Handle = NULL_HANDLE | ||
| var walletId: FFIByteTuple32 = | ||
| (0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0) | ||
|
|
@@ -379,6 +386,7 @@ public class PlatformWalletManager: ObservableObject { | |
| birthHeight: UInt32? = nil | ||
| ) throws -> ManagedPlatformWallet { | ||
| try ensureConfigured() | ||
| try ensureNoWalletDeletionInProgress() | ||
| guard seed.count == 64 else { | ||
| throw PlatformWalletError.invalidParameter( | ||
| "seed must be 64 bytes, got \(seed.count)" | ||
|
|
@@ -441,6 +449,7 @@ public class PlatformWalletManager: ObservableObject { | |
| @discardableResult | ||
| public func loadFromPersistor() throws -> [ManagedPlatformWallet] { | ||
| try ensureConfigured() | ||
| try ensureNoWalletDeletionInProgress() | ||
|
|
||
| try platform_wallet_manager_load_from_persistor(handle).check() | ||
|
|
||
|
|
@@ -970,18 +979,25 @@ public class PlatformWalletManager: ObservableObject { | |
|
|
||
| // MARK: - Wallet deletion | ||
|
|
||
| /// Fully wipe a wallet's Rust, SwiftData, and Keychain footprint. | ||
| /// | ||
| /// Requires the manager to have been `configure`d with a | ||
| /// `ModelContainer` — the per-identity Keychain sweep needs the | ||
| /// wallet's identity ids, which only the persistence handler can | ||
| /// resolve. The no-persistence configuration mode is rejected | ||
| /// here rather than silently leaving identity key material behind. | ||
| /// Fully wipe a wallet's Rust, SwiftData, and Keychain footprint without | ||
| /// blocking the main actor while SwiftData and Keychain perform their | ||
| /// potentially expensive database sweeps. | ||
| /// | ||
| /// Deleting an already-removed wallet succeeds unless an | ||
| /// operation fails. | ||
| public func deleteWallet(walletId: Data) throws { | ||
| /// `PlatformWalletManager` is main-actor isolated because its published | ||
| /// state and Rust handle are UI-facing. The persistence handler, however, | ||
| /// owns a dedicated serial queue with awaitable app-facing APIs, so its | ||
| /// database sweep does not freeze the UI. Rust/in-memory mutations remain | ||
| /// on the main actor. Deleting an already-removed wallet succeeds unless | ||
| /// an operation fails. | ||
| public func deleteWallet(walletId: Data) async throws { | ||
| let startedAt = ContinuousClock.now | ||
| let walletLabel = walletId.prefix(4) | ||
| .map { String(format: "%02x", $0) } | ||
| .joined() | ||
|
|
||
| try ensureConfigured() | ||
| try beginWalletDeletion() | ||
| defer { walletDeletionInProgress = false } | ||
| guard walletId.count == 32 else { | ||
| throw PlatformWalletError.invalidParameter( | ||
| "walletId must be 32 bytes, got \(walletId.count)" | ||
|
|
@@ -993,24 +1009,22 @@ public class PlatformWalletManager: ObservableObject { | |
| ) | ||
| } | ||
|
|
||
| let identityIds = try persistenceHandler.identityIdsForWallet(walletId: walletId) | ||
|
|
||
| // Wipe Keychain BEFORE the SwiftData identity deletion runs. | ||
| // Order matters for retry-safety: if `deleteWalletData` | ||
| // commits identity rows and then throws partway, a retry | ||
| // would see `identityIdsForWallet == []` and the | ||
| // `deleteAllKeychainItems(forIdentityId:)` sweep below | ||
| // could no longer find the keys to purge. Doing the | ||
| // keychain side first leaves at worst stale SwiftData | ||
| // rows on a retry — repeating the wipe is harmless, and | ||
| // every keychain call here is idempotent (no-op on "not | ||
| // found"). Mnemonic / metadata stay in `WalletStorage` | ||
| // for now so a retry can still derive any missed key. | ||
| for identityId in identityIds { | ||
| try KeychainManager.shared.deleteAllKeychainItems(forIdentityId: identityId) | ||
| } | ||
| try KeychainManager.shared.deleteAllIdentityPrivateKeys(forWalletId: walletId) | ||
| var phaseStartedAt = ContinuousClock.now | ||
| let identityIds = try await persistenceHandler.identityIdsForWallet( | ||
| walletId: walletId) | ||
|
|
||
| try await Task.detached(priority: .userInitiated) { | ||
| for identityId in identityIds { | ||
| try KeychainManager.shared.deleteAllKeychainItems(forIdentityId: identityId) | ||
| } | ||
| try KeychainManager.shared.deleteAllIdentityPrivateKeys(forWalletId: walletId) | ||
| }.value | ||
|
Comment on lines
+1013
to
+1021
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 Blocking: Quiesce child FFI handles before awaiting deletion Deletion now yields before removing the wallet from source: ['codex'] |
||
| SDKLogger.log( | ||
| "🗑️ wallet \(walletLabel)… delete keychain phase: " | ||
| + String(describing: phaseStartedAt.duration(to: .now)), | ||
| minimumLevel: .low) | ||
|
|
||
| phaseStartedAt = .now | ||
| try walletId.withUnsafeBytes { raw in | ||
| guard let base = raw.baseAddress?.assumingMemoryBound(to: FFIByteTuple32.self) else { | ||
| throw PlatformWalletError.nullPointer( | ||
|
|
@@ -1021,31 +1035,32 @@ public class PlatformWalletManager: ObservableObject { | |
| } | ||
|
|
||
| wallets.removeValue(forKey: walletId) | ||
| // Drop the needs-unlock banner state immediately so a re-created wallet | ||
| // with the same deterministic id doesn't inherit a stale banner (the | ||
| // poller would also prune it, but not until the next tick). | ||
| dashPayUnlockStatus.removeValue(forKey: walletId) | ||
|
|
||
| try persistenceHandler.deleteWalletData(walletId: walletId) | ||
|
|
||
| // The mnemonic + metadata blobs in the Keychain are keyed by | ||
| // `walletId`. With network-scoped wallet ids the same mnemonic | ||
| // maps to a DIFFERENT id per network, so a given id is owned by | ||
| // exactly one network's wallet and carries its own mnemonic | ||
| // copy — purging it can't orphan a sibling network (those live | ||
| // under their own distinct ids). The `walletRowCountAcrossNetworks | ||
| // == 0` check is therefore expected to be true right after | ||
| // `deleteWalletData` removes this id's lone row; it is retained | ||
| // as a defensive guard (and to stay correct should the id model | ||
| // ever change) so we never delete the phrase while any row for | ||
| // this exact id still exists. | ||
| let remaining = try persistenceHandler.walletRowCountAcrossNetworks(walletId: walletId) | ||
| SDKLogger.log( | ||
| "🗑️ wallet \(walletLabel)… delete Rust/in-memory phase: " | ||
| + String(describing: phaseStartedAt.duration(to: .now)), | ||
| minimumLevel: .low) | ||
|
|
||
| phaseStartedAt = .now | ||
| try await persistenceHandler.deleteWalletData(walletId: walletId) | ||
| SDKLogger.log( | ||
| "🗑️ wallet \(walletLabel)… delete SwiftData phase: " | ||
| + String(describing: phaseStartedAt.duration(to: .now)), | ||
| minimumLevel: .low) | ||
|
|
||
| let remaining = try await persistenceHandler.walletRowCountAcrossNetworks( | ||
| walletId: walletId) | ||
| if remaining == 0 { | ||
| let storage = WalletStorage() | ||
| // Delete metadata first so the mnemonic remains available for retry. | ||
| try storage.deleteMetadata(for: walletId) | ||
| try storage.deleteMnemonic(for: walletId) | ||
| try await Task.detached(priority: .userInitiated) { | ||
| let storage = WalletStorage() | ||
| try storage.deleteMetadata(for: walletId) | ||
| try storage.deleteMnemonic(for: walletId) | ||
| }.value | ||
| } | ||
| SDKLogger.log( | ||
| "🗑️ wallet \(walletLabel)… delete total: " | ||
| + String(describing: startedAt.duration(to: .now)), | ||
| minimumLevel: .low) | ||
| } | ||
|
|
||
| // MARK: - Per-wallet lookup | ||
|
|
@@ -1238,6 +1253,19 @@ public class PlatformWalletManager: ObservableObject { | |
| } | ||
| } | ||
|
|
||
| private func ensureNoWalletDeletionInProgress() throws { | ||
| guard !walletDeletionInProgress else { | ||
| throw PlatformWalletError.walletOperation( | ||
| "wallet deletion is already in progress" | ||
| ) | ||
| } | ||
| } | ||
|
|
||
| private func beginWalletDeletion() throws { | ||
| try ensureNoWalletDeletionInProgress() | ||
| walletDeletionInProgress = true | ||
| } | ||
|
|
||
| /// Starts the SPV progress polling loop. Cancelled on deinit. | ||
| /// | ||
| /// `@Published` assignments are gated on inequality so that identical | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 Suggestion: Add coverage for the manager-level deletion gate
The new
walletDeletionInProgressflag and its checks in bothcreateWalletoverloads,loadFromPersistor, anddeleteWalletare the only protection against manager actor reentrancy while deletion is suspended. The added tests callPlatformWalletPersistenceHandler.deleteWalletDatadirectly, and the idempotence test performs two completed deletions sequentially, so removingbeginWalletDeletionor any create/load guard would not fail the suite. Add a manager-level test that blocks the first deletion on the persistence queue, verifies that an overlapping delete and create/load attempts are rejected, then releases the first deletion and verifies the gate is cleared.source: ['codex']