Skip to content
Open
Show file tree
Hide file tree
Changes from all 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 Expand Up @@ -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
}
}
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,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()
Comment thread
coderabbitai[bot] marked this conversation as resolved.
// 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()
}
}
Loading
Loading