diff --git a/packages/rs-platform-wallet-ffi/src/manager.rs b/packages/rs-platform-wallet-ffi/src/manager.rs index 37661da3502..aa00c0831c6 100644 --- a/packages/rs-platform-wallet-ffi/src/manager.rs +++ b/packages/rs-platform-wallet-ffi/src/manager.rs @@ -214,3 +214,34 @@ pub unsafe extern "C" fn platform_wallet_manager_destroy( } PlatformWalletFFIResult::ok() } + +/// Remove one wallet from the manager. Idempotent on missing wallets. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_manager_remove_wallet( + manager_handle: Handle, + wallet_id: *const [u8; 32], +) -> PlatformWalletFFIResult { + check_ptr!(wallet_id); + let wallet_id_value = *wallet_id; + + let option = PLATFORM_WALLET_MANAGER_STORAGE.with_item(manager_handle, |manager| { + runtime().block_on(manager.remove_wallet(&wallet_id_value)) + }); + let result = unwrap_option_or_return!(option); + match result { + Ok(_) => PlatformWalletFFIResult::ok(), + // Idempotency: a wallet that's already gone is the success + // state callers want. Everything else is a real failure. + Err(platform_wallet::PlatformWalletError::WalletNotFound(_)) => { + PlatformWalletFFIResult::ok() + } + Err(e) => PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorWalletOperation, + format!( + "Failed to remove wallet {}: {}", + hex::encode(wallet_id_value), + e + ), + ), + } +} diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index 4fc7ddba2df..e873156a6e2 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -362,6 +362,32 @@ pub struct PersistenceCallbacks { unsafe impl Send for PersistenceCallbacks {} unsafe impl Sync for PersistenceCallbacks {} +impl Default for PersistenceCallbacks { + fn default() -> Self { + Self { + context: std::ptr::null_mut(), + on_changeset_begin_fn: None, + on_changeset_end_fn: None, + on_store_fn: None, + on_flush_fn: None, + on_persist_address_balances_fn: None, + on_persist_wallet_changeset_fn: None, + on_persist_sync_state_fn: None, + on_persist_account_registrations_fn: None, + on_load_wallet_list_fn: None, + on_load_wallet_list_free_fn: None, + on_persist_wallet_metadata_fn: None, + on_persist_account_address_pools_fn: None, + on_persist_identities_fn: None, + on_persist_identity_keys_fn: None, + on_persist_token_balances_fn: None, + on_persist_contacts_fn: None, + on_get_core_tx_record_fn: None, + on_get_core_tx_record_free_fn: None, + } + } +} + /// In-memory persister that accumulates changesets and notifies via callbacks. pub struct FFIPersister { callbacks: PersistenceCallbacks, @@ -2287,3 +2313,4 @@ unsafe fn slice_from_raw<'a>(ptr: *const u8, len: usize) -> &'a [u8] { slice::from_raw_parts(ptr, len) } } + diff --git a/packages/rs-platform-wallet/src/manager/identity_sync.rs b/packages/rs-platform-wallet/src/manager/identity_sync.rs index b998ea73e01..7023190d91f 100644 --- a/packages/rs-platform-wallet/src/manager/identity_sync.rs +++ b/packages/rs-platform-wallet/src/manager/identity_sync.rs @@ -541,7 +541,14 @@ where return; } - // Build the changeset and update our own cache in lockstep. + self.apply_fresh_balances(identity_id, fresh_balances).await; + } + + async fn apply_fresh_balances( + &self, + identity_id: Identifier, + fresh_balances: BTreeMap>, + ) { let mut cs = TokenBalanceChangeSet::default(); for (token_id, maybe_balance) in &fresh_balances { let key = (identity_id, *token_id); @@ -555,6 +562,16 @@ where } } + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + + let mut state = self.state.write().await; + let Some(existing_row) = state.get(&identity_id).cloned() else { + return; + }; + // The persister API is wallet-scoped (`store(wallet_id, ..)`) // but this manager is identity-scoped. Use the zero-byte // sentinel — the FFI / SQLite token-balance write paths key @@ -569,67 +586,31 @@ where ); } - // TODO(identity-sync nonce): once token-id → contract-id - // resolution lands on the registry (currently keyed by token - // id only), fetch the per-(identity, contract) nonce here via - // `self.sdk.get_identity_contract_nonce(identity_id, - // contract_id, false, None).await` and replicate it onto - // every token row that shares the same contract. The - // `IdentityTokenSyncInfo::contract_id` field is plumbed - // through with a `Identifier::default()` placeholder so the - // FFI mirror shape doesn't have to change when this lands. - - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_secs()) - .unwrap_or(0); - - // Rewrite the per-identity cache row from the freshly fetched - // balances. Tokens that returned `None` (i.e. removed on - // Platform) drop out of the row; tokens that returned `Some` - // get the new balance. We rebuild rather than splice so that - // the row always reflects the latest watched-token set - // intersected with what Platform reports. - let mut state = self.state.write().await; - if let Some(existing_row) = state.get(&identity_id).cloned() { - // Rebuild from the *live* row (which may have been mutated - // by concurrent `update_watched_tokens` / `unregister_identity` - // while our network calls were in flight) rather than the - // stale `token_ids` snapshot. This way mid-sync registry - // changes are preserved: newly added tokens keep their - // initial state, and tokens removed during the pass stay - // removed. - let mut new_tokens: Vec = - Vec::with_capacity(existing_row.tokens.len()); - for prior in &existing_row.tokens { - match fresh_balances.get(&prior.token_id) { - Some(Some(amount)) => { - new_tokens.push(IdentityTokenSyncInfo { - balance: *amount, - ..*prior - }); - } - Some(None) => { - // Platform reported the token removed for - // this identity — drop the row. - } - None => { - // Batch didn't cover this token (added mid- - // sync, or batch failed) — keep prior state. - new_tokens.push(*prior); - } + let mut new_tokens: Vec = + Vec::with_capacity(existing_row.tokens.len()); + for prior in &existing_row.tokens { + match fresh_balances.get(&prior.token_id) { + Some(Some(amount)) => { + new_tokens.push(IdentityTokenSyncInfo { + balance: *amount, + ..*prior + }); + } + Some(None) => {} + None => { + new_tokens.push(*prior); } } + } - state.insert( + state.insert( + identity_id, + IdentityTokenSyncState { identity_id, - IdentityTokenSyncState { - identity_id, - last_sync_unix: now, - tokens: new_tokens, - }, - ); - } + last_sync_unix: now, + tokens: new_tokens, + }, + ); } } @@ -652,6 +633,7 @@ mod tests { use super::*; use crate::changeset::{ClientStartState, PersistenceError, PlatformWalletChangeSet}; + use std::sync::atomic::{AtomicUsize, Ordering as AtomicOrdering}; /// Test-only persister that swallows every `store` call and /// records nothing. Lifecycle / registry tests don't need the @@ -678,6 +660,37 @@ mod tests { } } + struct RecordingPersister { + stores: AtomicUsize, + } + + impl RecordingPersister { + fn new() -> Self { + Self { + stores: AtomicUsize::new(0), + } + } + } + + impl PlatformWalletPersistence for RecordingPersister { + fn store( + &self, + _wallet_id: WalletId, + _changeset: PlatformWalletChangeSet, + ) -> Result<(), PersistenceError> { + self.stores.fetch_add(1, AtomicOrdering::SeqCst); + Ok(()) + } + + fn flush(&self, _wallet_id: WalletId) -> Result<(), PersistenceError> { + Ok(()) + } + + fn load(&self) -> Result { + Ok(ClientStartState::default()) + } + } + /// Build a manager wired to a no-op persister. The SDK is /// constructed via `SdkBuilder::new_mock` so we don't need a /// running runtime for the registry/lifecycle tests below; none @@ -688,6 +701,18 @@ mod tests { Arc::new(IdentitySyncManager::new(sdk, persister)) } + fn make_recording_manager() -> ( + Arc>, + Arc, + ) { + let sdk = Arc::new(dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk")); + let persister = Arc::new(RecordingPersister::new()); + ( + Arc::new(IdentitySyncManager::new(sdk, Arc::clone(&persister))), + persister, + ) + } + /// `register_identity` populates a row with zero-balance /// placeholders for each token, and `state_for_identity` returns /// the cloned row. Validates the read API the FFI snapshot path @@ -770,6 +795,21 @@ mod tests { mgr.unregister_identity(&Identifier::from([99u8; 32])).await; } + #[tokio::test] + async fn unregistered_identity_does_not_persist_fresh_balances() { + let (mgr, persister) = make_recording_manager(); + let id_a = Identifier::from([1u8; 32]); + let token_x = Identifier::from([10u8; 32]); + let mut fresh = BTreeMap::new(); + fresh.insert(token_x, Some(5u64)); + + mgr.register_identity(id_a, [token_x]).await; + mgr.unregister_identity(&id_a).await; + mgr.apply_fresh_balances(id_a, fresh).await; + + assert_eq!(persister.stores.load(AtomicOrdering::SeqCst), 0); + } + /// `set_interval` clamps to >=1s and is read back via `interval`. /// Default interval matches the documented constant. Pinned so /// future tuning surfaces in the test suite. diff --git a/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs b/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs index 1042feb440a..59649f672c9 100644 --- a/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs +++ b/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs @@ -340,16 +340,40 @@ impl PlatformWalletManager

