From 09333bad8951203e26243a158422c3ac1d747c7a Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Wed, 24 Jun 2026 13:18:52 +0200 Subject: [PATCH 1/4] fix(swift-example-app): make Platform Sync "Clear" actually clear synced data The "Clear" button in the Platform Sync Status section only called `clearDisplay()`, which zeroes the in-memory @Published mirror but leaves the synced data intact in all three places it actually lives: - SwiftData rows (`PersistentPlatformAddress`, `PersistentPlatformAddressesSyncState`) - the in-memory Rust incremental-sync watermark in the address provider - the in-memory managed-account balance map (read by spend paths) Because the watermark survived, the next "Sync Now" resumed incrementally in ~2s instead of doing a full rescan, so it looked like Clear did nothing. This wires the button to a real wipe mirroring the Shielded Sync "Clear" precedent. Reset is manager-level (all wallets on the network) to match the global SwiftData delete; the sync loop is quiesced first so no in-flight pass re-persists the rows after the reset, and is left stopped (manual "Sync Now" still works) so the data stays cleared until the user explicitly resyncs. - rs-platform-wallet: `PlatformPaymentAddressProvider::reset_sync_state` (zero watermark + drop `found`/`absent` seed) and `PlatformAddressWallet::reset_sync_state` (also clears managed-account balances via `all_platform_payment_managed_accounts_mut().clear_balances()`). - rs-platform-wallet manager: `reset_platform_address_sync_state` (quiesce + reset every registered wallet). - rs-platform-wallet-ffi: `platform_wallet_manager_platform_address_sync_reset`. - swift-sdk: `PlatformWalletManager.resetPlatformAddressSyncState()`. - SwiftExampleApp: `PlatformBalanceSyncService.clearLocalState(modelContext:)` (Rust reset -> delete SwiftData rows -> clearDisplay) and the rewired button. Tests: Rust unit test `reset_sync_state_clears_watermark_and_seed` and Swift `PlatformBalanceSyncServiceClearTests` both pass; full platform-wallet lib suite (203) green; `build_ios.sh --target sim` and the SwiftExampleApp build succeed with the new FFI symbol in the regenerated header. Co-Authored-By: Claude Opus 4.8 --- .../src/platform_address_sync.rs | 26 +++++++ .../rs-platform-wallet/src/manager/mod.rs | 36 +++++++++ .../src/wallet/platform_addresses/provider.rs | 64 +++++++++++++++ .../src/wallet/platform_addresses/wallet.rs | 37 +++++++++ .../PlatformWalletManagerAddressSync.swift | 23 ++++++ .../Services/PlatformBalanceSyncService.swift | 49 ++++++++++++ .../Core/Views/CoreContentView.swift | 6 +- ...PlatformBalanceSyncServiceClearTests.swift | 77 +++++++++++++++++++ 8 files changed, 317 insertions(+), 1 deletion(-) create mode 100644 packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/PlatformBalanceSyncServiceClearTests.swift 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 1a97cce8b5b..cfd87cfee0f 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 3d04ca086d0..718993e8a4e 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 d56c004c122..b8d4cc653f7 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 a4bb1bd1e53..a59cbe2f7ad 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/PlatformWalletManagerAddressSync.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerAddressSync.swift index d26d47aefd6..a086e366bde 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerAddressSync.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerAddressSync.swift @@ -219,4 +219,27 @@ 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 + } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/PlatformBalanceSyncService.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/PlatformBalanceSyncService.swift index c7211c5f37f..1011cd12212 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,54 @@ 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 all three + /// 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. + /// + /// The Rust reset and the SwiftData delete are both network-wide + /// (every wallet) — the Clear button lives on the global Sync Status + /// surface, so its semantics are "blow away platform persistence", + /// not "scope to one wallet". + func clearLocalState(modelContext: ModelContext) 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. Best-effort — + // failure logs but doesn't abort the wipe. + if let walletManager { + do { + try await walletManager.resetPlatformAddressSyncState() + } catch { + SDKLogger.error( + "PlatformBalanceSyncService.clearLocalState: resetPlatformAddressSyncState failed: \(error.localizedDescription)" + ) + } + } + + // 2) Delete every platform-address SwiftData row across all + // wallets on this device: the cached per-address balances and + // the network-scoped sync-state watermark. + do { + try modelContext.delete(model: PersistentPlatformAddress.self) + try modelContext.delete(model: PersistentPlatformAddressesSyncState.self) + try modelContext.save() + } catch { + lastError = "Failed to wipe persisted platform-address state: \(error.localizedDescription)" + SDKLogger.error(lastError ?? "") + } + + // 3) Zero the published display mirror. + 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 72b6267f631..0d8dc5121e8 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift @@ -372,7 +372,11 @@ var body: some View { .disabled(platformBalanceSyncService.isSyncing) Button { - platformBalanceSyncService.clearDisplay() + Task { + await platformBalanceSyncService.clearLocalState( + modelContext: modelContext + ) + } } label: { Text("Clear") .font(.caption) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/PlatformBalanceSyncServiceClearTests.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/PlatformBalanceSyncServiceClearTests.swift new file mode 100644 index 00000000000..a04c4ed109d --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/PlatformBalanceSyncServiceClearTests.swift @@ -0,0 +1,77 @@ +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 testClearLocalStateWipesPlatformAddressRows() async throws { + let container = try DashModelContainer.createInMemory() + let context = ModelContext(container) + let walletId = Data(repeating: 0x44, count: 32) + + context.insert( + PersistentPlatformAddress( + address: "yTestPlatformAddr", + 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: walletId + ) + ) + context.insert( + PersistentPlatformAddressesSyncState( + walletId: Self.syncStateScopeId(for: .testnet), + network: .testnet, + syncHeight: 10, + syncTimestamp: 20, + lastKnownRecentBlock: 30 + ) + ) + try context.save() + + // Sanity: both rows present before the clear. + XCTAssertEqual(try fetch(PersistentPlatformAddress.self, in: container).count, 1) + XCTAssertEqual(try fetch(PersistentPlatformAddressesSyncState.self, in: container).count, 1) + + let service = PlatformBalanceSyncService() + await service.clearLocalState(modelContext: context) + + XCTAssertTrue( + try fetch(PersistentPlatformAddress.self, in: container).isEmpty, + "cached per-address balances must be deleted" + ) + XCTAssertTrue( + try fetch(PersistentPlatformAddressesSyncState.self, in: container).isEmpty, + "the sync-state watermark must be deleted so the next sync is a full rescan" + ) + } + + 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()) + } +} From 8d3ca393e736e71d9a1831eb5ac9fb94874a4fa9 Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Wed, 24 Jun 2026 13:42:58 +0200 Subject: [PATCH 2/4] fix(swift-example-app): fail closed and scope platform Clear to active network MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses CodeRabbit review on #3959: - Fail closed: `clearLocalState` previously fell through to `clearDisplay()` (which nils `lastError`) even when the SwiftData delete threw, showing a false "cleared" UI over data still on disk. Now both the Rust reset and the delete return early on failure with `lastError` set; `clearDisplay()` runs only on full success — matching the ShieldedService precedent. - Scope to active network: the SwiftData store holds every network's `PersistentPlatformAddress` / `PersistentPlatformAddressesSyncState` rows at once (the UI filters by network), so a blanket `delete(model:)` also wiped mainnet/devnet cached state when clearing on testnet. The delete is now scoped to the active network — addresses via the `walletIdsOnNetwork` pivot the view already uses, sync-state via `networkRaw` — matching the manager-level Rust reset, which only touches the active network's wallets. The test now seeds two networks and asserts the active one is wiped while the other survives. Passes on the iPhone 17 simulator. Co-Authored-By: Claude Opus 4.8 --- .../Services/PlatformBalanceSyncService.swift | 61 ++++++++++++------ .../Core/Views/CoreContentView.swift | 4 +- ...PlatformBalanceSyncServiceClearTests.swift | 62 ++++++++++++++----- 3 files changed, 94 insertions(+), 33 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/PlatformBalanceSyncService.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/PlatformBalanceSyncService.swift index 1011cd12212..a0fd4832fbb 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/PlatformBalanceSyncService.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/PlatformBalanceSyncService.swift @@ -195,46 +195,71 @@ class PlatformBalanceSyncService: ObservableObject { /// /// 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 all three - /// stores the synced data actually lives in, mirroring + /// ~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. /// - /// The Rust reset and the SwiftData delete are both network-wide - /// (every wallet) — the Clear button lives on the global Sync Status - /// surface, so its semantics are "blow away platform persistence", - /// not "scope to one wallet". - func clearLocalState(modelContext: ModelContext) async { + /// 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. Best-effort — - // failure logs but doesn't abort the wipe. + // 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 { - SDKLogger.error( - "PlatformBalanceSyncService.clearLocalState: resetPlatformAddressSyncState failed: \(error.localizedDescription)" - ) + lastError = "Failed to reset platform-address sync state: \(error.localizedDescription)" + SDKLogger.error(lastError ?? "") + return } } - // 2) Delete every platform-address SwiftData row across all - // wallets on this device: the cached per-address balances and - // the network-scoped sync-state watermark. + // 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 { - try modelContext.delete(model: PersistentPlatformAddress.self) - try modelContext.delete(model: PersistentPlatformAddressesSyncState.self) + 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. + // 3) Zero the published display mirror — only on full success. clearDisplay() } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift index 0d8dc5121e8..c2550bec5ad 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift @@ -374,7 +374,9 @@ var body: some View { Button { Task { await platformBalanceSyncService.clearLocalState( - modelContext: modelContext + modelContext: modelContext, + network: platformState.currentNetwork, + walletIdsOnNetwork: walletIdsOnNetwork ) } } label: { diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/PlatformBalanceSyncServiceClearTests.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/PlatformBalanceSyncServiceClearTests.swift index a04c4ed109d..939b18f9afb 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/PlatformBalanceSyncServiceClearTests.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/PlatformBalanceSyncServiceClearTests.swift @@ -18,21 +18,23 @@ final class PlatformBalanceSyncServiceClearTests: XCTestCase { /// circuits); that path is covered by the platform-wallet Rust unit /// test (`reset_sync_state_clears_watermark_and_seed`) and manual /// simulator verification. - func testClearLocalStateWipesPlatformAddressRows() async throws { + func testClearLocalStateWipesActiveNetworkRowsAndPreservesOthers() async throws { let container = try DashModelContainer.createInMemory() let context = ModelContext(container) - let walletId = Data(repeating: 0x44, count: 32) + 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: "yTestPlatformAddr", + 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: walletId + walletId: testnetWalletId ) ) context.insert( @@ -44,22 +46,54 @@ final class PlatformBalanceSyncServiceClearTests: XCTestCase { 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 rows present before the clear. - XCTAssertEqual(try fetch(PersistentPlatformAddress.self, in: container).count, 1) - XCTAssertEqual(try fetch(PersistentPlatformAddressesSyncState.self, in: container).count, 1) + // 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) + await service.clearLocalState( + modelContext: context, + network: .testnet, + walletIdsOnNetwork: [testnetWalletId] + ) - XCTAssertTrue( - try fetch(PersistentPlatformAddress.self, in: container).isEmpty, - "cached per-address balances must be deleted" + // 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" ) - XCTAssertTrue( - try fetch(PersistentPlatformAddressesSyncState.self, in: container).isEmpty, - "the sync-state watermark must be deleted so the next sync is a full rescan" + 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" ) } From 7ee5034358c325e7ae186ffbd249b6511866be8d Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Wed, 24 Jun 2026 14:22:08 +0200 Subject: [PATCH 3/4] fix(swift-sdk): drop stale platform-address sync completions after Clear MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses a Codex review finding on #3959: platform-address sync completions were dispatched onto the main actor with no generation guard, unlike the shielded path. A completion callback from a pass drained by `resetPlatformAddressSyncState` (Clear) could already be enqueued as a `Task { @MainActor … }`; when it ran after `clearDisplay()`, it repainted chain-tip height, last-sync time, block time, metrics, and `lastSyncTime` over the just-cleared UI — making it look synced again immediately after Clear (the balance itself stayed 0 since it reads from the deleted SwiftData rows). Mirror the proven shielded generation guard for platform-address sync: - Generalize `ShieldedSyncGenerationCounter` -> `SyncGenerationCounter` (its mechanism was already generic) and add a `platformAddressSyncGeneration` counter on PlatformWalletManager. - The FFI completion callback snapshots the generation on its own thread before the main-actor hop; `handlePlatformAddressSyncCompleted` now guards `generation == platformAddressSyncGeneration.current()` and drops stale events. - `resetPlatformAddressSyncState` (Clear) and `stopPlatformAddressSync` bump the counter after the Rust drain returns, so a trailing completion whose snapshot predates the bump is dropped. Adds `PlatformAddressSyncGenerationTests` (stale dropped after bump, current published, straggler doesn't clobber) — mirrors `ShieldedSyncGenerationTests`. All pass on the iPhone 17 simulator; existing clear test still green. Pure Swift change — the Rust xcframework is unchanged. Co-Authored-By: Claude Opus 4.8 --- .../PlatformWalletManager.swift | 20 +++- .../PlatformWalletManagerAddressSync.swift | 25 ++++- .../PlatformAddressSyncGenerationTests.swift | 93 +++++++++++++++++++ 3 files changed, 131 insertions(+), 7 deletions(-) create mode 100644 packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/PlatformAddressSyncGenerationTests.swift diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift index 0e433d368ef..c420dc22b5b 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. diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerAddressSync.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerAddressSync.swift index a086e366bde..05efd95ef3e 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 { @@ -241,5 +257,10 @@ extension PlatformWalletManager { 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() } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/PlatformAddressSyncGenerationTests.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/PlatformAddressSyncGenerationTests.swift new file mode 100644 index 00000000000..1c3f6910c70 --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/PlatformAddressSyncGenerationTests.swift @@ -0,0 +1,93 @@ +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" + ) + } +} From 93f3bf0b9b6065618e2b017aed848891ce67034e Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Wed, 24 Jun 2026 14:34:39 +0200 Subject: [PATCH 4/4] fix(swift-sdk): clear retained platform-address published state on reset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses a CodeRabbit re-review finding on #3959. The generation guard only blocks *future* stale completion callbacks; it can't un-publish the value already held on `lastPlatformAddressSyncEvent`. The service's `configure()` re-subscribes with a fresh `.sink`, and Combine replays the current `@Published` value to a new subscriber — so after Clear, a later re-configure would replay the retained event and repaint chain-tip height, last-sync time, and metrics over the cleared UI. `resetPlatformAddressSyncState()` now calls a new `resetPlatformAddressPublishedMirror()` (lives in PlatformWalletManager.swift because `platformAddressSyncIsSyncing` is `private(set)`) which nils `lastPlatformAddressSyncEvent` and sets `platformAddressSyncIsSyncing = false` after the Rust drain. Adds `testResetPublishedMirrorDropsRetainedEvent`. All four generation tests pass on the iPhone 17 simulator. Pure Swift — the Rust xcframework is unchanged. Co-Authored-By: Claude Opus 4.8 --- .../PlatformWalletManager.swift | 15 +++++++++++++ .../PlatformWalletManagerAddressSync.swift | 5 +++++ .../PlatformAddressSyncGenerationTests.swift | 22 +++++++++++++++++++ 3 files changed, 42 insertions(+) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift index c420dc22b5b..1a48986f2ab 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift @@ -842,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 05efd95ef3e..64c40d85213 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerAddressSync.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerAddressSync.swift @@ -262,5 +262,10 @@ extension PlatformWalletManager { // (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/SwiftExampleAppTests/PlatformAddressSyncGenerationTests.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/PlatformAddressSyncGenerationTests.swift index 1c3f6910c70..aea5e824398 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/PlatformAddressSyncGenerationTests.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/PlatformAddressSyncGenerationTests.swift @@ -90,4 +90,26 @@ final class PlatformAddressSyncGenerationTests: XCTestCase { "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" + ) + } }