feat: background silent-payments scanning - #113
Draft
theanmolsharma wants to merge 9 commits into
Draft
Conversation
…staged merge Prepare the silent-payments scan pipeline for a background scanner that only has scan-only keys (not the seed): - RustTransactionProcessor takes (scanPrivkeyHex, spendPubkeyHex) directly, with fromSeed() preserving the existing seed-based path. - Export the SilentPaymentIndexer class so a background task can spin up its own instance instead of mutating the foreground singleton. - Add ScanCredentials / StagedScanData types. - HDSilentPaymentsWallet gains getScanPrivateKey/getBirthHeight/ getLastScannedBlock and mergeStagedScanResults() — idempotent merge of staged background finds (dedup on txid:vout, cursor never regresses, guarded against adopting a stale-credential cursor below birth height). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Building blocks for background scanning, not yet wired into the app: - BackgroundScanCredentials: scan-only keychain entry (scan privkey + spend pubkey + indexer URL + cursor + birth height), AFTER_FIRST_UNLOCK so it's readable while the device is locked. Can detect, never spend. - ScanStagingStore: keychain-backed staged results; UTXOs are written before the cursor advances so a killed task never skips blocks. - ScanLock: foreground-active flag the headless task reads to bail while the live app owns scanning (AppState is useless in a headless context). - BackgroundScanManager (JS): wrapper over the native module (start/stop/getStatus/finish/postNotification/permission + events). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
runBackgroundScan: budget-aware incremental scan from the scan-only credentials. Uses its own 50-block loop and a private indexer instance (short 8s timeout so retries can't burn the iOS budget), stages UTXOs + cursor per range, posts a "payment received" notification on new finds, and is single-flight so overlapping iOS refresh/processing tasks share one run. Stops at a failed range rather than skipping it (stricter than the foreground scanner) so no blocks are silently missed. BackgroundScanHeadless adapts the OS task payload to runBackgroundScan and always calls finish() (iOS BGTask completion). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A 15-minute periodic WorkManager job (network-constrained) drives the "BackgroundScan" headless JS task. BackgroundScanWorker boots the React context (cold-starting the process if needed) and blocks on HeadlessJsTaskContext until the JS task completes, so WorkManager's wakelock and process priority cover the whole scan — it runs even after the app is swiped away. BackgroundScanModule exposes start/stop/status and posts the local notification via NotificationCompat; adds the POST_NOTIFICATIONS permission for Android 13+. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Register a BGAppRefreshTask (org.bitshala.shroud.fetchTxsForWallet, ~25s) and a new BGProcessingTask (org.bitshala.shroud.scanCatchup, ~4min) and bridge them to the JS scan task. BackgroundScanManager emits a start event to JS with a listener-ready retry loop (cold background launches load the bundle asynchronously) and a watchdog so a BGTask is never left dangling; finish() maps to setTaskCompleted. AppDelegate registers the handlers at launch, reschedules on background, and guards the cache-cleared alert against background launches. Posts the local notification via UNUserNotificationCenter. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
BackgroundScanSetup centralizes the lifecycle: provision scan-only credentials on wallet create/import and backfill on unlock (self-healing the indexer URL and birth height), merge staged background finds before the foreground scan and on every return-to-foreground, refresh the cursor floor after each foreground scan, and tear everything down on wallet delete. StorageProvider stamps the foreground flag on AppState changes. index.js registers the headless task and the iOS event listeners at module scope so a cold background launch reaches JS. Settings gains a Background Scanning toggle (default on, requests notification permission on enable). Adds the keychain ACCESSIBLE mock so the existing test suite keeps passing. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…e scan UI bfac4ef ("DEL: remove unused class members") deleted isScanActive() from HDSilentPaymentsWallet after verifying no direct callers. But the isScannable() type guard checks for it structurally (typeof w.isScanActive === 'function'), so the guard has returned false for every wallet since: useScannableWallet() always null, the home-screen scan banner never rendered, SyncScreen was unreachable, and StorageProvider never registered the scan-state callback. The scan itself kept running — with no UI. Restore the method, declare `implements IScannableWallet` so removing any contract method is a compile error, and add a guard regression test. Note: master carries the same regression and needs this fix too. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
mergeStagedScanResults updated wallet.lastScannedBlock but never notified scan-state listeners, so the context scanState (home banner, SyncScreen) kept the pre-merge cursor. Worse, when the merge brings the wallet up to the chain tip, the next foreground scan early-returns before emitting anything — leaving the UI stale (or hidden, for a wallet that never completed a foreground scan) indefinitely. Re-emit the current status rather than forcing 'idle' so a paused scan isn't clobbered; _emitScanState always stamps the fresh lastScannedBlock. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The banner previously returned null for a never-scanned idle wallet. That hidden state is the only entry point to SyncScreen, and it made silent scan failures indistinguishable from "nothing to show" — the isScanActive regression went unnoticed for two weeks because of it. Now the banner renders whenever a scannable wallet exists: - never scanned + idle -> "Waiting to sync…" (static dot) - scanning / paused / error / synced -> unchanged SyncScreen correspondingly presents a never-scanned wallet as scanning instead of falsely claiming "You're all caught up / 100%"; the first real scan starts moments after mount and the state self-corrects. WalletsList's getItemLayout height accounting now matches the unconditional banner. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
theanmolsharma
force-pushed
the
background-scanning
branch
from
June 6, 2026 17:30
3a271fd to
f432b84
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Scan the silent-payments indexer for incoming payments while the app is closed or suspended, stage any found UTXOs, post a local notification, and merge results on next open so the app launches already caught-up. Previously, scanning only ran while the app was foregrounded (it even paused on background).
A shared headless JS task reuses the existing scan pipeline (
SilentPaymentIndexer+ Rust JSI) but runs from scan-only keychain credentials (scan privkey + spend pubkey,AFTER_FIRST_UNLOCK) — by BIP-352 design these can detect payments while the device is locked but can never spend. Results are staged in the keychain and merged into the wallet idempotently (dedup ontxid:vout, cursor never regresses, guarded against adopting a stale-credential cursor below birth height).Scheduling is hand-rolled per platform (no new third-party deps):
HeadlessJsTaskContextdirectly and blocks until it completes, so the wakelock covers the whole scan. Runs even after swipe-away.BGTaskScheduler:BGAppRefreshTask(fetchTxsForWallet, ~25s) + a newBGProcessingTask(scanCatchup, ~4min), bridged to JS via an event emitter with a listener-ready retry loop (cold background launches load the bundle async) and a watchdog so a task is never left dangling.Commits (reviewable in order)
refactor:key-basedRustTransactionProcessor+ SP background types + staged mergefeat:scan-only credential, staging, and lock stores + native bridge APIfeat:background scan task + headless entrychore:drop staleRNCPushNotificationIOS/RNQuickActionManagerrefs (also fixes a pre-existing broken iOS build)feat(android):WorkManager background scan schedulingfeat(ios):BGTaskScheduler background scan schedulingfeat:wire background scanning into app lifecycle + settings toggleVerification
tscclean,npm run lintcleanloc.test.jsfailure is pre-existing on master (host-locale digit grouping).On-device testing (not done — requires a device)
e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchTask:@"org.bitshala.shroud.fetchTxsForWallet"](andscanCatchup,_simulateExpirationForTaskWithIdentifier:). Send a real SP payment, lock device → notification → reopen → balance already merged.adb shell dumpsys jobscheduler | grep -A3 org.bitshala.shroud→adb shell cmd jobscheduler run -f org.bitshala.shroud <JOB_ID>.Known limitations (OS policy)
🤖 Generated with Claude Code