{ &self, wallet_id: &WalletId, ) -> Result, PlatformWalletError> { + let owned_identity_ids: Vec = { + let mut wm = self.wallet_manager.write().await; + let ids = match wm.get_wallet_info(wallet_id) { + Some(info) => info + .identity_manager + .wallet_identities + .get(wallet_id) + .map(|inner| { + use dpp::identity::accessors::IdentityGettersV0; + inner + .values() + .map(|managed| managed.identity.id()) + .collect() + }) + .unwrap_or_default(), + None => Vec::new(), + }; + let _ = wm.remove_wallet(wallet_id); + ids + }; + let removed = { let mut wallets = self.wallets.write().await; wallets .remove(wallet_id) .ok_or_else(|| PlatformWalletError::WalletNotFound(hex::encode(wallet_id)))? }; - { - let mut wm = self.wallet_manager.write().await; - let _ = wm.remove_wallet(wallet_id); + + for identity_id in &owned_identity_ids { + self.identity_sync_manager + .unregister_identity(identity_id) + .await; } + Ok(removed) } } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift index 8f6cdc3dc0c..f32ed18876e 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift @@ -322,6 +322,57 @@ public class PlatformWalletManager: ObservableObject { return restored } + // 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. + /// + /// Deleting an already-removed wallet succeeds unless an + /// operation fails. + public func deleteWallet(walletId: Data) throws { + try ensureConfigured() + guard walletId.count == 32 else { + throw PlatformWalletError.invalidParameter( + "walletId must be 32 bytes, got \(walletId.count)" + ) + } + guard let persistenceHandler = persistenceHandler else { + throw PlatformWalletError.invalidHandle( + "deleteWallet requires a persistence handler — configure the manager with a ModelContainer" + ) + } + + 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) + + try persistenceHandler.deleteWalletData(walletId: walletId) + + for identityId in identityIds { + try KeychainManager.shared.deleteAllKeychainItems(forIdentityId: identityId) + } + try KeychainManager.shared.deleteAllIdentityPrivateKeys(forWalletId: walletId) + + let storage = WalletStorage() + // Delete metadata first so the mnemonic remains available for retry. + try storage.deleteMetadata(for: walletId) + try storage.deleteMnemonic(for: walletId) + } + // MARK: - Per-wallet lookup /// Return the managed wallet with the given 32-byte id, or `nil` diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 965e236e55d..93dd0c88aea 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -67,8 +67,8 @@ public class PlatformWalletPersistenceHandler { /// recursive entry. The internal helpers in this file all /// assume they are already on the queue and call /// `backgroundContext` directly. - private func onQueue(_ body: () -> T) -> T { - serialQueue.sync(execute: body) + private func onQueue(_ body: () throws -> T) rethrows -> T { + try serialQueue.sync(execute: body) } // MARK: - Platform Address Balances @@ -230,11 +230,9 @@ public class PlatformWalletPersistenceHandler { /// Utxo records so views observing via `@Query` update automatically. func persistWalletChangeset(walletId: Data, changeset: UnsafePointer) { onQueue { + guard let wallet = findWalletRecord(walletId: walletId) else { return } let cs = changeset.pointee - // Ensure PersistentWallet exists (lightweight upsert). - let wallet = ensureWalletRecord(walletId: walletId) - // Chain update. if cs.has_chain { if cs.chain.has_synced_height { @@ -265,7 +263,10 @@ public class PlatformWalletPersistenceHandler { } } - /// Find or create the `PersistentWallet` record for this wallet id. + /// Find or create the `PersistentWallet` row for `walletId`. + /// Used only by `persistWalletMetadata`; every other write path + /// fetches via `findWalletRecord` and drops on missing so that + /// stale post-deletion callbacks can't resurrect a wiped wallet. private func ensureWalletRecord(walletId: Data) -> PersistentWallet { let descriptor = FetchDescriptor( predicate: #Predicate { $0.walletId == walletId } @@ -278,6 +279,15 @@ public class PlatformWalletPersistenceHandler { return record } + /// Find the `PersistentWallet` row for `walletId`. Returns `nil` + /// when no row exists. + private func findWalletRecord(walletId: Data) -> PersistentWallet? { + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.walletId == walletId } + ) + return try? backgroundContext.fetch(descriptor).first + } + /// Look up a `PersistentWallet` to hang on /// `PersistentIdentity.wallet`. Non-creating — returns `nil` if /// no row exists (an identity may arrive before its owning @@ -2039,16 +2049,111 @@ public class PlatformWalletPersistenceHandler { /// Set the user-facing name on the `PersistentWallet` row. /// Called from `PlatformWalletManager.createWallet` after the FFI /// returns a wallet id; only Swift knows the name, so it doesn't - /// travel through a Rust-side callback. + /// travel through a Rust-side callback. Silently skips if the row + /// is missing (wallet wasn't successfully registered). public func setWalletName(walletId: Data, name: String) { onQueue { - let wallet = ensureWalletRecord(walletId: walletId) + guard let wallet = findWalletRecord(walletId: walletId) else { return } wallet.name = name wallet.lastUpdated = Date() try? backgroundContext.save() } } + public func identityIdsForWallet(walletId: Data) throws -> [Data] { + try onQueue { + let descriptor = FetchDescriptor( + predicate: PersistentWallet.predicate(walletId: walletId) + ) + guard let walletRow = try backgroundContext.fetch(descriptor).first else { + return [] + } + return walletRow.identities.map { $0.identityId } + } + } + + /// Wipe a wallet's SwiftData footprint. + public func deleteWalletData(walletId: Data) throws { + try onQueue { + do { + let walletDescriptor = FetchDescriptor( + predicate: PersistentWallet.predicate(walletId: walletId) + ) + let walletRow = try backgroundContext.fetch(walletDescriptor).first + let walletNetwork = walletRow?.network + + if let walletRow = walletRow { + // Wallet identity relationships are `.nullify`; this delete path cascades them explicitly. + let identitiesToDelete = Array(walletRow.identities) + let identityIds = identitiesToDelete.map { $0.identityId } + + for identityId in identityIds { + let balanceDescriptor = FetchDescriptor( + predicate: PersistentTokenBalance.predicate(identityId: identityId) + ) + for row in try backgroundContext.fetch(balanceDescriptor) { + backgroundContext.delete(row) + } + } + + for identity in identitiesToDelete { + backgroundContext.delete(identity) + } + } + + let txoDescriptor = FetchDescriptor( + predicate: #Predicate { $0.walletId == walletId } + ) + for row in try backgroundContext.fetch(txoDescriptor) { + backgroundContext.delete(row) + } + + let pendingDescriptor = FetchDescriptor( + predicate: #Predicate { $0.walletId == walletId } + ) + for row in try backgroundContext.fetch(pendingDescriptor) { + backgroundContext.delete(row) + } + + if let walletRow = walletRow { + backgroundContext.delete(walletRow) + } + + try backgroundContext.save() + + let txRows = try backgroundContext.fetch(FetchDescriptor()) + for tx in txRows where tx.outputs.isEmpty && + tx.inputs.isEmpty && + tx.pendingInputs.isEmpty { + backgroundContext.delete(tx) + } + + if let walletNetwork = walletNetwork { + let networkRaw = walletNetwork.rawValue + let siblingDescriptor = FetchDescriptor( + predicate: #Predicate { $0.networkRaw == networkRaw } + ) + let remaining = try backgroundContext.fetch(siblingDescriptor) + .filter { $0.walletId != walletId } + if remaining.isEmpty { + let scopeId = syncStateScopeId(for: walletNetwork) + let syncDescriptor = FetchDescriptor( + predicate: #Predicate { $0.walletId == scopeId } + ) + if let syncRow = try backgroundContext.fetch(syncDescriptor).first { + backgroundContext.delete(syncRow) + } + } + } + + try backgroundContext.save() + } catch { + backgroundContext.rollback() + throw error + } + } + } + // MARK: - Watch-only Restore: Account xpub /// Upsert a `PersistentAccount` row with the full `AccountSpecFFI` @@ -2057,78 +2162,78 @@ public class PlatformWalletPersistenceHandler { /// that uniquely identifies an account across variants. func persistAccount(walletId: Data, spec: AccountSpecFFI) { onQueue { - let wallet = ensureWalletRecord(walletId: walletId) - let typeTag = UInt32(spec.type_tag) - let index = spec.index - let registrationIndex = spec.registration_index - let keyClass = spec.key_class - var userIdentityId = Data(count: 32) - withUnsafeBytes(of: spec.user_identity_id) { src in - userIdentityId.withUnsafeMutableBytes { dst in - dst.copyMemory(from: src) + guard let wallet = findWalletRecord(walletId: walletId) else { return } + let typeTag = UInt32(spec.type_tag) + let index = spec.index + let registrationIndex = spec.registration_index + let keyClass = spec.key_class + var userIdentityId = Data(count: 32) + withUnsafeBytes(of: spec.user_identity_id) { src in + userIdentityId.withUnsafeMutableBytes { dst in + dst.copyMemory(from: src) + } } - } - var friendIdentityId = Data(count: 32) - withUnsafeBytes(of: spec.friend_identity_id) { src in - friendIdentityId.withUnsafeMutableBytes { dst in - dst.copyMemory(from: src) + var friendIdentityId = Data(count: 32) + withUnsafeBytes(of: spec.friend_identity_id) { src in + friendIdentityId.withUnsafeMutableBytes { dst in + dst.copyMemory(from: src) + } } - } - let xpubBytes: Data - if let xpubPtr = spec.account_xpub_bytes, spec.account_xpub_bytes_len > 0 { - xpubBytes = Data(bytes: xpubPtr, count: Int(spec.account_xpub_bytes_len)) - } else { - xpubBytes = Data() - } - - // Upsert keyed by the full account identity. We can't easily - // express the identity tuple in a #Predicate with local `Data` - // captures, so fetch by (walletId, accountType, accountIndex) - // and verify the richer fields in Swift. - let descriptor = FetchDescriptor( - predicate: #Predicate { - $0.wallet.walletId == walletId - && $0.accountType == typeTag - && $0.accountIndex == index + let xpubBytes: Data + if let xpubPtr = spec.account_xpub_bytes, spec.account_xpub_bytes_len > 0 { + xpubBytes = Data(bytes: xpubPtr, count: Int(spec.account_xpub_bytes_len)) + } else { + xpubBytes = Data() } - ) - let existing = (try? backgroundContext.fetch(descriptor)) ?? [] - let match = existing.first { acc in - // `standardTag` splits Standard accounts into BIP44 (0) - // and BIP32 (1) variants. Without it, the second emit - // (whichever the Rust side serializes last) silently - // aliases onto the first row and the BIP32 account is - // never persisted as its own record. - acc.standardTag == spec.standard_tag - && acc.registrationIndex == registrationIndex - && acc.keyClass == keyClass - && acc.userIdentityId == userIdentityId - && acc.friendIdentityId == friendIdentityId - } - let account: PersistentAccount - if let match = match { - account = match - } else { - account = PersistentAccount( - wallet: wallet, - accountType: typeTag, - accountIndex: index, - accountTypeName: accountTypeName( - for: spec.type_tag, - standardTag: spec.standard_tag - ) + + // Upsert keyed by the full account identity. We can't easily + // express the identity tuple in a #Predicate with local `Data` + // captures, so fetch by (walletId, accountType, accountIndex) + // and verify the richer fields in Swift. + let descriptor = FetchDescriptor( + predicate: #Predicate { + $0.wallet.walletId == walletId + && $0.accountType == typeTag + && $0.accountIndex == index + } ) - backgroundContext.insert(account) + let existing = (try? backgroundContext.fetch(descriptor)) ?? [] + let match = existing.first { acc in + // `standardTag` splits Standard accounts into BIP44 (0) + // and BIP32 (1) variants. Without it, the second emit + // (whichever the Rust side serializes last) silently + // aliases onto the first row and the BIP32 account is + // never persisted as its own record. + acc.standardTag == spec.standard_tag + && acc.registrationIndex == registrationIndex + && acc.keyClass == keyClass + && acc.userIdentityId == userIdentityId + && acc.friendIdentityId == friendIdentityId + } + let account: PersistentAccount + if let match = match { + account = match + } else { + account = PersistentAccount( + wallet: wallet, + accountType: typeTag, + accountIndex: index, + accountTypeName: accountTypeName( + for: spec.type_tag, + standardTag: spec.standard_tag + ) + ) + backgroundContext.insert(account) + } + account.standardTag = spec.standard_tag + account.registrationIndex = registrationIndex + account.keyClass = keyClass + account.userIdentityId = userIdentityId + account.friendIdentityId = friendIdentityId + account.accountExtendedPubKeyBytes = xpubBytes + account.lastUpdated = Date() + if !self.inChangeset { try? backgroundContext.save() } } - account.standardTag = spec.standard_tag - account.registrationIndex = registrationIndex - account.keyClass = keyClass - account.userIdentityId = userIdentityId - account.friendIdentityId = friendIdentityId - account.accountExtendedPubKeyBytes = xpubBytes - account.lastUpdated = Date() - if !self.inChangeset { try? backgroundContext.save() } - } // onQueue } // MARK: - Watch-only Restore: Load diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Security/KeychainManager.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Security/KeychainManager.swift index b096b2d8043..bff301c86ff 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Security/KeychainManager.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Security/KeychainManager.swift @@ -147,7 +147,7 @@ public final class KeychainManager: Sendable { // Add metadata let metadata: [String: Any] = [ - "identityId": identityId.map { String(format: "%02x", $0) }.joined(), + "identityId": identityId.toHexString(), "keyIndex": keyIndex, "createdAt": Date().timeIntervalSince1970 ] @@ -228,49 +228,50 @@ public final class KeychainManager: Sendable { return status == errSecSuccess || status == errSecItemNotFound } - /// Delete all private keys for an identity - /// - Parameter identityId: The identity ID (32 bytes) - /// - Returns: true if deletion completed (even if no keys existed) - @discardableResult - public func deleteAllPrivateKeys(for identityId: Data) -> Bool { + /// Delete every `privkey__*` keychain row for `identityId`. + public nonisolated func deleteAllPrivateKeys(for identityId: Data) throws { + try deleteItems(accountPrefixes: ["privkey_\(identityId.toHexString())_"]) + } + + /// Delete every per-identity keychain row — both `privkey_*` and + /// `specialkey_*` schemes — for `identityId`. + public nonisolated func deleteAllKeychainItems(forIdentityId identityId: Data) throws { + let identityHex = identityId.toHexString() + try deleteItems(accountPrefixes: [ + "privkey_\(identityHex)_", + "specialkey_\(identityHex)_" + ]) + } + + private nonisolated func deleteItems(accountPrefixes: [String]) throws { var query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: serviceName, - kSecMatchLimit as String: kSecMatchLimitAll + kSecMatchLimit as String: kSecMatchLimitAll, + kSecReturnAttributes as String: true ] if let accessGroup = accessGroup { query[kSecAttrAccessGroup as String] = accessGroup } - // First, find all keys for this identity var result: AnyObject? let searchStatus = SecItemCopyMatching(query as CFDictionary, &result) + if searchStatus == errSecItemNotFound { + return + } + guard searchStatus == errSecSuccess, let items = result as? [[String: Any]] else { + throw KeychainError.retrieveFailed(searchStatus) + } - let identityHex = identityId.map { String(format: "%02x", $0) }.joined() - - if searchStatus == errSecSuccess, - let items = result as? [[String: Any]] { - // Filter items for this identity and delete them - for item in items { - if let account = item[kSecAttrAccount as String] as? String, - account.hasPrefix("privkey_\(identityHex)_") { - var deleteQuery: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: serviceName, - kSecAttrAccount as String: account - ] - - if let accessGroup = accessGroup { - deleteQuery[kSecAttrAccessGroup as String] = accessGroup - } - - SecItemDelete(deleteQuery as CFDictionary) - } + for item in items { + guard let account = item[kSecAttrAccount as String] as? String, + accountPrefixes.contains(where: { account.hasPrefix($0) }) + else { + continue } + try deleteGenericPassword(account: account) } - - return true } // MARK: - Special Keys (Voting, Owner, Payout) @@ -432,18 +433,33 @@ public final class KeychainManager: Sendable { // MARK: - Private Helpers + private nonisolated func deleteGenericPassword(account: String) throws { + var query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: serviceName, + kSecAttrAccount as String: account + ] + + if let accessGroup = accessGroup { + query[kSecAttrAccessGroup as String] = accessGroup + } + + let status = SecItemDelete(query as CFDictionary) + guard status == errSecSuccess || status == errSecItemNotFound else { + throw KeychainError.deleteFailed(status) + } + } + /// Nonisolated because the result only depends on the arguments /// — no access to actor-isolated state — and the function is /// shared between the `@MainActor` wrapper methods and the /// off-actor `storePrivateKeyNonisolated` path. private nonisolated func generateKeyIdentifier(identityId: Data, keyIndex: Int32) -> String { - let identityHex = identityId.map { String(format: "%02x", $0) }.joined() - return "privkey_\(identityHex)_\(keyIndex)" + return "privkey_\(identityId.toHexString())_\(keyIndex)" } private func generateSpecialKeyIdentifier(identityId: Data, keyType: SpecialKeyType) -> String { - let identityHex = identityId.map { String(format: "%02x", $0) }.joined() - return "specialkey_\(identityHex)_\(keyType.rawValue)" + return "specialkey_\(identityId.toHexString())_\(keyType.rawValue)" } } @@ -475,7 +491,7 @@ extension KeychainManager { } } guard rc == 0 else { return "" } - return out.map { String(format: "%02x", $0) }.joined() + return Data(out).toHexString() } } @@ -747,6 +763,52 @@ extension KeychainManager { let status = SecItemDelete(query as CFDictionary) return status == errSecSuccess || status == errSecItemNotFound } + + /// Delete every `identity_privkey.` keychain row whose + /// `IdentityPrivateKeyMetadata.walletId` matches `walletId`. + public nonisolated func deleteAllIdentityPrivateKeys(forWalletId walletId: Data) throws { + let walletIdHex = walletId.toHexString() + var query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: serviceName, + kSecMatchLimit as String: kSecMatchLimitAll, + kSecReturnAttributes as String: true, + ] + if let accessGroup = accessGroup { + query[kSecAttrAccessGroup as String] = accessGroup + } + + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + if status == errSecItemNotFound { + return + } + guard status == errSecSuccess, let items = result as? [[String: Any]] else { + throw KeychainError.retrieveFailed(status) + } + + let decoder = JSONDecoder() + for item in items { + guard let account = item[kSecAttrAccount as String] as? String, + account.hasPrefix("identity_privkey.") + else { + continue + } + guard let metadataData = item[kSecAttrGeneric as String] as? Data, + let metadata = try? decoder.decode( + IdentityPrivateKeyMetadata.self, + from: metadataData + ) + else { + continue + } + guard metadata.walletId.caseInsensitiveCompare(walletIdHex) == .orderedSame else { + continue + } + + try deleteGenericPassword(account: account) + } + } } // MARK: - Platform-address private-key storage — REMOVED diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift index 1df76b683c0..7fbb2f1450e 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift @@ -185,6 +185,7 @@ struct WalletDetailView: View { struct WalletInfoView: View { @Environment(\.dismiss) var dismiss @Environment(\.modelContext) var modelContext + @EnvironmentObject var walletManager: PlatformWalletManager let wallet: PersistentWallet var onWalletDeleted: () -> Void = {} @@ -627,19 +628,14 @@ struct WalletInfoView: View { await MainActor.run { isDeleting = true } - // Cascade-delete rules on `accounts` / `identities` null out - // or cascade the children automatically. - modelContext.delete(wallet) + // `PlatformWalletManager.deleteWallet` handles the full wipe: + // Rust manager-side drop, in-memory dict removal, SwiftData + // cascade + orphan sweep (transactions / pending inputs / + // identities the @Relationship rule doesn't reach), and the + // Keychain mnemonic + metadata blobs. do { - try modelContext.save() - let storage = WalletStorage() - try storage.deleteMnemonic(for: walletId) - // Keychain metadata is independent of the mnemonic - // row — clear it here so a deleted wallet doesn't - // leave stale name/description behind. - try storage.deleteMetadata(for: walletId) + try walletManager.deleteWallet(walletId: walletId) } catch { - modelContext.rollback() SDKLogger.error( "Failed to fully delete wallet: \(error.localizedDescription)" ) @@ -656,8 +652,6 @@ struct WalletInfoView: View { dismiss() onWalletDeleted() } - // TODO(platform-wallet): expose wallet removal on PlatformWalletManager - // so the Rust side also drops the in-memory handle. } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/KeyManagerTests.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/KeyManagerTests.swift index fff8bd68551..144f300b914 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/KeyManagerTests.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/KeyManagerTests.swift @@ -175,7 +175,7 @@ final class KeyManagerTests: XCTestCase { ]) // Encode to WIF - guard let wif = KeyFormatter.toWIF(originalKey, isTestnet: true) else { + guard let wif = KeyFormatter.toWIF(originalKey, network: .testnet) else { XCTFail("Failed to encode to WIF") return } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/WalletDeletionTests.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/WalletDeletionTests.swift new file mode 100644 index 00000000000..cd74c5e6eae --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/WalletDeletionTests.swift @@ -0,0 +1,173 @@ +import SwiftData +import XCTest +@testable import SwiftDashSDK + +final class WalletDeletionTests: XCTestCase { + + func testDeleteWalletDataRemovesWalletFootprintAndLastNetworkSyncState() throws { + let container = try DashModelContainer.createInMemory() + let context = ModelContext(container) + let walletId = Data(repeating: 0x44, count: 32) + let identityId = Data(repeating: 0x55, count: 32) + + let wallet = PersistentWallet(walletId: walletId, network: .testnet) + let identity = PersistentIdentity(identityId: identityId, network: .testnet) + identity.wallet = wallet + wallet.identities.append(identity) + + let balance = PersistentTokenBalance( + tokenId: "token-a", + identityId: identityId, + balance: 10, + network: .testnet + ) + balance.identity = identity + identity.tokenBalances.append(balance) + + let pendingTx = PersistentTransaction( + txid: Data(repeating: 0x66, count: 32), + transactionData: Data([0x01]) + ) + let pending = PersistentPendingInput( + outpoint: Data(repeating: 0x67, count: 36), + inputIndex: 0, + spendingTxid: pendingTx.txid, + spendingTransaction: pendingTx, + walletId: walletId + ) + pendingTx.pendingInputs.append(pending) + + let orphanTx = PersistentTransaction( + txid: Data(repeating: 0x77, count: 32), + transactionData: Data([0x02]) + ) + + let liveTx = PersistentTransaction( + txid: Data(repeating: 0x88, count: 32), + transactionData: Data([0x03]) + ) + let liveTxo = PersistentTxo( + transaction: liveTx, + vout: 0, + amount: 1, + address: "yTest" + ) + liveTx.outputs.append(liveTxo) + + let syncState = PersistentPlatformAddressesSyncState( + walletId: Self.syncStateScopeId(for: .testnet), + network: .testnet, + syncHeight: 10, + syncTimestamp: 20, + lastKnownRecentBlock: 30 + ) + + context.insert(wallet) + context.insert(identity) + context.insert(balance) + context.insert(pendingTx) + context.insert(pending) + context.insert(orphanTx) + context.insert(liveTx) + context.insert(liveTxo) + context.insert(syncState) + try context.save() + + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + try handler.deleteWalletData(walletId: walletId) + try handler.deleteWalletData(walletId: walletId) + + XCTAssertTrue(try fetch(PersistentWallet.self, in: container).isEmpty) + XCTAssertTrue(try fetch(PersistentIdentity.self, in: container).isEmpty) + XCTAssertTrue(try fetch(PersistentTokenBalance.self, in: container).isEmpty) + XCTAssertTrue(try fetch(PersistentPendingInput.self, in: container).isEmpty) + XCTAssertTrue(try fetch(PersistentPlatformAddressesSyncState.self, in: container).isEmpty) + + let transactions = try fetch(PersistentTransaction.self, in: container) + XCTAssertEqual(transactions.count, 1) + XCTAssertEqual(transactions.first?.txid, liveTx.txid) + } + + func testDeleteWalletDataKeepsNetworkSyncStateWhenSiblingWalletRemains() throws { + let container = try DashModelContainer.createInMemory() + let context = ModelContext(container) + let walletId = Data(repeating: 0x99, count: 32) + let siblingId = Data(repeating: 0xaa, count: 32) + + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + context.insert(PersistentWallet(walletId: siblingId, network: .testnet)) + context.insert( + PersistentPlatformAddressesSyncState( + walletId: Self.syncStateScopeId(for: .testnet), + network: .testnet, + syncHeight: 10, + syncTimestamp: 20, + lastKnownRecentBlock: 30 + ) + ) + try context.save() + + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + try handler.deleteWalletData(walletId: walletId) + + let wallets = try fetch(PersistentWallet.self, in: container) + XCTAssertEqual(wallets.map(\.walletId), [siblingId]) + XCTAssertEqual(try fetch(PersistentPlatformAddressesSyncState.self, in: container).count, 1) + } + + @MainActor + func testThrowingKeychainSweepsUseIsolatedService() throws { + let manager = KeychainManager(serviceName: "org.dash.wallet-delete-tests.\(UUID().uuidString)") + let identityId = Data(repeating: 0xbb, count: 32) + let walletId = Data(repeating: 0xcc, count: 32) + let publicKey = Data(repeating: 0xdd, count: 33).toHexString() + + XCTAssertNotNil(manager.storePrivateKey(Data(repeating: 0x01, count: 32), identityId: identityId, keyIndex: 0)) + XCTAssertNotNil(manager.storeSpecialKey(Data(repeating: 0x02, count: 32), identityId: identityId, keyType: .voting)) + XCTAssertNotNil( + manager.storeIdentityPrivateKey( + Data(repeating: 0x03, count: 32), + derivationPath: "m/9'/5'/3'/1'", + metadata: .init( + identityId: "identity", + keyId: 1, + walletId: walletId.toHexString(), + identityIndex: 0, + keyIndex: 0, + derivationPath: "m/9'/5'/3'/1'", + publicKey: publicKey, + publicKeyHash: Data(repeating: 0xee, count: 20).toHexString(), + keyType: 0, + purpose: 0, + securityLevel: 0 + ) + ) + ) + + XCTAssertNotNil(manager.retrievePrivateKey(identityId: identityId, keyIndex: 0)) + XCTAssertNotNil(manager.retrieveSpecialKey(identityId: identityId, keyType: .voting)) + XCTAssertNotNil(manager.retrieveIdentityPrivateKey(publicKeyHex: publicKey)) + + try manager.deleteAllKeychainItems(forIdentityId: identityId) + + XCTAssertNil(manager.retrievePrivateKey(identityId: identityId, keyIndex: 0)) + XCTAssertNil(manager.retrieveSpecialKey(identityId: identityId, keyType: .voting)) + XCTAssertNotNil(manager.retrieveIdentityPrivateKey(publicKeyHex: publicKey)) + + try manager.deleteAllIdentityPrivateKeys(forWalletId: walletId) + + XCTAssertNil(manager.retrieveIdentityPrivateKey(publicKeyHex: publicKey)) + } + + private static func syncStateScopeId(for network: Network) -> Data { + var data = Data("platform-sync:\(network.networkName)".utf8.prefix(32)) + if data.count < 32 { + data.append(Data(repeating: 0, count: 32 - data.count)) + } + return data + } + + private func fetch(_ type: T.Type, in container: ModelContainer) throws -> [T] { + try ModelContext(container).fetch(FetchDescriptor()) + } +}