Skip to content

fix: stop reporting "Sync 100%" while a wallet rescan is owed (#103) - #144

Merged
jvsena42 merged 1 commit into
mainfrom
fix/103-p2-pending-rescan-sync-state
Jul 23, 2026
Merged

fix: stop reporting "Sync 100%" while a wallet rescan is owed (#103)#144
jvsena42 merged 1 commit into
mainfrom
fix/103-p2-pending-rescan-sync-state

Conversation

@jvsena42

Copy link
Copy Markdown
Owner

Summary

Fixes P2 of #103, plus a worse sibling found while auditing it: the foreground-service notification and the Node screen disagreed about how synced we are.

P2. SettingsViewModel arms WALLET_NEEDS_RESCAN on descriptor load (:451) and birthday change (:313), but FlorestaService only fires the rescan 60s after filters reach the tip. The stepper's allDone consulted only rescan_in_progress, so for at least that minute — plus up to 10s of poll latency — the UI reported 100% with a rescan still pending. That is what put @TheButterZone in a position to reconnect their Electrum wallets at step 20 when it wasn't yet safe to.

The notification was worse. applySyncState knew only headers → blocks → stalled → synced. With no filters phase and no rescan phase at all, it read Synced - Block #N / "Fully synced" through the entire compact-filter sync — ~3 hours in the reporter's log (steps 11→13) — and through a running rescan, while the Node screen correctly said "Syncing filters" / "Scanning wallet".

The root cause of the drift: the phase logic existed in three hand-rolled copies (ScreenNode, ScreenNodeTablet, FlorestaService).

What changed

  • domain/floresta/SyncPhase.kt — one pure phase computation shared by all three surfaces. WALLET_SCAN now covers a rescan that is pending as well as one in flight. Precedence preserves today's phone-screen title order exactly. Also holds computeFilterSyncDecimal / computeRescanProgressDecimal, so the screen and the service stop computing them differently.
  • presentation/service/SyncNotificationContent.kt — maps a phase to notification text, free of Android types so it is unit-testable. synced = (phase == SYNCED) is the one line that makes "Fully synced" impossible during filters or a rescan.
  • domain/floresta/WalletRescanGate.kt — the service's grace-window state, plus a retry bound. Since the UI now stays unsynced while the flag is set, a rescan that can never succeed (e.g. NoBlockFilters) would otherwise pin it there forever. Ten attempts, backoff doubling from 60s to a 30m cap, then it clears the flag. Timing uses a monotonic TimeSource, not the wall clock — an NTP correction mid-session would otherwise make a long backoff fire instantly or never.
  • ScreenNodeTablet now takes a SyncPhase instead of five derived booleans, so it can't be handed an inconsistent set.

Note rescanblockchain flips rescan_in_progress synchronously before returning Ok, so clearing the flag right after a successful RPC leaves no gap where both pending and in-progress read false.

Tests

42 new tests (331 total). Beyond the per-phase cases, SyncNotificationParityTest walks all 256 combinations of ibd × progress × filters × rescan × pending × stalled and asserts the notification's synced flag equals the stepper's allDone — that is what fails today and what stops the two surfaces drifting again.

Reverting just the P2 condition (rescanInProgress || rescanPendingrescanInProgress) fails exactly four tests, one per surface, confirming they are load-bearing.

Verified on device

Mainnet emulator at height 959,254. With ibd=false, progress=1.0 and filters at 2.5%:

Surface Before After
Node screen Syncing filters 2.83% Syncing filters 2.83%
Notification Synced - Block #959,254 / Fully synced Syncing filters: 2.52%

Also confirmed WALLET_NEEDS_RESCAN arms on descriptor load (read back from DataStore), and that with it armed while filters are at 12.67% both surfaces still correctly show "Syncing filters" — a pending rescan must not preempt a phase that has to finish first.

Scope

P1, P3 and P4 of #103 remain open; P1 and P4 are filed upstream (getfloresta/Floresta#1225, #1226).

Checklist

  • Commits are atomic and follow conventional style (feat:, fix:, chore:, ...)
  • Unit tests added/updated for business-logic changes
  • Journeys and journeys/README.md testTags updated for UI changes — N/A, no testTags added or renamed
  • ./gradlew detekt, ./gradlew lintDebug, and ./gradlew test pass locally
  • I have self-reviewed my own diff

Preview

Text-only change to sync labels; the before/after notification text is in the table above.

Two ways the app claimed to be fully synced when it wasn't.

P2: `SettingsViewModel` arms `WALLET_NEEDS_RESCAN` on descriptor load and
on birthday change, but `FlorestaService` only fires the rescan 60s after
filters reach the tip. The sync stepper's `allDone` consulted only
`rescan_in_progress`, so for at least that minute — plus up to 10s of poll
latency — the UI reported 100% with a rescan still pending. That is what
put the reporter in a position to reconnect their Electrum wallets too
early and see partial history.

The foreground notification was worse: `applySyncState` knew only
headers -> blocks -> stalled -> synced. With no filters phase and no
rescan phase at all, it read "Fully synced" through the entire
compact-filter sync (~3 hours in the reporter's log) and through a
running rescan, while the Node screen correctly said otherwise.

The phase logic existed in three hand-rolled copies (`ScreenNode`,
`ScreenNodeTablet`, `FlorestaService`) which is why they drifted. Replace
them with one pure `SyncPhase` computation in `domain/floresta`, shared by
all three, where `WALLET_SCAN` now covers a rescan that is pending as well
as one in flight. `SyncNotificationContent` maps a phase to notification
text with `synced = (phase == SYNCED)`, making "Fully synced" impossible
outside that phase.

Extract the service's grace-window state into `WalletRescanGate`, which
also bounds retries: since the UI now stays unsynced while the flag is
set, a rescan that can never succeed (e.g. filters disabled) would
otherwise pin it there forever. Ten attempts with backoff doubling from
60s to a 30m cap, then it clears the flag and lets the UI report synced.
Timing runs off a monotonic `TimeSource` rather than the wall clock, so an
NTP correction mid-session cannot make a long backoff fire instantly or
never; a `TestTimeSource` asserts the whole schedule in virtual time.

Verified on a mainnet device at height 959,254: with `ibd=false` and
filters at 2.5%, screen and notification both report "Syncing filters"
where the notification previously said "Synced - Block #959,254".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@jvsena42
jvsena42 enabled auto-merge (rebase) July 23, 2026 13:07
@jvsena42
jvsena42 merged commit 23e2634 into main Jul 23, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant