Skip to content

feat: background silent-payments scanning - #113

Draft
theanmolsharma wants to merge 9 commits into
masterfrom
background-scanning
Draft

feat: background silent-payments scanning#113
theanmolsharma wants to merge 9 commits into
masterfrom
background-scanning

Conversation

@theanmolsharma

Copy link
Copy Markdown
Collaborator

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 on txid: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):

  • Android — WorkManager periodic job (15 min, network-constrained) drives the headless task via HeadlessJsTaskContext directly and blocks until it completes, so the wakelock covers the whole scan. Runs even after swipe-away.
  • iOSBGTaskScheduler: BGAppRefreshTask (fetchTxsForWallet, ~25s) + a new BGProcessingTask (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)

  1. refactor: key-based RustTransactionProcessor + SP background types + staged merge
  2. feat: scan-only credential, staging, and lock stores + native bridge API
  3. feat: background scan task + headless entry
  4. chore: drop stale RNCPushNotificationIOS/RNQuickActionManager refs (also fixes a pre-existing broken iOS build)
  5. feat(android): WorkManager background scan scheduling
  6. feat(ios): BGTaskScheduler background scan scheduling
  7. feat: wire background scanning into app lifecycle + settings toggle

Verification

  • tsc clean, npm run lint clean
  • 19 new unit tests (merge idempotency/cursor guards; task budget/idempotency/bail paths). Full suite: 109 passed; the 1 loc.test.js failure is pre-existing on master (host-locale digit grouping).
  • Android Kotlin compiles; iOS simulator build succeeds.

On-device testing (not done — requires a device)

  • iOS: background the app, pause in Xcode, e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchTask:@"org.bitshala.shroud.fetchTxsForWallet"] (and scanCatchup, _simulateExpirationForTaskWithIdentifier:). Send a real SP payment, lock device → notification → reopen → balance already merged.
  • Android: swipe app away, adb shell dumpsys jobscheduler | grep -A3 org.bitshala.shroudadb shell cmd jobscheduler run -f org.bitshala.shroud <JOB_ID>.

Known limitations (OS policy)

  • iOS runs nothing after a user force-quit; cadence is opportunistic.
  • Aggressive Android OEMs may kill scheduled work; Doze stretches the period to maintenance windows.
  • Offline devices scan when connectivity returns. Background scan is best-effort; the foreground scan remains the source of truth.

🤖 Generated with Claude Code

theanmolsharma and others added 9 commits June 6, 2026 18:34
…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>
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