diff --git a/packages/rs-platform-wallet-ffi/src/platform_address_sync.rs b/packages/rs-platform-wallet-ffi/src/platform_address_sync.rs index 1a97cce8b5..cfd87cfee0 100644 --- a/packages/rs-platform-wallet-ffi/src/platform_address_sync.rs +++ b/packages/rs-platform-wallet-ffi/src/platform_address_sync.rs @@ -195,3 +195,29 @@ pub unsafe extern "C" fn platform_wallet_manager_platform_address_sync_sync_now( unwrap_option_or_return!(option); PlatformWalletFFIResult::ok() } + +/// Reset the platform-address (BLAST/DIP-17) incremental-sync watermark +/// and drop every cached balance across all registered wallets, forcing +/// a full rescan on the next sync. Backs the SwiftExampleApp "Clear" +/// button. +/// +/// `reset_platform_address_sync_state` quiesces the background sync loop +/// before resetting so no in-flight pass can re-write the watermark. The +/// loop is left stopped (not restarted) — the host re-arms it via +/// `..._start`, or uses one-shot `..._sync_now`, afterward. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_manager_platform_address_sync_reset( + handle: Handle, +) -> PlatformWalletFFIResult { + let option = PLATFORM_WALLET_MANAGER_STORAGE.with_item(handle, |manager| { + runtime().block_on(manager.reset_platform_address_sync_state()) + }); + let result = unwrap_option_or_return!(option); + if let Err(e) = result { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorWalletOperation, + format!("reset_platform_address_sync_state failed: {e}"), + ); + } + PlatformWalletFFIResult::ok() +} diff --git a/packages/rs-platform-wallet/src/manager/mod.rs b/packages/rs-platform-wallet/src/manager/mod.rs index 3d04ca086d..718993e8a4 100644 --- a/packages/rs-platform-wallet/src/manager/mod.rs +++ b/packages/rs-platform-wallet/src/manager/mod.rs @@ -289,6 +289,42 @@ impl PlatformWalletManager

