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..4d395ab7f2e 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -23,7 +23,7 @@ use platform_wallet::changeset::{ }; use platform_wallet::wallet::platform_wallet::WalletId; use platform_wallet::wallet::{PerAccountPlatformAddressState, PerWalletPlatformAddressState}; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::ffi::CString; use std::os::raw::c_void; use std::slice; @@ -362,10 +362,39 @@ 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, pending: RwLock>, + /// Wallet ids removed from the manager. Stale handles may still call + /// `store`; registration metadata clears retirement for same-id reimport. + retired: RwLock>, } impl FFIPersister { @@ -373,6 +402,7 @@ impl FFIPersister { Self { callbacks, pending: RwLock::new(BTreeMap::new()), + retired: RwLock::new(BTreeSet::new()), } } } @@ -383,6 +413,14 @@ impl PlatformWalletPersistence for FFIPersister { wallet_id: WalletId, changeset: PlatformWalletChangeSet, ) -> Result<(), PersistenceError> { + 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(()); + } + } + // Bracket the whole per-kind callback sequence with a // begin/end pair so clients (Swift, etc.) can treat the // round as a single atomic transaction: begin opens a @@ -909,6 +947,10 @@ impl PlatformWalletPersistence for FFIPersister { } fn flush(&self, wallet_id: WalletId) -> Result<(), PersistenceError> { + if wallet_id != WalletId::default() && self.retired.read().contains(&wallet_id) { + return Ok(()); + } + // Notify caller. if let Some(cb) = self.callbacks.on_flush_fn { let result = unsafe { cb(self.callbacks.context, wallet_id.as_ptr()) }; @@ -926,6 +968,15 @@ impl PlatformWalletPersistence for FFIPersister { Ok(()) } + fn retire_wallet(&self, wallet_id: WalletId) { + if wallet_id == WalletId::default() { + return; + } + + self.pending.write().remove(&wallet_id); + self.retired.write().insert(wallet_id); + } + fn load(&self) -> Result { // If Swift hasn't wired up `on_load_wallet_list_fn` there's // nothing to restore — treat as a fresh client. @@ -2287,3 +2338,109 @@ unsafe fn slice_from_raw<'a>(ptr: *const u8, len: usize) -> &'a [u8] { slice::from_raw_parts(ptr, len) } } + +#[cfg(test)] +mod tests { + use super::*; + + use platform_wallet::changeset::WalletMetadataEntry; + use std::sync::atomic::{AtomicUsize, Ordering}; + + struct CallbackCounts { + metadata: AtomicUsize, + stores: AtomicUsize, + flushes: AtomicUsize, + } + + impl CallbackCounts { + fn new() -> Self { + Self { + metadata: AtomicUsize::new(0), + stores: AtomicUsize::new(0), + flushes: AtomicUsize::new(0), + } + } + } + + unsafe extern "C" fn metadata_callback( + context: *mut c_void, + _wallet_id: *const u8, + _network: FFINetwork, + _birth_height: u32, + ) -> i32 { + let counts = &*(context as *const CallbackCounts); + counts.metadata.fetch_add(1, Ordering::SeqCst); + 0 + } + + unsafe extern "C" fn store_callback(context: *mut c_void, _wallet_id: *const u8) -> i32 { + let counts = &*(context as *const CallbackCounts); + counts.stores.fetch_add(1, Ordering::SeqCst); + 0 + } + + unsafe extern "C" fn flush_callback(context: *mut c_void, _wallet_id: *const u8) -> i32 { + let counts = &*(context as *const CallbackCounts); + counts.flushes.fetch_add(1, Ordering::SeqCst); + 0 + } + + fn callbacks(counts: &CallbackCounts) -> PersistenceCallbacks { + PersistenceCallbacks { + context: counts as *const CallbackCounts as *mut c_void, + on_persist_wallet_metadata_fn: Some(metadata_callback), + on_store_fn: Some(store_callback), + on_flush_fn: Some(flush_callback), + ..Default::default() + } + } + + #[test] + fn retired_wallet_drops_non_registration_store_and_flush() { + let counts = CallbackCounts::new(); + let persister = FFIPersister::new(callbacks(&counts)); + let wallet_id = [7u8; 32]; + + persister + .store(wallet_id, PlatformWalletChangeSet::default()) + .unwrap(); + assert_eq!(counts.stores.load(Ordering::SeqCst), 1); + assert!(persister.pending.read().contains_key(&wallet_id)); + + persister.retire_wallet(wallet_id); + assert!(!persister.pending.read().contains_key(&wallet_id)); + + persister + .store(wallet_id, PlatformWalletChangeSet::default()) + .unwrap(); + persister.flush(wallet_id).unwrap(); + + assert_eq!(counts.metadata.load(Ordering::SeqCst), 0); + assert_eq!(counts.stores.load(Ordering::SeqCst), 1); + assert_eq!(counts.flushes.load(Ordering::SeqCst), 0); + } + + #[test] + fn registration_store_clears_retirement() { + let counts = CallbackCounts::new(); + let persister = FFIPersister::new(callbacks(&counts)); + let wallet_id = [8u8; 32]; + + persister.retire_wallet(wallet_id); + + let registration = PlatformWalletChangeSet { + wallet_metadata: Some(WalletMetadataEntry { + network: key_wallet::Network::Testnet, + birth_height: 42, + }), + ..Default::default() + }; + persister.store(wallet_id, registration).unwrap(); + persister.flush(wallet_id).unwrap(); + + assert_eq!(counts.metadata.load(Ordering::SeqCst), 1); + assert_eq!(counts.stores.load(Ordering::SeqCst), 1); + assert_eq!(counts.flushes.load(Ordering::SeqCst), 1); + assert!(!persister.retired.read().contains(&wallet_id)); + } +} diff --git a/packages/rs-platform-wallet/src/changeset/traits.rs b/packages/rs-platform-wallet/src/changeset/traits.rs index 1e567e451ed..685a5b24830 100644 --- a/packages/rs-platform-wallet/src/changeset/traits.rs +++ b/packages/rs-platform-wallet/src/changeset/traits.rs @@ -134,6 +134,13 @@ pub trait PlatformWalletPersistence: Send + Sync { /// clear that wallet's buffer. fn flush(&self, wallet_id: WalletId) -> Result<(), PersistenceError>; + /// Stop accepting future writes for a wallet removed from the manager. + /// + /// Persisters that can receive callbacks from stale wallet handles should + /// drop any buffered state and ignore later non-registration writes for this + /// id. Backends without an in-memory callback gate can use the default no-op. + fn retire_wallet(&self, _wallet_id: WalletId) {} + /// Load the full client state from storage. /// /// Returns a [`ClientStartState`] — a ready-to-boot snapshot covering 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..017a9f36756 100644 --- a/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs +++ b/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs @@ -335,11 +335,32 @@ impl PlatformWalletManager

