Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions packages/rs-platform-wallet-ffi/src/platform_address_sync.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
36 changes: 36 additions & 0 deletions packages/rs-platform-wallet/src/manager/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,42 @@ impl<P: PlatformWalletPersistence + 'static> PlatformWalletManager<P> {
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<Arc<PlatformWallet>> = {
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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`].
Expand Down Expand Up @@ -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"
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import Foundation
import SwiftUI
import Combine
import SwiftData
import SwiftDashSDK

/// Observable service managing BLAST address balance sync UI state.
Expand Down Expand Up @@ -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()
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
} catch {
lastError = "Failed to wipe persisted platform-address state: \(error.localizedDescription)"
SDKLogger.error(lastError ?? "")
}

// 3) Zero the published display mirror.
clearDisplay()
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

/// Trigger a manual sync. No-op if already syncing.
func manualSync() async {
await performSync()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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<T: PersistentModel>(_ type: T.Type, in container: ModelContainer) throws -> [T] {
try ModelContext(container).fetch(FetchDescriptor<T>())
}
}
Loading