{ Ok(()) } + /// Reset the platform-address (BLAST/DIP-17) incremental-sync + /// watermark and drop every cached balance across **all** + /// registered wallets, forcing a full rescan on the next sync. + /// + /// Backs the SwiftExampleApp "Clear" button. Manager-level (not + /// per-wallet) to match [`clear_shielded`](Self::clear_shielded): + /// the host's persistence delete is global, so a per-wallet reset + /// would leave sibling wallets' in-memory watermarks to + /// re-populate the deleted rows on the next sync. + /// + /// Quiesces the platform-address sync manager first so no in-flight + /// pass can call `update_sync_state` and re-write the watermark (or + /// re-seed balances) *after* the reset. Does NOT restart the loop — + /// manual "Sync Now" works without it, and leaving it stopped is + /// the desired UX: data stays cleared until the user explicitly + /// resyncs. `quiesce` leaves the manager stopped-but-restartable. + pub async fn reset_platform_address_sync_state( + &self, + ) -> Result<(), crate::error::PlatformWalletError> { + self.platform_address_sync_manager.quiesce().await; + + // Snapshot Arc clones under a short read lock; never hold the + // `wallets` read guard across the per-wallet `.await`s below — + // that would block registration and invite lock-ordering + // issues against each wallet's `wallet_manager` lock. + let wallets: Vec> = { + let guard = self.wallets.read().await; + guard.values().cloned().collect() + }; + + for wallet in wallets { + wallet.platform().reset_sync_state().await; + } + Ok(()) + } + /// Stop all background tasks and wait for them to exit. /// /// **Quiesces** the periodic coordinators diff --git a/packages/rs-platform-wallet/src/wallet/platform_addresses/provider.rs b/packages/rs-platform-wallet/src/wallet/platform_addresses/provider.rs index d56c004c12..b8d4cc653f 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_addresses/provider.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_addresses/provider.rs @@ -475,6 +475,36 @@ impl PlatformPaymentAddressProvider { self.last_known_recent_block = last_known_recent_block; } + /// Reset the incremental-sync watermark and drop every cached + /// balance so the next `sync_balances` performs a full + /// trunk/branch/compact rescan from genesis instead of an + /// incremental catch-up. + /// + /// Backs the host's "Clear" flow. Zeroing the three watermark + /// scalars alone is not enough: `found` doubles as the + /// `current_balances()` seed for the next pass (and the `before` + /// snapshot for the persistence diff), so a non-empty `found` + /// would re-seed the very balances Clear is meant to wipe. + /// `sync_timestamp == 0` is what flips `last_sync_timestamp()` + /// back to `None` and the SDK back into full-scan mode. + /// + /// The `addresses` bijection is intentionally preserved — + /// `prepare_for_sync` rebuilds `pending` from it each pass, so + /// keeping it avoids needless re-derivation while still forcing a + /// full rescan. + pub(crate) fn reset_sync_state(&mut self) { + self.sync_height = 0; + self.sync_timestamp = 0; + self.last_known_recent_block = 0; + self.per_wallet_in_sync.clear(); + for state in self.per_wallet.values_mut() { + for account_state in state.values_mut() { + account_state.found.clear(); + account_state.absent.clear(); + } + } + } + /// Diagnostic snapshot counts used by the read-only memory /// explorer surface on /// [`crate::manager::PlatformWalletManager::platform_address_provider_state_blocking`]. @@ -1105,4 +1135,38 @@ mod tests { "on_address_absent must zero the in-memory managed-account balance" ); } + + /// `reset_sync_state` must zero the incremental watermark AND drop + /// the cached `found` seed, so the next pass is a full rescan rather + /// than an incremental catch-up. This is the core of the platform + /// "Clear" fix — without the seed drop, a non-empty `found` would + /// re-seed the balances the next incremental round, and a non-zero + /// `sync_timestamp` would keep the SDK out of full-scan mode. + #[tokio::test] + async fn reset_sync_state_clears_watermark_and_seed() { + let addr = p2pkh(1); + let mut provider = provider_with_one_funded_address(addr, funds(294_627_247_940, 5)); + + // Simulate a wallet mid-incremental-sync: non-zero watermark and + // a populated balance seed. + provider.set_stored_sync_state(10, 20, 30); + assert_eq!(provider.last_sync_height(), 10); + assert_eq!(provider.last_sync_timestamp(), Some(20)); + assert_eq!(provider.last_known_recent_block(), 30); + assert_eq!(provider.current_balances().count(), 1); + + provider.reset_sync_state(); + + // Watermark fully zeroed → SDK drops back to full-scan mode + // (`last_sync_timestamp() == None` is the full-scan trigger). + assert_eq!(provider.last_sync_height(), 0); + assert_eq!(provider.last_sync_timestamp(), None); + assert_eq!(provider.last_known_recent_block(), 0); + // Seed emptied → nothing re-seeds the next incremental pass. + assert_eq!( + provider.current_balances().count(), + 0, + "reset must drop the cached `found` seed" + ); + } } diff --git a/packages/rs-platform-wallet/src/wallet/platform_addresses/wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_addresses/wallet.rs index a4bb1bd1e5..a59cbe2f7a 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_addresses/wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_addresses/wallet.rs @@ -223,6 +223,43 @@ impl PlatformAddressWallet { .await; } + /// Reset the platform-address sync watermark and drop every cached + /// balance for this wallet, forcing a full trunk/branch/compact + /// rescan on the next `sync_balances`. + /// + /// Backs the host's "Clear" flow. Clears BOTH in-memory balance + /// stores a resume would otherwise read from: + /// * the provider's incremental seed (`found`) + watermark — what + /// makes a resync "fast" (see + /// [`PlatformPaymentAddressProvider::reset_sync_state`]); + /// * each `ManagedPlatformAccount`'s `address_balances` map — what + /// [`addresses_with_balances`](Self::addresses_with_balances) / + /// `total_credits` and the transfer/withdraw spend paths read. + /// Without this the UI/spend paths would keep reporting stale + /// balances until the next full sync re-zeroed them via the + /// absent diff. + /// + /// Does NOT route through [`apply_sync_state`] — that helper's + /// all-None early-return guard is meant for persisted-state replay + /// and is irrelevant here. The two locks are taken sequentially + /// (one released before the next is acquired), so there is no + /// nested-lock hazard; this mirrors the ordering rationale in + /// [`initialize_from_persisted`]. + pub async fn reset_sync_state(&self) { + { + let mut wm = self.wallet_manager.write().await; + if let Some(info) = wm.get_wallet_info_mut(&self.wallet_id) { + for account in info.core_wallet.all_platform_payment_managed_accounts_mut() { + account.clear_balances(); + } + } + } + let mut guard = self.provider.write().await; + if let Some(provider) = guard.as_mut() { + provider.reset_sync_state(); + } + } + /// Internal accessor for the diagnostic snapshot path on /// [`crate::manager::PlatformWalletManager`]. The provider lock is /// otherwise crate-private — the manager-level snapshot needs to diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift index 0e433d368e..1a48986f2a 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift @@ -4,12 +4,13 @@ import Combine import DashSDKFFI /// Lock-guarded monotonic generation counter, safe to read and bump from -/// any thread. Used to drop shielded sync completion events that belong -/// to a generation already superseded by a `stop`/`clear`, even when a +/// any thread. Used to drop sync completion events that belong to a +/// generation already superseded by a `stop`/`clear`/`reset`, even when a /// restart happens in the same `@MainActor` turn (a plain boolean gate /// can't, because the restart re-opens the gate before the stale, -/// previously-enqueued completion task runs). -final class ShieldedSyncGenerationCounter: @unchecked Sendable { +/// previously-enqueued completion task runs). Shared by the shielded and +/// platform-address sync paths. +final class SyncGenerationCounter: @unchecked Sendable { private let lock = NSLock() private var value: UInt64 = 0 func current() -> UInt64 { lock.withLock { value } } @@ -108,7 +109,16 @@ public class PlatformWalletManager: ObservableObject { /// /// `nonisolated` + lock-guarded so the FFI callback thread can snapshot /// it without hopping onto the main actor first. - nonisolated let shieldedSyncGeneration = ShieldedSyncGenerationCounter() + nonisolated let shieldedSyncGeneration = SyncGenerationCounter() + + /// Generation guard for platform-address (BLAST/DIP-17) sync + /// completion events, mirroring [`shieldedSyncGeneration`]. The FFI + /// completion callback snapshots this on its own thread before the + /// main-actor hop; `stopPlatformAddressSync` / `resetPlatformAddressSyncState` + /// bump it so a trailing completion the main actor delivers *after* + /// the stop/reset is dropped instead of repainting the just-cleared + /// sync-status UI. + nonisolated let platformAddressSyncGeneration = SyncGenerationCounter() /// All wallets currently held by the Rust-side /// `PlatformWalletManager`, keyed by the 32-byte wallet id. @@ -832,4 +842,19 @@ public class PlatformWalletManager: ObservableObject { } } } + + /// Drop the platform-address published mirror after a reset/clear so a + /// later `configure()` re-subscribe — which Combine replays the current + /// `@Published` value to a fresh subscriber — can't repaint stale sync + /// state over a just-cleared UI. + /// + /// Lives here (not in the `…AddressSync` extension) because + /// `platformAddressSyncIsSyncing` is `private(set)`; the generation guard + /// only blocks *future* stale callbacks and can't un-publish a value + /// already held on these `@Published` properties. Called by + /// `resetPlatformAddressSyncState` after the Rust drain returns. + func resetPlatformAddressPublishedMirror() { + lastPlatformAddressSyncEvent = nil + platformAddressSyncIsSyncing = false + } } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerAddressSync.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerAddressSync.swift index d26d47aefd..64c40d8521 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerAddressSync.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerAddressSync.swift @@ -80,8 +80,13 @@ private func platformAddressSyncCompletedCallback( walletResults: results ) + // Snapshot the generation now, on the FFI callback thread, BEFORE the + // main-actor hop, so a stop/reset that bumps the counter after this + // point invalidates the trailing event (mirrors the shielded path). + let generation = handler.manager?.platformAddressSyncGeneration.current() ?? 0 + Task { @MainActor [weak manager = handler.manager] in - manager?.handlePlatformAddressSyncCompleted(event) + manager?.handlePlatformAddressSyncCompleted(event, generation: generation) } } @@ -100,7 +105,14 @@ private extension AddressSyncMetrics { } extension PlatformWalletManager { - func handlePlatformAddressSyncCompleted(_ event: PlatformAddressSyncEvent) { + func handlePlatformAddressSyncCompleted(_ event: PlatformAddressSyncEvent, generation: UInt64) { + // Drop a trailing event the Rust drain already dispatched but the + // main actor only delivers after a stop/reset bumped the counter — + // its snapshot predates the bump. Without this, a completion from a + // pass drained by `resetPlatformAddressSyncState` (Clear) repaints + // chain-tip height, last-sync time, and metrics over the freshly + // cleared UI. Mirrors the shielded guard. + guard generation == platformAddressSyncGeneration.current() else { return } lastPlatformAddressSyncEvent = event } @@ -132,6 +144,10 @@ extension PlatformWalletManager { } try platform_wallet_manager_platform_address_sync_stop(handle).check() + // The Rust drain returned; bump the generation so any trailing + // completion the main actor delivers after this point is dropped + // (its snapshot predates this bump). Mirrors the shielded stop. + platformAddressSyncGeneration.bump() } public func isPlatformAddressSyncRunning() throws -> Bool { @@ -219,4 +235,37 @@ extension PlatformWalletManager { try platform_wallet_manager_platform_address_sync_sync_now(handle).check() }.value } + + /// Reset the platform-address (BLAST/DIP-17) incremental-sync + /// watermark and drop every cached balance across all registered + /// wallets, forcing a full rescan on the next sync. Backs the + /// SwiftExampleApp Platform Sync "Clear" button. + /// + /// Quiesces the background sync loop before resetting (so no + /// in-flight pass re-writes the watermark) and leaves it stopped — + /// callers re-arm via `startPlatformAddressSync` or one-shot + /// `syncPlatformAddressNow`. Runs off the main actor because the + /// quiesce drains any in-flight pass. + public func resetPlatformAddressSyncState() async throws { + guard isConfigured, handle != NULL_HANDLE else { + throw PlatformWalletError.invalidHandle( + "PlatformWalletManager not configured" + ) + } + + let handle = self.handle + try await Task.detached(priority: .userInitiated) { + try platform_wallet_manager_platform_address_sync_reset(handle).check() + }.value + // The Rust reset quiesced + drained the in-flight pass; bump the + // generation so a trailing completion captured before this point + // (and delivered onto the main actor after Clear) is dropped + // instead of repainting the just-cleared sync-status UI. + platformAddressSyncGeneration.bump() + // Drop the retained published mirror too: the generation guard only + // blocks future stale callbacks, but a later `configure()` would + // replay the current `@Published` value to its fresh subscriber and + // repaint the cleared UI. + resetPlatformAddressPublishedMirror() + } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/PlatformBalanceSyncService.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/PlatformBalanceSyncService.swift index c7211c5f37..a0fd4832fb 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/PlatformBalanceSyncService.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/PlatformBalanceSyncService.swift @@ -8,6 +8,7 @@ import Foundation import SwiftUI import Combine +import SwiftData import SwiftDashSDK /// Observable service managing BLAST address balance sync UI state. @@ -189,6 +190,79 @@ class PlatformBalanceSyncService: ObservableObject { syncStateCancellable?.cancel() } + /// Clear platform-address sync data for real — what the Platform + /// Sync "Clear" button calls. + /// + /// Plain [`clearDisplay`] only zeroes the in-memory `@Published` + /// mirror, so the next sync resumed from the surviving watermark in + /// ~2s (the "Clear didn't work" symptom). This wipes the stores the + /// synced data actually lives in, mirroring + /// `ShieldedService.clearLocalState`: Rust-side reset FIRST (so the + /// next sync can't re-persist stale rows), then the SwiftData wipe, + /// then the published-mirror reset. + /// + /// Scoped to the **active network**. The SwiftData store holds rows + /// for every network at once (the UI filters them by network), so a + /// blanket `delete(model:)` would also erase other networks' cached + /// platform state. `PersistentPlatformAddress` carries no network + /// column, so it's scoped via `walletIdsOnNetwork` — the same + /// wallet-id-per-network pivot the view uses; + /// `PersistentPlatformAddressesSyncState` is network-keyed and is + /// scoped by `networkRaw`. This matches the manager-level Rust reset, + /// which only touches the active network's registered wallets. + /// + /// Fails closed: if the Rust reset OR the SwiftData delete throws, it + /// surfaces the error in `lastError` and returns WITHOUT calling + /// `clearDisplay()` — the UI never shows a false "cleared" state over + /// data that is still on disk / in Rust memory. + func clearLocalState( + modelContext: ModelContext, + network: Network, + walletIdsOnNetwork: Set + ) async { + // 1) Reset the Rust-owned state BEFORE touching disk. Without + // this the in-memory watermark survives and the next "Sync + // Now" resumes incrementally (fast) instead of doing a full + // rescan; a still-registered background pass could also + // re-persist the rows we're about to delete. Fail closed — + // the reset is load-bearing for the wipe, so abort (surfacing + // the error) rather than leave a half-cleared state. + if let walletManager { + do { + try await walletManager.resetPlatformAddressSyncState() + } catch { + lastError = "Failed to reset platform-address sync state: \(error.localizedDescription)" + SDKLogger.error(lastError ?? "") + return + } + } + + // 2) Delete this network's platform-address rows: the cached + // per-address balances (scoped via the network's wallet-id + // set) and the network-keyed sync-state watermark. + do { + let addresses = try modelContext.fetch(FetchDescriptor()) + for row in addresses where walletIdsOnNetwork.contains(row.walletId) { + modelContext.delete(row) + } + + let networkRaw = network.rawValue + let syncStates = try modelContext.fetch(FetchDescriptor()) + for row in syncStates where row.networkRaw == networkRaw { + modelContext.delete(row) + } + + try modelContext.save() + } catch { + lastError = "Failed to wipe persisted platform-address state: \(error.localizedDescription)" + SDKLogger.error(lastError ?? "") + return + } + + // 3) Zero the published display mirror — only on full success. + clearDisplay() + } + /// Trigger a manual sync. No-op if already syncing. func manualSync() async { await performSync() diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift index 72b6267f63..c2550bec5a 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift @@ -372,7 +372,13 @@ var body: some View { .disabled(platformBalanceSyncService.isSyncing) Button { - platformBalanceSyncService.clearDisplay() + Task { + await platformBalanceSyncService.clearLocalState( + modelContext: modelContext, + network: platformState.currentNetwork, + walletIdsOnNetwork: walletIdsOnNetwork + ) + } } label: { Text("Clear") .font(.caption) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/PlatformAddressSyncGenerationTests.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/PlatformAddressSyncGenerationTests.swift new file mode 100644 index 0000000000..aea5e82439 --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/PlatformAddressSyncGenerationTests.swift @@ -0,0 +1,115 @@ +import XCTest +@testable import SwiftDashSDK + +/// Regression coverage for the platform-address (BLAST/DIP-17) sync +/// generation guard in +/// `PlatformWalletManager.handlePlatformAddressSyncCompleted(_:generation:)`. +/// +/// A completion event is snapshotted with the current generation on the FFI +/// callback thread, then re-dispatched onto the main actor. `stopPlatformAddressSync` +/// / `resetPlatformAddressSyncState` (the Clear path) bump the generation +/// counter, so a trailing event the main actor delivers *after* the stop/reset +/// must be dropped — otherwise it repaints chain-tip height, last-sync time, and +/// metrics over the just-cleared sync-status UI (the exact bug a generation-less +/// platform path had, which Shielded Clear already guards against). +/// +/// `handlePlatformAddressSyncCompleted` does not touch the FFI handle — it only +/// reads `platformAddressSyncGeneration` and assigns `lastPlatformAddressSyncEvent` +/// — so a bare, unconfigured `PlatformWalletManager()` is safe to drive directly. +@MainActor +final class PlatformAddressSyncGenerationTests: XCTestCase { + + private func makeEvent(syncUnixSeconds: UInt64) -> PlatformAddressSyncEvent { + PlatformAddressSyncEvent(syncUnixSeconds: syncUnixSeconds, walletResults: []) + } + + /// The exact race Clear closes: a completion captured under generation N is + /// dropped once the counter is bumped to N+1 before delivery — + /// `lastPlatformAddressSyncEvent` stays nil. + func testStaleCompletionIsDroppedAfterGenerationBump() { + let manager = PlatformWalletManager() + XCTAssertNil(manager.lastPlatformAddressSyncEvent, "fresh manager has no event") + + // Snapshot at "enqueue" time the way the FFI callback thread does, + // then advance it (as stop/reset would) before delivery. + let capturedGeneration = manager.platformAddressSyncGeneration.current() + manager.platformAddressSyncGeneration.bump() + + manager.handlePlatformAddressSyncCompleted( + makeEvent(syncUnixSeconds: 1_000), + generation: capturedGeneration + ) + + XCTAssertNil( + manager.lastPlatformAddressSyncEvent, + "a completion captured under a superseded generation must be dropped" + ) + } + + /// A completion captured under the CURRENT generation is published, so a + /// normal sync (no intervening Clear) still updates the UI. + func testCurrentGenerationCompletionIsPublished() { + let manager = PlatformWalletManager() + + let currentGeneration = manager.platformAddressSyncGeneration.current() + manager.handlePlatformAddressSyncCompleted( + makeEvent(syncUnixSeconds: 2_000), + generation: currentGeneration + ) + + XCTAssertEqual( + manager.lastPlatformAddressSyncEvent?.syncUnixSeconds, + 2_000, + "a completion captured under the current generation must be published" + ) + } + + /// A previously-published event must not be clobbered by a late straggler + /// carrying a superseded (pre-bump) generation — the cleared/updated state + /// stays put. + func testStaleCompletionDoesNotOverwriteAlreadyPublishedEvent() { + let manager = PlatformWalletManager() + + let firstGeneration = manager.platformAddressSyncGeneration.current() + manager.handlePlatformAddressSyncCompleted( + makeEvent(syncUnixSeconds: 5_000), + generation: firstGeneration + ) + XCTAssertEqual(manager.lastPlatformAddressSyncEvent?.syncUnixSeconds, 5_000) + + // Bump (stop/reset), then deliver a straggler carrying the old snapshot. + manager.platformAddressSyncGeneration.bump() + manager.handlePlatformAddressSyncCompleted( + makeEvent(syncUnixSeconds: 6_000), + generation: firstGeneration + ) + + XCTAssertEqual( + manager.lastPlatformAddressSyncEvent?.syncUnixSeconds, + 5_000, + "a superseded straggler must not overwrite the last valid event" + ) + } + + /// After a reset the retained published mirror must be dropped. The + /// generation guard only blocks *future* stale callbacks; the value + /// already held on `lastPlatformAddressSyncEvent` would otherwise replay to + /// a fresh `configure()` subscriber (Combine emits the current `@Published` + /// value on subscribe) and repaint the cleared sync-status UI. + func testResetPublishedMirrorDropsRetainedEvent() { + let manager = PlatformWalletManager() + manager.lastPlatformAddressSyncEvent = makeEvent(syncUnixSeconds: 7_000) + XCTAssertNotNil(manager.lastPlatformAddressSyncEvent) + + manager.resetPlatformAddressPublishedMirror() + + XCTAssertNil( + manager.lastPlatformAddressSyncEvent, + "reset must drop the retained completion so it can't replay on re-subscribe" + ) + XCTAssertFalse( + manager.platformAddressSyncIsSyncing, + "reset must clear the syncing mirror so a re-subscribe doesn't replay a stale spinner" + ) + } +} diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/PlatformBalanceSyncServiceClearTests.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/PlatformBalanceSyncServiceClearTests.swift new file mode 100644 index 0000000000..939b18f9af --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/PlatformBalanceSyncServiceClearTests.swift @@ -0,0 +1,111 @@ +import SwiftData +import XCTest +@testable import SwiftDashSDK +@testable import SwiftExampleApp + +@MainActor +final class PlatformBalanceSyncServiceClearTests: XCTestCase { + + /// The Platform Sync "Clear" button must delete BOTH platform-address + /// SwiftData stores — the cached per-address balances + /// (`PersistentPlatformAddress`) and the network-scoped sync-state + /// watermark (`PersistentPlatformAddressesSyncState`) — so the UI + /// reads zero and the next sync is a full rescan rather than a ~2s + /// incremental resume (the reported "Clear didn't work" symptom). + /// + /// The Rust-side watermark reset is skipped here because no wallet + /// manager is configured (the `if let walletManager` guard short- + /// circuits); that path is covered by the platform-wallet Rust unit + /// test (`reset_sync_state_clears_watermark_and_seed`) and manual + /// simulator verification. + func testClearLocalStateWipesActiveNetworkRowsAndPreservesOthers() async throws { + let container = try DashModelContainer.createInMemory() + let context = ModelContext(container) + let testnetWalletId = Data(repeating: 0x44, count: 32) + let mainnetWalletId = Data(repeating: 0x55, count: 32) + + // Active-network (testnet) rows — these must be deleted. + context.insert( + PersistentPlatformAddress( + address: "yTestnetPlatformAddr", + addressType: 0, + addressHash: Data(repeating: 0x01, count: 20), + accountIndex: 0, + addressIndex: 0, + derivationPath: "m/9'/1'/17'/0'/0'/0", + balance: 294_627_247_940, + walletId: testnetWalletId + ) + ) + context.insert( + PersistentPlatformAddressesSyncState( + walletId: Self.syncStateScopeId(for: .testnet), + network: .testnet, + syncHeight: 10, + syncTimestamp: 20, + lastKnownRecentBlock: 30 + ) + ) + + // Other-network (mainnet) rows — these must SURVIVE, since the + // SwiftData store holds every network's rows at once and Clear is + // scoped to the active network only. + context.insert( + PersistentPlatformAddress( + address: "XMainnetPlatformAddr", + addressType: 0, + addressHash: Data(repeating: 0x02, count: 20), + accountIndex: 0, + addressIndex: 0, + derivationPath: "m/9'/5'/17'/0'/0'/0", + balance: 111_111, + walletId: mainnetWalletId + ) + ) + context.insert( + PersistentPlatformAddressesSyncState( + walletId: Self.syncStateScopeId(for: .mainnet), + network: .mainnet, + syncHeight: 99, + syncTimestamp: 88, + lastKnownRecentBlock: 77 + ) + ) + try context.save() + + // Sanity: both networks' rows present before the clear. + XCTAssertEqual(try fetch(PersistentPlatformAddress.self, in: container).count, 2) + XCTAssertEqual(try fetch(PersistentPlatformAddressesSyncState.self, in: container).count, 2) + + let service = PlatformBalanceSyncService() + await service.clearLocalState( + modelContext: context, + network: .testnet, + walletIdsOnNetwork: [testnetWalletId] + ) + + // Active-network (testnet) rows gone. + let remainingAddresses = try fetch(PersistentPlatformAddress.self, in: container) + XCTAssertEqual( + remainingAddresses.map(\.walletId), [mainnetWalletId], + "only testnet's cached per-address balances must be deleted" + ) + let remainingStates = try fetch(PersistentPlatformAddressesSyncState.self, in: container) + XCTAssertEqual( + remainingStates.map(\.networkRaw), [Network.mainnet.rawValue], + "only testnet's sync-state watermark must be deleted; mainnet must survive" + ) + } + + 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()) + } +}