{ Ok(platform_wallet) } - /// Remove a wallet from the manager. + /// Remove a wallet from the manager and retire its persister writes. pub async fn remove_wallet( &self, wallet_id: &WalletId, ) -> Result, PlatformWalletError> { + self.persister.retire_wallet(*wallet_id); + + let owned_identity_ids: Vec = { + let wm = self.wallet_manager.read().await; + 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 removed = { let mut wallets = self.wallets.write().await; wallets @@ -350,6 +371,13 @@ impl PlatformWalletManager

{ 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..45407511912 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift @@ -322,6 +322,44 @@ public class PlatformWalletManager: ObservableObject { return restored } + // MARK: - Wallet deletion + + /// Fully wipe a wallet's Rust, SwiftData, and Keychain footprint. + /// + /// 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)" + ) + } + + 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) + } + try KeychainManager.shared.deleteAllIdentityPrivateKeys(forWalletId: walletId) + + try persistenceHandler?.deleteWalletData(walletId: 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..b1ea76ad755 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,10 +230,8 @@ public class PlatformWalletPersistenceHandler { /// Utxo records so views observing via `@Query` update automatically. func persistWalletChangeset(walletId: Data, changeset: UnsafePointer) { onQueue { - let cs = changeset.pointee - - // Ensure PersistentWallet exists (lightweight upsert). let wallet = ensureWalletRecord(walletId: walletId) + let cs = changeset.pointee // Chain update. if cs.has_chain { @@ -2049,6 +2047,88 @@ public class PlatformWalletPersistenceHandler { } } + 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) + } + backgroundContext.delete(walletRow) + } + + let pendingDescriptor = FetchDescriptor( + predicate: #Predicate { $0.walletId == walletId } + ) + for row in try backgroundContext.fetch(pendingDescriptor) { + backgroundContext.delete(row) + } + + 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 +2137,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) + 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) + } } - } - 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..c703419d31f --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/WalletDeletionTests.swift @@ -0,0 +1,204 @@ +import SwiftData +import XCTest +@testable import SwiftDashSDK + +final class WalletDeletionTests: XCTestCase { + + func testIdentityIdsForWallet() throws { + let container = try DashModelContainer.createInMemory() + let context = ModelContext(container) + let walletId = Data(repeating: 0x11, count: 32) + let identityA = Data(repeating: 0x22, count: 32) + let identityB = Data(repeating: 0x33, count: 32) + + let wallet = PersistentWallet(walletId: walletId, network: .testnet) + let first = PersistentIdentity(identityId: identityA, network: .testnet) + let second = PersistentIdentity(identityId: identityB, network: .testnet) + first.wallet = wallet + second.wallet = wallet + wallet.identities.append(contentsOf: [first, second]) + + context.insert(wallet) + context.insert(first) + context.insert(second) + try context.save() + + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + + XCTAssertEqual( + Set(try handler.identityIdsForWallet(walletId: walletId)), + Set([identityA, identityB]) + ) + XCTAssertEqual( + try handler.identityIdsForWallet(walletId: Data(repeating: 0xff, count: 32)), + [] + ) + } + + 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()) + } +}