Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
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 @@ -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 } }
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand All @@ -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
}

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -219,4 +235,32 @@ 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()
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}
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,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<Data>
) 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<PersistentPlatformAddress>())
for row in addresses where walletIdsOnNetwork.contains(row.walletId) {
modelContext.delete(row)
}
Comment on lines +244 to +247

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Blocking: Clear deletes durable platform-address metadata that the balance callback cannot rebuild

PersistentPlatformAddress is not a balance cache — it stores the bech32m address, 20-byte hash, derivation path, account/address index, and 33-byte public key (see PersistentPlatformAddress.swift:33-71). On the Rust side, PlatformPaymentAddressProvider::reset_sync_state intentionally preserves the addresses bijection (provider.rs:491-494), and AccountAddressPoolEntrys are only pushed during initial register_wallet (wallet_lifecycle.rs:312) — no BLAST sync round, pool extension, or used-flip re-emits them. The balance callback persistAddressBalances keys by addressHash and explicitly continues when the row is missing (PlatformWalletPersistenceHandler.swift:97-99). So after Clear deletes the rows, the next BLAST sync updates Rust-side balances but cannot re-create the SwiftData rows, and the Platform Sync UI stays at zero for the remainder of the live session. The fail-closed handling and active-network scoping added in this revision are correct but don't address this root cause. Zero the volatile fields in place instead, and still delete the network-scoped PersistentPlatformAddressesSyncState row to force a full rescan.

Suggested change
let addresses = try modelContext.fetch(FetchDescriptor<PersistentPlatformAddress>())
for row in addresses where walletIdsOnNetwork.contains(row.walletId) {
modelContext.delete(row)
}
let addresses = try modelContext.fetch(FetchDescriptor<PersistentPlatformAddress>())
for row in addresses where walletIdsOnNetwork.contains(row.walletId) {
row.balance = 0
row.nonce = 0
row.isUsed = false
row.firstSeenHeight = 0
row.lastSeenHeight = 0
row.lastUpdated = Date()
}

source: ['codex']

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in this update — Clear deletes durable platform-address metadata that the balance callback cannot rebuild no longer present.

Auto-resolved by the review system based on the latest commit diff. If you believe this was closed in error, reopen the thread.

Comment on lines +244 to +247

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Blocking: Clear deletes platform-address derivation metadata that no later sync can rebuild

PersistentPlatformAddress is durable derivation state — bech32m address, 20-byte hash, 33-byte compressed public key, account/address index, derivation path (see PersistentPlatformAddress.swift:33-71) — not a balance cache. Deleting these rows on Clear creates an asymmetry that the rest of the pipeline cannot recover from in-session:

  1. The Rust-side reset (provider.rs:495-506) intentionally preserves the addresses bijection and only clears found/absent/watermark scalars — its own doc comment notes this is to avoid 'needless re-derivation'.
  2. sync_balances (sync.rs:110-165) emits only a PlatformAddressChangeSet { addresses: <balance diff>, ... }. The on_persist_account_address_pools_fn callback that creates PersistentPlatformAddress rows is fired only at wallet creation and gap-limit extension, not on every sync.
  3. persistAddressBalances (PlatformWalletPersistenceHandler.swift:88-114) keys by addressHash and guard let existing = ... else { continue } skips when the row is missing.

Net effect: after Clear, the next 'Sync Now' produces balance updates against now-missing rows that silently no-op in SwiftData. The Platform Sync UI stays empty (and the spend/signing path loses its derivation-path source) until app restart triggers re-derivation through the wallet load path. This contradicts the file's own contract: 'data stays cleared until the user explicitly resyncs'.

Fix by zeroing only the volatile balance/sync fields in place for walletIdsOnNetwork and keeping the durable derivation row, while still deleting the network-scoped PersistentPlatformAddressesSyncState row to force a full rescan. The full rescan + preserved rows together give Clear the semantics the doc comment promises.

Suggested change
let addresses = try modelContext.fetch(FetchDescriptor<PersistentPlatformAddress>())
for row in addresses where walletIdsOnNetwork.contains(row.walletId) {
modelContext.delete(row)
}
let addresses = try modelContext.fetch(FetchDescriptor<PersistentPlatformAddress>())
for row in addresses where walletIdsOnNetwork.contains(row.walletId) {
row.balance = 0
row.nonce = 0
row.isUsed = false
row.firstSeenHeight = 0
row.lastSeenHeight = 0
row.lastUpdated = Date()
}

source: ['claude', 'codex']


let networkRaw = network.rawValue
let syncStates = try modelContext.fetch(FetchDescriptor<PersistentPlatformAddressesSyncState>())
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()
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
Loading
Loading