diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index bd44dd2ab..dbe148657 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -16,6 +16,8 @@
+
+
(PERIOD_MINUTES, TimeUnit.MINUTES)
+ .setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
+ .setInputData(workDataOf(BackgroundScanWorker.KEY_TIME_BUDGET_MS to BackgroundScanWorker.DEFAULT_TIME_BUDGET_MS))
+ .build()
+
+ WorkManager.getInstance(reactApplicationContext)
+ .enqueueUniquePeriodicWork(WORK_NAME, ExistingPeriodicWorkPolicy.UPDATE, request)
+
+ prefs.edit().putBoolean(PREF_ENABLED, true).apply()
+ promise.resolve(true)
+ } catch (e: Exception) {
+ promise.reject("bg_scan_start_failed", e)
+ }
+ }
+
+ @ReactMethod
+ fun stop(promise: Promise) {
+ try {
+ WorkManager.getInstance(reactApplicationContext).cancelUniqueWork(WORK_NAME)
+ prefs.edit().putBoolean(PREF_ENABLED, false).apply()
+ promise.resolve(true)
+ } catch (e: Exception) {
+ promise.reject("bg_scan_stop_failed", e)
+ }
+ }
+
+ @ReactMethod
+ fun getStatus(promise: Promise) {
+ try {
+ val lastRunAt = prefs.getLong(PREF_LAST_RUN_AT, 0L)
+ val result = com.facebook.react.bridge.Arguments.createMap().apply {
+ putBoolean("enabled", prefs.getBoolean(PREF_ENABLED, false))
+ if (lastRunAt > 0) putDouble("lastRunAt", lastRunAt.toDouble()) else putNull("lastRunAt")
+ putBoolean("available", true)
+ }
+ promise.resolve(result)
+ } catch (e: Exception) {
+ promise.reject("bg_scan_status_failed", e)
+ }
+ }
+
+ /**
+ * No-op on Android: the headless task's resolved promise notifies completion
+ * (AppRegistry → HeadlessJsTaskContext.finishTask). Kept for iOS API symmetry.
+ */
+ @ReactMethod
+ fun finish(taskId: String?, success: Boolean) = Unit
+
+ @ReactMethod
+ fun postNotification(title: String, body: String, promise: Promise) {
+ try {
+ val context = reactApplicationContext
+ if (
+ Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
+ ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
+ ) {
+ promise.resolve(null) // silently skip; permission is requested from the foreground flow
+ return
+ }
+
+ ensureChannel(context)
+
+ val notification = NotificationCompat.Builder(context, CHANNEL_ID)
+ .setSmallIcon(R.mipmap.ic_launcher)
+ .setContentTitle(title)
+ .setContentText(body)
+ .setPriority(NotificationCompat.PRIORITY_DEFAULT)
+ .setAutoCancel(true)
+ .setContentIntent(
+ android.app.PendingIntent.getActivity(
+ context,
+ 0,
+ android.content.Intent(context, MainActivity::class.java),
+ android.app.PendingIntent.FLAG_UPDATE_CURRENT or android.app.PendingIntent.FLAG_IMMUTABLE,
+ ),
+ )
+ .build()
+
+ NotificationManagerCompat.from(context).notify(System.currentTimeMillis().toInt(), notification)
+ promise.resolve(null)
+ } catch (e: Exception) {
+ promise.reject("bg_scan_notify_failed", e)
+ }
+ }
+
+ @ReactMethod
+ fun requestNotificationPermission(promise: Promise) {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
+ promise.resolve(true)
+ return
+ }
+
+ val context = reactApplicationContext
+ if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) {
+ promise.resolve(true)
+ return
+ }
+
+ val activity = currentActivity as? PermissionAwareActivity
+ if (activity == null) {
+ promise.resolve(false)
+ return
+ }
+
+ val listener = PermissionListener { requestCode, _, grantResults ->
+ if (requestCode == NOTIFICATION_PERMISSION_REQUEST_CODE) {
+ promise.resolve(grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED)
+ true
+ } else {
+ false
+ }
+ }
+
+ activity.requestPermissions(arrayOf(Manifest.permission.POST_NOTIFICATIONS), NOTIFICATION_PERMISSION_REQUEST_CODE, listener)
+ }
+
+ private fun ensureChannel(context: Context) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+ if (manager.getNotificationChannel(CHANNEL_ID) == null) {
+ manager.createNotificationChannel(
+ NotificationChannel(CHANNEL_ID, "Incoming payments", NotificationManager.IMPORTANCE_DEFAULT),
+ )
+ }
+ }
+ }
+}
diff --git a/android/app/src/main/java/org/bitshala/shroud/background/BackgroundScanPackage.kt b/android/app/src/main/java/org/bitshala/shroud/background/BackgroundScanPackage.kt
new file mode 100644
index 000000000..8fa59ec05
--- /dev/null
+++ b/android/app/src/main/java/org/bitshala/shroud/background/BackgroundScanPackage.kt
@@ -0,0 +1,13 @@
+package org.bitshala.shroud.background
+
+import com.facebook.react.ReactPackage
+import com.facebook.react.bridge.NativeModule
+import com.facebook.react.bridge.ReactApplicationContext
+import com.facebook.react.uimanager.ViewManager
+
+class BackgroundScanPackage : ReactPackage {
+ override fun createNativeModules(reactContext: ReactApplicationContext): List =
+ listOf(BackgroundScanModule(reactContext))
+
+ override fun createViewManagers(reactContext: ReactApplicationContext): List> = emptyList()
+}
diff --git a/android/app/src/main/java/org/bitshala/shroud/background/BackgroundScanWorker.kt b/android/app/src/main/java/org/bitshala/shroud/background/BackgroundScanWorker.kt
new file mode 100644
index 000000000..6acd89a41
--- /dev/null
+++ b/android/app/src/main/java/org/bitshala/shroud/background/BackgroundScanWorker.kt
@@ -0,0 +1,142 @@
+package org.bitshala.shroud.background
+
+import android.content.Context
+import android.util.Log
+import androidx.work.Worker
+import androidx.work.WorkerParameters
+import com.facebook.react.ReactApplication
+import com.facebook.react.ReactInstanceEventListener
+import com.facebook.react.bridge.Arguments
+import com.facebook.react.bridge.ReactContext
+import com.facebook.react.bridge.UiThreadUtil
+import com.facebook.react.common.LifecycleState
+import com.facebook.react.jstasks.HeadlessJsTaskConfig
+import com.facebook.react.jstasks.HeadlessJsTaskContext
+import com.facebook.react.jstasks.HeadlessJsTaskEventListener
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.TimeUnit
+
+/**
+ * Runs the "BackgroundScan" headless JS task and blocks until it completes, so
+ * WorkManager's wakelock and process priority cover the whole scan. Drives
+ * HeadlessJsTaskContext directly instead of going through a HeadlessJsTaskService:
+ * no startService() background restrictions, and completion is observable.
+ *
+ * Cold process: boots the ReactContext headlessly (same path HeadlessJsTaskService
+ * uses) before starting the task.
+ */
+class BackgroundScanWorker(appContext: Context, params: WorkerParameters) : Worker(appContext, params) {
+
+ companion object {
+ const val TAG = "BackgroundScanWorker"
+ const val KEY_TIME_BUDGET_MS = "timeBudgetMs"
+ const val DEFAULT_TIME_BUDGET_MS = 8L * 60 * 1000 // stay well inside the 10-min Worker window
+ private const val REACT_BOOT_TIMEOUT_MS = 30L * 1000
+ private const val TASK_COMPLETION_GRACE_MS = 30L * 1000
+ }
+
+ override fun doWork(): Result {
+ val timeBudgetMs = inputData.getLong(KEY_TIME_BUDGET_MS, DEFAULT_TIME_BUDGET_MS)
+
+ val reactContext = awaitReactContext() ?: run {
+ Log.w(TAG, "Could not obtain ReactContext, retrying later")
+ return Result.retry()
+ }
+
+ if (reactContext.lifecycleState == LifecycleState.RESUMED) {
+ // App is in the foreground — the live app owns scanning.
+ Log.i(TAG, "App in foreground, skipping background scan")
+ return Result.success()
+ }
+
+ return if (runHeadlessTask(reactContext, timeBudgetMs)) Result.success() else Result.retry()
+ }
+
+ private fun awaitReactContext(): ReactContext? {
+ val reactNativeHost = (applicationContext as ReactApplication).reactNativeHost
+ val reactInstanceManager = reactNativeHost.reactInstanceManager
+
+ reactInstanceManager.currentReactContext?.let { return it }
+
+ val latch = CountDownLatch(1)
+ var obtainedContext: ReactContext? = null
+
+ val listener = object : ReactInstanceEventListener {
+ override fun onReactContextInitialized(context: ReactContext) {
+ obtainedContext = context
+ reactInstanceManager.removeReactInstanceEventListener(this)
+ latch.countDown()
+ }
+ }
+
+ UiThreadUtil.runOnUiThread {
+ // Re-check on the UI thread: the context may have appeared in between.
+ val current = reactInstanceManager.currentReactContext
+ if (current != null) {
+ obtainedContext = current
+ latch.countDown()
+ } else {
+ reactInstanceManager.addReactInstanceEventListener(listener)
+ if (!reactInstanceManager.hasStartedCreatingInitialContext()) {
+ reactInstanceManager.createReactContextInBackground()
+ }
+ }
+ }
+
+ latch.await(REACT_BOOT_TIMEOUT_MS, TimeUnit.MILLISECONDS)
+ if (obtainedContext == null) reactInstanceManager.removeReactInstanceEventListener(listener)
+ return obtainedContext
+ }
+
+ private fun runHeadlessTask(reactContext: ReactContext, timeBudgetMs: Long): Boolean {
+ val taskContext = HeadlessJsTaskContext.getInstance(reactContext)
+ val latch = CountDownLatch(1)
+ var startedTaskId = -1
+
+ val listener = object : HeadlessJsTaskEventListener {
+ override fun onHeadlessJsTaskStart(taskId: Int) = Unit
+ override fun onHeadlessJsTaskFinish(taskId: Int) {
+ if (taskId == startedTaskId) latch.countDown()
+ }
+ }
+
+ taskContext.addTaskEventListener(listener)
+ try {
+ UiThreadUtil.runOnUiThread {
+ try {
+ val data = Arguments.createMap().apply {
+ putDouble(KEY_TIME_BUDGET_MS, timeBudgetMs.toDouble())
+ putString("reason", "android-worker")
+ }
+ startedTaskId = taskContext.startTask(
+ HeadlessJsTaskConfig(
+ "BackgroundScan",
+ data,
+ timeBudgetMs + TASK_COMPLETION_GRACE_MS,
+ false, // not allowed in foreground; pre-checked above but races are possible
+ ),
+ )
+ } catch (e: IllegalStateException) {
+ // App moved to foreground between our check and startTask.
+ Log.i(TAG, "Headless task rejected: ${e.message}")
+ latch.countDown()
+ }
+ }
+
+ val finished = latch.await(timeBudgetMs + TASK_COMPLETION_GRACE_MS, TimeUnit.MILLISECONDS)
+ if (!finished) Log.w(TAG, "Headless task did not finish within budget")
+ return finished
+ } finally {
+ taskContext.removeTaskEventListener(listener)
+ markLastRun()
+ }
+ }
+
+ private fun markLastRun() {
+ applicationContext
+ .getSharedPreferences("group.org.bitshala.shroud", Context.MODE_PRIVATE)
+ .edit()
+ .putLong("background_scan_last_run_at", System.currentTimeMillis())
+ .apply()
+ }
+}
diff --git a/class/wallets/hd-bip352-wallet.ts b/class/wallets/hd-bip352-wallet.ts
index 5a6fd0fa1..a10b1d1da 100644
--- a/class/wallets/hd-bip352-wallet.ts
+++ b/class/wallets/hd-bip352-wallet.ts
@@ -6,17 +6,20 @@ import { getDefaultIndexer } from '../../modules/SilentPaymentIndexer';
import ecc from '../../modules/noble_ecc';
import {
getSilentPaymentAddress,
+ getScanPrivateKey,
getSpendPrivateKey,
getSpendPublicKey,
RustTransactionProcessor,
createTransactionProcessor,
type IndexerTransaction,
+ type IScannableWallet,
type SilentPaymentUTXO,
type SilentPaymentUTXOSerializable,
type ScanProgressCallback,
type ScanStateInfo,
type ScanStatus,
IDLE_SCAN_STATE,
+ type StagedScanData,
} from '../../helpers/silent-payments';
import { BIP352_ACTIVATION_HEIGHT } from '../../modules/constants';
import { CreateTransactionResult, CreateTransactionTarget, CreateTransactionUtxo, Transaction, Utxo } from './types.ts';
@@ -30,7 +33,11 @@ const SCAN_PROGRESS_THROTTLE_MS = 500;
// Number of recent progress samples kept for the windowed ETA throughput estimate.
const SCAN_ETA_ROLLING_WINDOW = 10;
-export class HDSilentPaymentsWallet extends HDTaprootWallet {
+// `implements IScannableWallet` is load-bearing: the isScannable() runtime guard
+// checks for every interface method structurally, so removing one (e.g. in an
+// "unused members" sweep) silently hides the entire scan UI. The implements
+// clause turns that into a compile error.
+export class HDSilentPaymentsWallet extends HDTaprootWallet implements IScannableWallet {
static readonly type = 'HDSilentPaymentsWallet';
static readonly typeReadable = 'HD Silent Payments';
// @ts-ignore: override
@@ -102,6 +109,10 @@ export class HDSilentPaymentsWallet extends HDTaprootWallet {
}
}
+ isScanActive(): boolean {
+ return this.activeScanPromise !== null;
+ }
+
private _emitScanState(status: ScanStatus, overrides?: Partial): void {
this._scanState = { ...this._scanState, status, ...overrides, lastScannedBlock: this.lastScannedBlock };
this._onScanStateChangeCallback?.(this._scanState);
@@ -277,6 +288,71 @@ export class HDSilentPaymentsWallet extends HDTaprootWallet {
return getSpendPublicKey(seed);
}
+ getScanPrivateKey(): Uint8Array {
+ const seed = this.getSeed();
+ return getScanPrivateKey(seed);
+ }
+
+ getBirthHeight(): number {
+ return this._birthHeight;
+ }
+
+ getLastScannedBlock(): number {
+ return this.lastScannedBlock;
+ }
+
+ /**
+ * Merge UTXOs found by background scans into the wallet. Idempotent: UTXOs
+ * dedup on txid:vout and the cursor never regresses, so re-merging stale
+ * staging (e.g. after a race with a finishing background run) is harmless.
+ *
+ * @returns number of newly added UTXOs
+ */
+ mergeStagedScanResults(staged: StagedScanData | null): number {
+ if (!staged || staged.walletID !== this.getID()) {
+ return 0;
+ }
+
+ let addedCount = 0;
+ for (const serializable of staged.utxos) {
+ const { tweakHex, ...rest } = serializable;
+ const utxo: SilentPaymentUTXO = {
+ ...rest,
+ tweak: new Uint8Array(Buffer.from(tweakHex, 'hex')),
+ };
+ if (this.addUTXO(utxo)) {
+ addedCount++;
+ }
+ }
+
+ // Only adopt the staged cursor when it's past the wallet's effective birth.
+ // A background scan run with stale credentials (birth height not yet set)
+ // may have scanned from the BIP-352 activation height; adopting such a low
+ // cursor into a never-scanned wallet (lastScannedBlock = 0) would drag the
+ // next foreground scan years behind its real birth height.
+ const effectiveBirthHeight = Math.max(this._birthHeight, BIP352_ACTIVATION_HEIGHT);
+ const cursorAdvanced = staged.cursor > this.lastScannedBlock && staged.cursor >= effectiveBirthHeight;
+ if (cursorAdvanced) {
+ this.lastScannedBlock = staged.cursor;
+ // Surface the advanced cursor to scan-state listeners (home-screen banner,
+ // sync screen). Without this the UI keeps the pre-merge cursor, and when
+ // the merge brings the wallet up to the tip the next foreground scan
+ // early-returns without emitting either — leaving the banner hidden
+ // forever. Re-emit the current status rather than forcing 'idle' so a
+ // paused scan isn't clobbered.
+ this._emitScanState(this._scanState.status);
+ }
+
+ if (addedCount > 0) {
+ this.onBalanceChangeCallback?.();
+ }
+ if (addedCount > 0 || cursorAdvanced) {
+ this.onPersistCallback?.();
+ }
+
+ return addedCount;
+ }
+
private getSeed(): Buffer {
if (this.cachedSeed) return this.cachedSeed;
diff --git a/components/Context/StorageProvider.tsx b/components/Context/StorageProvider.tsx
index 36bec8cab..1028f7ddc 100644
--- a/components/Context/StorageProvider.tsx
+++ b/components/Context/StorageProvider.tsx
@@ -1,10 +1,18 @@
import React, { createContext, useCallback, useEffect, useMemo, useRef, useState } from 'react';
-import { InteractionManager, LayoutAnimation } from 'react-native';
+import { AppState, InteractionManager, LayoutAnimation } from 'react-native';
import A from '../../modules/analytics';
import { ShroudApp, TTXMetadata } from '../../class';
import { HDSilentPaymentsWallet } from '../../class/wallets/hd-bip352-wallet';
import type { TWallet } from '../../class/wallets/types';
import presentAlert from '../../components/Alert';
+import { updateScanCursor } from '../../helpers/silent-payments/BackgroundScanCredentials';
+import {
+ findScannableWallet,
+ mergeStagedResults,
+ syncBackgroundScanState,
+ teardownBackgroundScanState,
+} from '../../helpers/silent-payments/BackgroundScanSetup';
+import { markForegroundActive, markForegroundInactive } from '../../helpers/silent-payments/ScanLock';
import loc, { formatBalanceWithoutSuffix } from '../../loc';
import * as Electrum from '../../modules/Electrum';
import triggerHapticFeedback, { HapticFeedbackTypes } from '../../modules/hapticFeedback';
@@ -179,6 +187,32 @@ export const StorageProvider = ({ children }: { children: React.ReactNode }) =>
};
}, []);
+ // Stamp the foreground flag so headless background scans know to bail while
+ // the live app owns scanning (AppState is useless inside a headless context).
+ // On return to foreground, also merge anything a background scan staged while
+ // we were suspended — the cold-start merge won't re-run in that case.
+ useEffect(() => {
+ // Don't claim the foreground on a cold BACKGROUND launch (BGTask relaunching
+ // a killed app mounts this provider too) — only when actually active.
+ if (AppState.currentState === 'active') {
+ markForegroundActive();
+ }
+ const subscription = AppState.addEventListener('change', state => {
+ if (state === 'active') {
+ markForegroundActive();
+ mergeStagedResults(shroudApp.getWallets())
+ .then(merged => {
+ if (merged > 0) return saveToDisk();
+ })
+ .catch(error => console.warn('[StorageProvider] Foreground staged merge failed:', error));
+ } else {
+ markForegroundInactive();
+ }
+ });
+ return () => subscription.remove();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
const addWallet = useCallback(
(wallet: TWallet): boolean => {
if (shroudApp.wallets.length > 0) {
@@ -201,6 +235,12 @@ export const StorageProvider = ({ children }: { children: React.ReactNode }) =>
shroudApp.wallets.push(wallet);
setWallets([...shroudApp.getWallets()]);
+
+ // Provision scan-only credentials + OS scheduling for background scanning.
+ // Fire-and-forget; re-synced on every app start (which also self-heals the
+ // birth height that onboarding sets after this point).
+ syncBackgroundScanState([wallet]).catch(error => console.warn('[StorageProvider] Background scan provisioning failed:', error));
+
return true;
},
[forceWalletsUpdate, debouncedPersist],
@@ -214,6 +254,9 @@ export const StorageProvider = ({ children }: { children: React.ReactNode }) =>
if ('clearCache' in wallet && typeof wallet.clearCache === 'function') wallet.clearCache();
+ // Remove scan-only keychain credentials, staged results and OS scheduling.
+ teardownBackgroundScanState().catch(error => console.warn('[StorageProvider] Background scan teardown failed:', error));
+
shroudApp.deleteWallet(wallet);
setWallets([...shroudApp.getWallets()]);
setScanState(IDLE_SCAN_STATE);
@@ -294,8 +337,23 @@ export const StorageProvider = ({ children }: { children: React.ReactNode }) =>
}
});
- setWallets(currentWallets);
+ // Merge staged background-scan results and (re-)provision scan credentials
+ // BEFORE exposing wallets — the foreground scan kicked off by WalletsList
+ // must start from the merged cursor, not behind it.
+ (async () => {
+ try {
+ const merged = await syncBackgroundScanState(currentWallets);
+ if (merged > 0) {
+ await saveToDisk();
+ }
+ } catch (error) {
+ console.warn('[StorageProvider] Background scan sync failed:', error);
+ } finally {
+ setWallets(currentWallets);
+ }
+ })();
}
+ // eslint-disable-next-line react-hooks/exhaustive-deps
}, [walletsInitialized, forceWalletsUpdate, debouncedPersist]);
// Add a refresh lock to prevent concurrent refreshes
@@ -356,6 +414,13 @@ export const StorageProvider = ({ children }: { children: React.ReactNode }) =>
console.debug('[refreshAllWalletTransactions] Saving data to disk');
await saveToDisk();
+
+ // Refresh the background-scan cursor floor (once per scan, not per
+ // batch) so background runs don't re-scan foreground-covered ranges.
+ const scanWallet = findScannableWallet(shroudApp.getWallets());
+ if (scanWallet) {
+ await updateScanCursor(scanWallet.getID(), scanWallet.getLastScannedBlock());
+ }
})(),
timeoutPromise,
]);
diff --git a/components/ScanProgressBar.tsx b/components/ScanProgressBar.tsx
index 9c67160f1..8036d65e9 100644
--- a/components/ScanProgressBar.tsx
+++ b/components/ScanProgressBar.tsx
@@ -41,8 +41,11 @@ const ScanProgressBar: React.FC = ({ scanState, onResume }) => {
return () => pulseAnimRef.current?.stop();
}, [status, pulseAnim]);
+ // Never hide the banner: it's the only entry point to SyncScreen, and a
+ // hidden banner makes silent scan failures indistinguishable from "nothing
+ // to show". A never-scanned idle wallet renders as "waiting to sync".
const isDone = status === 'idle' && lastScannedBlock > 0;
- if (status === 'idle' && !isDone) return null;
+ const isPending = status === 'idle' && !isDone;
if (isDone) {
const blockText = loc.formatString(loc.sync.banner_synced, { blockHeight: lastScannedBlock.toLocaleString() });
@@ -94,7 +97,7 @@ const ScanProgressBar: React.FC = ({ scanState, onResume }) => {
}
const dotColor = status === 'error' ? colors.statusError : colors.brandPrimary;
- const bannerText = status === 'error' ? loc.sync.banner_error : loc.sync.banner_scanning;
+ const bannerText = status === 'error' ? loc.sync.banner_error : isPending ? loc.sync.banner_pending : loc.sync.banner_scanning;
return (
{
+ const credentials: ScanCredentials = {
+ walletID: wallet.getID(),
+ scanPrivkeyHex: Buffer.from(wallet.getScanPrivateKey()).toString('hex'),
+ spendPubkeyHex: Buffer.from(wallet.getSpendPublicKey()).toString('hex'),
+ silentPaymentAddress: wallet.getSilentPaymentAddress()!,
+ baseUrl,
+ cursor: wallet.getLastScannedBlock(),
+ birthHeight: wallet.getBirthHeight(),
+ schema: 1,
+ };
+
+ await Keychain.setGenericPassword(SERVICE, JSON.stringify(credentials), KEYCHAIN_OPTIONS);
+}
+
+export async function readScanCredentials(): Promise {
+ try {
+ const result = await Keychain.getGenericPassword({ service: SERVICE });
+ if (!result) return null;
+
+ const credentials = JSON.parse(result.password) as ScanCredentials;
+ if (credentials.schema !== 1 || !credentials.scanPrivkeyHex || !credentials.spendPubkeyHex) return null;
+
+ return credentials;
+ } catch (error) {
+ console.warn('[BackgroundScan] Failed to read scan credentials:', error);
+ return null;
+ }
+}
+
+/**
+ * Refresh the background cursor floor after a foreground scan so background runs
+ * don't re-scan ranges the app already covered. Called once per completed scan,
+ * never per-batch.
+ */
+export async function updateScanCursor(walletID: string, cursor: number): Promise {
+ const credentials = await readScanCredentials();
+ if (!credentials || credentials.walletID !== walletID || cursor <= credentials.cursor) return;
+
+ await Keychain.setGenericPassword(SERVICE, JSON.stringify({ ...credentials, cursor }), KEYCHAIN_OPTIONS);
+}
+
+export async function deleteScanCredentials(): Promise {
+ try {
+ await Keychain.resetGenericPassword({ service: SERVICE });
+ } catch (error) {
+ console.warn('[BackgroundScan] Failed to delete scan credentials:', error);
+ }
+}
diff --git a/helpers/silent-payments/BackgroundScanHeadless.ts b/helpers/silent-payments/BackgroundScanHeadless.ts
new file mode 100644
index 000000000..74fa8d0a6
--- /dev/null
+++ b/helpers/silent-payments/BackgroundScanHeadless.ts
@@ -0,0 +1,40 @@
+import { finishBackgroundScan } from '../../modules/BackgroundScanManager';
+import { runBackgroundScan } from './BackgroundScanTask';
+
+export interface BackgroundScanTaskData {
+ taskId?: string;
+ timeBudgetMs?: number;
+ reason?: 'ios-refresh' | 'ios-processing' | 'android-worker';
+}
+
+const DEFAULT_TIME_BUDGET_MS = 25000;
+
+/**
+ * Entry point for OS-driven background scans.
+ * Android: registered via AppRegistry.registerHeadlessTask('BackgroundScan', ...)
+ * and started by BackgroundScanService — the resolved promise notifies completion.
+ * iOS: invoked by the onBackgroundScanStart listener in index.js; finish() maps
+ * to BGTask.setTaskCompleted, so it MUST be called on every path.
+ */
+export default async function BackgroundScanHeadless(data: BackgroundScanTaskData): Promise {
+ let success = false;
+
+ try {
+ const result = await runBackgroundScan({
+ timeBudgetMs: data?.timeBudgetMs ?? DEFAULT_TIME_BUDGET_MS,
+ reason: data?.reason,
+ });
+ // Bailing because the live app owns scanning is a successful no-op, not a failure.
+ success = result.bailedReason === undefined || result.bailedReason === 'foreground-active';
+ } catch (error) {
+ console.warn('[BackgroundScan] Task failed:', error);
+ } finally {
+ if (data?.taskId) {
+ try {
+ finishBackgroundScan(data.taskId, success);
+ } catch (error) {
+ console.warn('[BackgroundScan] finish() failed:', error);
+ }
+ }
+ }
+}
diff --git a/helpers/silent-payments/BackgroundScanSetup.ts b/helpers/silent-payments/BackgroundScanSetup.ts
new file mode 100644
index 000000000..4f4a6a5fe
--- /dev/null
+++ b/helpers/silent-payments/BackgroundScanSetup.ts
@@ -0,0 +1,130 @@
+import AsyncStorage from '@react-native-async-storage/async-storage';
+
+import { HDSilentPaymentsWallet } from '../../class/wallets/hd-bip352-wallet';
+import type { TWallet } from '../../class/wallets/types';
+import { requestNotificationPermission, startBackgroundScanning, stopBackgroundScanning } from '../../modules/BackgroundScanManager';
+import { getDefaultIndexer } from '../../modules/SilentPaymentIndexer';
+import { deleteScanCredentials, provisionScanCredentials, readScanCredentials } from './BackgroundScanCredentials';
+import { clearStaging, readStaging } from './ScanStagingStore';
+
+const USER_PREF_KEY = 'background_scan_user_pref'; // 'on' | 'off'; absent = on (default)
+
+export async function isBackgroundScanningEnabledByUser(): Promise {
+ try {
+ return (await AsyncStorage.getItem(USER_PREF_KEY)) !== 'off';
+ } catch {
+ return true;
+ }
+}
+
+export async function setBackgroundScanningUserPref(enabled: boolean): Promise {
+ await AsyncStorage.setItem(USER_PREF_KEY, enabled ? 'on' : 'off');
+ try {
+ if (enabled) {
+ await requestNotificationPermission();
+ await startBackgroundScanning();
+ } else {
+ await stopBackgroundScanning();
+ }
+ } catch (error) {
+ console.warn('[BackgroundScan] Failed to toggle native scheduling:', error);
+ }
+}
+
+export function findScannableWallet(wallets: TWallet[]): HDSilentPaymentsWallet | undefined {
+ return wallets.find((w): w is HDSilentPaymentsWallet => w instanceof HDSilentPaymentsWallet);
+}
+
+/**
+ * Merge-only path, run when the app returns to foreground from suspension: a
+ * background scan may have staged finds while we were suspended, and the cold
+ * start merge in syncBackgroundScanState won't re-run. Idempotent and cheap
+ * when staging is empty.
+ *
+ * @returns number of staged UTXOs merged (caller should persist if > 0)
+ */
+export async function mergeStagedResults(wallets: TWallet[]): Promise {
+ const wallet = findScannableWallet(wallets);
+ if (!wallet) return 0;
+
+ const staged = await readStaging();
+ if (!staged) return 0;
+
+ const merged = wallet.mergeStagedScanResults(staged);
+ await clearStaging();
+ if (merged > 0) {
+ console.log(`[BackgroundScan] Merged ${merged} staged UTXO(s) on foreground`);
+ }
+ return merged;
+}
+
+/**
+ * Reconcile background-scan state with the loaded wallets. Called once per app
+ * start (after wallets are decrypted, BEFORE any foreground scan) and after
+ * wallet creation/import:
+ * - merges staged background finds into the wallet (idempotent),
+ * - (re-)provisions the scan-only credentials — backfills pre-existing wallets
+ * and self-heals the indexer baseUrl on rebuilds,
+ * - starts/stops the OS scheduling to match the user preference,
+ * - tears everything down when no scannable wallet exists.
+ *
+ * @returns number of staged UTXOs merged into the wallet (caller should persist if > 0)
+ */
+export async function syncBackgroundScanState(wallets: TWallet[]): Promise {
+ const wallet = findScannableWallet(wallets);
+
+ if (!wallet) {
+ await deleteScanCredentials();
+ await clearStaging();
+ try {
+ await stopBackgroundScanning();
+ } catch {} // native module unavailable (e.g. tests) — nothing scheduled anyway
+ return 0;
+ }
+
+ let merged = 0;
+ const staged = await readStaging();
+ if (staged) {
+ merged = wallet.mergeStagedScanResults(staged);
+ await clearStaging();
+ if (merged > 0) {
+ console.log(`[BackgroundScan] Merged ${merged} staged UTXO(s) from background scans`);
+ }
+ }
+
+ const hadCredentials = (await readScanCredentials()) !== null;
+ try {
+ // Same indexer the foreground scan uses (initialized in App.tsx from env).
+ const baseUrl = getDefaultIndexer().getBaseUrl();
+ await provisionScanCredentials(wallet, baseUrl);
+ } catch (error) {
+ console.warn('[BackgroundScan] Failed to provision scan credentials:', error);
+ return merged;
+ }
+
+ if (await isBackgroundScanningEnabledByUser()) {
+ try {
+ if (!hadCredentials) {
+ // First provisioning for this wallet: ask for notification permission
+ // while we're in the foreground, then enable scheduling.
+ await requestNotificationPermission();
+ }
+ await startBackgroundScanning();
+ } catch (error) {
+ console.warn('[BackgroundScan] Failed to start native scheduling:', error);
+ }
+ }
+
+ return merged;
+}
+
+/** Remove credentials, staged data and OS scheduling for a deleted wallet. */
+export async function teardownBackgroundScanState(): Promise {
+ await deleteScanCredentials();
+ await clearStaging();
+ try {
+ await stopBackgroundScanning();
+ } catch (error) {
+ console.warn('[BackgroundScan] Failed to stop native scheduling:', error);
+ }
+}
diff --git a/helpers/silent-payments/BackgroundScanTask.ts b/helpers/silent-payments/BackgroundScanTask.ts
new file mode 100644
index 000000000..21e6454b6
--- /dev/null
+++ b/helpers/silent-payments/BackgroundScanTask.ts
@@ -0,0 +1,177 @@
+import { Buffer } from 'buffer';
+
+import { BIP352_ACTIVATION_HEIGHT } from '../../modules/constants';
+import { postLocalNotification } from '../../modules/BackgroundScanManager';
+import { initializeRustJsiBridge } from '../../modules/RustJsiBridge';
+import { SilentPaymentIndexer } from '../../modules/SilentPaymentIndexer';
+import loc from '../../loc';
+import { readScanCredentials } from './BackgroundScanCredentials';
+import { RustTransactionProcessor } from './RustTransactionProcessor';
+import { appendStagedUtxos, readStaging } from './ScanStagingStore';
+import { isForegroundActive } from './ScanLock';
+import type { SilentPaymentUTXO, SilentPaymentUTXOSerializable } from './types';
+
+export interface BackgroundScanParams {
+ timeBudgetMs: number;
+ reason?: 'ios-refresh' | 'ios-processing' | 'android-worker';
+}
+
+export interface BackgroundScanResult {
+ blocksScanned: number;
+ newUtxos: number;
+ newCursor: number;
+ caughtUp: boolean;
+ bailedReason?: 'foreground-active' | 'no-credentials' | 'indexer-unreachable' | 'cancelled';
+}
+
+const RANGE_BATCH_SIZE = 50; // matches SilentPaymentIndexer.scanBlocks
+/**
+ * Short per-request timeout (vs the foreground 100s): fetchWithRetries retries
+ * 3x with no backoff, so a hung indexer must not eat the whole iOS budget.
+ */
+const BG_REQUEST_TIMEOUT_MS = 8000;
+/** Headroom reserved for the final staging write + native completion. */
+const SAFETY_MARGIN_MS = 2000;
+
+let cancelRequested = false;
+let activeRun: Promise | null = null;
+
+/**
+ * Cooperative cancellation, wired to the iOS BGTask expiration handler
+ * (onBackgroundScanCancel). Checked at range boundaries.
+ */
+export function requestBackgroundScanCancel(): void {
+ cancelRequested = true;
+}
+
+function toSerializable(utxo: SilentPaymentUTXO): SilentPaymentUTXOSerializable {
+ const { tweak, ...rest } = utxo;
+ return { ...rest, tweakHex: Buffer.from(tweak).toString('hex') };
+}
+
+async function doRunBackgroundScan(params: BackgroundScanParams): Promise {
+ const startedAt = Date.now();
+ const deadline = startedAt + params.timeBudgetMs - SAFETY_MARGIN_MS;
+ cancelRequested = false;
+
+ const bail = (reason: BackgroundScanResult['bailedReason']): BackgroundScanResult => ({
+ blocksScanned: 0,
+ newUtxos: 0,
+ newCursor: 0,
+ caughtUp: false,
+ bailedReason: reason,
+ });
+
+ if (await isForegroundActive()) {
+ console.log('[BackgroundScan] Foreground app active, bailing');
+ return bail('foreground-active');
+ }
+
+ const credentials = await readScanCredentials();
+ if (!credentials) {
+ console.log('[BackgroundScan] No scan credentials provisioned, bailing');
+ return bail('no-credentials');
+ }
+
+ if (!initializeRustJsiBridge()) {
+ console.warn('[BackgroundScan] Rust JSI bridge unavailable, bailing');
+ return bail('no-credentials');
+ }
+
+ // Private indexer instance: must not clobber the foreground singleton's
+ // config when running in a still-warm app runtime.
+ const indexer = new SilentPaymentIndexer({ baseUrl: credentials.baseUrl, timeout: BG_REQUEST_TIMEOUT_MS });
+ const processor = new RustTransactionProcessor(credentials.scanPrivkeyHex, credentials.spendPubkeyHex);
+
+ let tipHeight: number;
+ try {
+ tipHeight = (await indexer.getLatestBlockHeight()).height;
+ } catch (error) {
+ console.warn('[BackgroundScan] Indexer unreachable:', error);
+ return bail('indexer-unreachable');
+ }
+
+ const staging = await readStaging();
+ const stagedCursor = staging && staging.walletID === credentials.walletID ? staging.cursor : 0;
+ const cursor = Math.max(credentials.cursor, stagedCursor);
+ const effectiveBirthHeight = Math.max(credentials.birthHeight, BIP352_ACTIVATION_HEIGHT);
+ // Mirrors performScan: resume after the cursor, or start at birth for a never-scanned wallet.
+ const startHeight = cursor > 0 ? cursor + 1 : effectiveBirthHeight;
+
+ let blocksScanned = 0;
+ let newUtxos = 0;
+ let newCursor = cursor;
+
+ for (let rangeStart = startHeight; rangeStart <= tipHeight; rangeStart += RANGE_BATCH_SIZE) {
+ if (cancelRequested) {
+ return { blocksScanned, newUtxos, newCursor, caughtUp: false, bailedReason: 'cancelled' };
+ }
+ if (Date.now() >= deadline) {
+ break;
+ }
+
+ const rangeEnd = Math.min(rangeStart + RANGE_BATCH_SIZE - 1, tipHeight);
+
+ try {
+ const response = await indexer.getTransactionsByRange(rangeStart, rangeEnd);
+ const valid = response.transactions.filter(tx => tx.scanTweak && tx.outputs && tx.outputs.length > 0);
+ const matched = valid.length > 0 ? await processor.processBatch(valid, credentials.silentPaymentAddress) : [];
+
+ // Stage UTXOs and cursor in one write — the cursor never points past
+ // blocks whose finds weren't saved, so a kill mid-run is always safe.
+ await appendStagedUtxos(credentials.walletID, matched.map(toSerializable), rangeEnd);
+
+ newUtxos += matched.length;
+ newCursor = rangeEnd;
+ blocksScanned += rangeEnd - rangeStart + 1;
+ } catch (error) {
+ // Unlike the foreground scan we must NOT skip a failed range: advancing
+ // the cursor past it would permanently miss any payments inside.
+ console.warn(`[BackgroundScan] Range ${rangeStart}-${rangeEnd} failed, stopping:`, error);
+ break;
+ }
+ }
+
+ if (newUtxos > 0) {
+ try {
+ await postLocalNotification(loc.notifications.received_title, loc.notifications.received_body);
+ } catch (error) {
+ console.warn('[BackgroundScan] Failed to post notification:', error);
+ }
+ }
+
+ const result: BackgroundScanResult = {
+ blocksScanned,
+ newUtxos,
+ newCursor,
+ caughtUp: newCursor >= tipHeight,
+ };
+ console.log(
+ `[BackgroundScan] Done in ${Date.now() - startedAt}ms: ${blocksScanned} blocks, ` +
+ `${newUtxos} new UTXOs, cursor ${newCursor}/${tipHeight} (${params.reason ?? 'unknown'})`,
+ );
+ return result;
+}
+
+/**
+ * Headless background scan: detect incoming silent payments using only the
+ * scan-only keychain credentials, and stage results for the main app to merge
+ * on next open. Never touches the main wallet storage (unreadable while the
+ * device is locked) and never holds spending keys.
+ *
+ * Single-flight: concurrent invocations (e.g. iOS refresh + processing tasks
+ * firing together) share one run — interleaved loops would race the staging
+ * read-modify-write.
+ */
+export async function runBackgroundScan(params: BackgroundScanParams): Promise {
+ if (activeRun) {
+ return activeRun;
+ }
+
+ activeRun = doRunBackgroundScan(params);
+ try {
+ return await activeRun;
+ } finally {
+ activeRun = null;
+ }
+}
diff --git a/helpers/silent-payments/RustTransactionProcessor.ts b/helpers/silent-payments/RustTransactionProcessor.ts
index 001b9b0c4..248b5d823 100644
--- a/helpers/silent-payments/RustTransactionProcessor.ts
+++ b/helpers/silent-payments/RustTransactionProcessor.ts
@@ -9,12 +9,21 @@ export class RustTransactionProcessor {
private scanPrivkeyHex: string;
private spendPubkeyHex: string;
- constructor(seed: Buffer) {
+ /**
+ * Scanning only needs the scan private key + spend public key (BIP-352) — never the seed.
+ * Use `fromSeed` when the seed is available, or pass the keys directly (e.g. background
+ * scanning, where only the scan-only credentials are accessible).
+ */
+ constructor(scanPrivkeyHex: string, spendPubkeyHex: string) {
+ this.scanPrivkeyHex = scanPrivkeyHex;
+ this.spendPubkeyHex = spendPubkeyHex;
+ }
+
+ static fromSeed(seed: Buffer): RustTransactionProcessor {
const scanPrivkey = getScanPrivateKey(seed);
const spendPubkey = getSpendPublicKey(seed);
- this.scanPrivkeyHex = Buffer.from(scanPrivkey).toString('hex');
- this.spendPubkeyHex = Buffer.from(spendPubkey).toString('hex');
+ return new RustTransactionProcessor(Buffer.from(scanPrivkey).toString('hex'), Buffer.from(spendPubkey).toString('hex'));
}
private convertToSilentPaymentUTXO(rustUtxo: RustMatchedUTXO, silentPaymentAddress: string): SilentPaymentUTXO {
@@ -74,5 +83,5 @@ export class RustTransactionProcessor {
}
export function createTransactionProcessor(seed: Buffer): RustTransactionProcessor {
- return new RustTransactionProcessor(seed);
+ return RustTransactionProcessor.fromSeed(seed);
}
diff --git a/helpers/silent-payments/ScanLock.ts b/helpers/silent-payments/ScanLock.ts
new file mode 100644
index 000000000..70b612425
--- /dev/null
+++ b/helpers/silent-payments/ScanLock.ts
@@ -0,0 +1,53 @@
+import AsyncStorage from '@react-native-async-storage/async-storage';
+
+/**
+ * Coordination flag between the live app and the headless background scan task.
+ *
+ * In a headless JS context AppState.currentState is always 'background', so the
+ * background task cannot tell "app suspended with a live scan" from "no app at
+ * all". Instead the live app stamps this flag on every foreground/background
+ * transition, and the background task bails when the foreground claim is fresh.
+ * The timestamp guards against a stale claim left behind by a crashed app.
+ */
+const KEY = 'background_scan_foreground_flag';
+
+/**
+ * A foreground claim older than this is ignored (crashed app / missed transition).
+ * Kept short: the OS already refuses to run background tasks while the app is
+ * genuinely foregrounded, so this flag only guards edge timing around transitions.
+ */
+const FOREGROUND_STALENESS_MS = 5 * 60 * 1000;
+
+interface ForegroundFlag {
+ active: boolean;
+ ts: number;
+}
+
+async function setFlag(active: boolean): Promise {
+ try {
+ await AsyncStorage.setItem(KEY, JSON.stringify({ active, ts: Date.now() } satisfies ForegroundFlag));
+ } catch (error) {
+ console.warn('[BackgroundScan] Failed to write foreground flag:', error);
+ }
+}
+
+export async function markForegroundActive(): Promise {
+ await setFlag(true);
+}
+
+export async function markForegroundInactive(): Promise {
+ await setFlag(false);
+}
+
+export async function isForegroundActive(): Promise {
+ try {
+ const raw = await AsyncStorage.getItem(KEY);
+ if (!raw) return false;
+
+ const flag = JSON.parse(raw) as ForegroundFlag;
+ return flag.active && Date.now() - flag.ts < FOREGROUND_STALENESS_MS;
+ } catch (error) {
+ console.warn('[BackgroundScan] Failed to read foreground flag:', error);
+ return false;
+ }
+}
diff --git a/helpers/silent-payments/ScanStagingStore.ts b/helpers/silent-payments/ScanStagingStore.ts
new file mode 100644
index 000000000..c2ebc71a3
--- /dev/null
+++ b/helpers/silent-payments/ScanStagingStore.ts
@@ -0,0 +1,67 @@
+import Keychain, { type SetOptions } from 'react-native-keychain';
+
+import type { SilentPaymentUTXOSerializable, StagedScanData } from './types';
+
+/**
+ * Background scan results are staged here until the main app merges them
+ * (see HDSilentPaymentsWallet.mergeStagedScanResults). Stored in the keychain
+ * (device-bound, OS-encrypted, AFTER_FIRST_UNLOCK) rather than a plain file so
+ * UTXO/balance metadata never sits unprotected on disk, and so background runs
+ * on a locked device can write it. The payload is small: a cursor plus the few
+ * UTXOs found since the app was last opened.
+ */
+const SERVICE = 'org.bitshala.shroud.background.staging';
+
+const KEYCHAIN_OPTIONS: SetOptions = {
+ service: SERVICE,
+ accessible: Keychain.ACCESSIBLE.AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY,
+};
+
+export async function readStaging(): Promise {
+ try {
+ const result = await Keychain.getGenericPassword({ service: SERVICE });
+ if (!result) return null;
+
+ const staged = JSON.parse(result.password) as StagedScanData;
+ if (staged.schema !== 1 || typeof staged.cursor !== 'number') return null;
+
+ return staged;
+ } catch (error) {
+ console.warn('[BackgroundScan] Failed to read staging:', error);
+ return null;
+ }
+}
+
+export async function writeStaging(staged: StagedScanData): Promise {
+ await Keychain.setGenericPassword(SERVICE, JSON.stringify(staged), KEYCHAIN_OPTIONS);
+}
+
+/**
+ * Read-modify-write append. UTXOs are deduped on txid:vout, and the cursor only
+ * ever advances — the write happens atomically at the keychain-value level, so a
+ * task killed mid-run leaves either the old or the new state, never a cursor
+ * pointing past unsaved UTXOs.
+ */
+export async function appendStagedUtxos(walletID: string, utxos: SilentPaymentUTXOSerializable[], newCursor: number): Promise {
+ const existing = await readStaging();
+ const base: StagedScanData =
+ existing && existing.walletID === walletID ? existing : { walletID, cursor: 0, utxos: [], updatedAt: 0, schema: 1 };
+
+ const seen = new Set(base.utxos.map(u => `${u.txid}:${u.vout}`));
+ const fresh = utxos.filter(u => !seen.has(`${u.txid}:${u.vout}`));
+
+ await writeStaging({
+ ...base,
+ cursor: Math.max(base.cursor, newCursor),
+ utxos: [...base.utxos, ...fresh],
+ updatedAt: Date.now(),
+ });
+}
+
+export async function clearStaging(): Promise {
+ try {
+ await Keychain.resetGenericPassword({ service: SERVICE });
+ } catch (error) {
+ console.warn('[BackgroundScan] Failed to clear staging:', error);
+ }
+}
diff --git a/helpers/silent-payments/index.ts b/helpers/silent-payments/index.ts
index 139df35c0..a70efd2f7 100644
--- a/helpers/silent-payments/index.ts
+++ b/helpers/silent-payments/index.ts
@@ -14,6 +14,14 @@ export type {
ScanStatus,
ScanStateInfo,
IScannableWallet,
+ ScanCredentials,
+ StagedScanData,
} from './types';
export { IDLE_SCAN_STATE, isScannable } from './types';
+
export { RustTransactionProcessor, createTransactionProcessor } from './RustTransactionProcessor';
+// NOTE: the background-scan runtime modules (BackgroundScanTask, ScanStagingStore,
+// BackgroundScanCredentials, ScanLock) are deliberately NOT re-exported here — this
+// barrel is imported by wallet classes that must stay loadable outside React Native
+// (cli/tests), and those modules pull in NativeModules/AsyncStorage/loc. Import them
+// from their files directly.
diff --git a/helpers/silent-payments/types.ts b/helpers/silent-payments/types.ts
index 18eae1527..453184d7a 100644
--- a/helpers/silent-payments/types.ts
+++ b/helpers/silent-payments/types.ts
@@ -121,3 +121,34 @@ export interface TransactionByTxidResponse {
}
export type ScanProgressCallback = (progress: ScanProgress) => void | Promise;
+
+/**
+ * Scan-only credentials for background scanning. Stored in the keychain with
+ * AFTER_FIRST_UNLOCK accessibility so a background task can detect incoming
+ * payments while the device is locked. These keys can detect but NEVER spend.
+ */
+export interface ScanCredentials {
+ walletID: string;
+ scanPrivkeyHex: string;
+ spendPubkeyHex: string;
+ silentPaymentAddress: string;
+ baseUrl: string;
+ /** Floor for the background cursor, refreshed after foreground scans. */
+ cursor: number;
+ birthHeight: number;
+ schema: 1;
+}
+
+/**
+ * Results of background scans, staged until the main app merges them into the
+ * wallet on next launch/foreground (main wallet storage may be unreadable
+ * while the device is locked or password-encrypted).
+ */
+export interface StagedScanData {
+ walletID: string;
+ /** Highest block height fully staged. Only advanced AFTER utxos are written (kill-safe). */
+ cursor: number;
+ utxos: SilentPaymentUTXOSerializable[];
+ updatedAt: number;
+ schema: 1;
+}
diff --git a/index.js b/index.js
index 5fabd7290..2dfb2188e 100644
--- a/index.js
+++ b/index.js
@@ -8,6 +8,9 @@ import { AppRegistry, LogBox } from 'react-native';
import App from './App';
import A from './modules/analytics';
import { restoreSavedPreferredFiatCurrencyAndExchangeFromStorage } from './modules/currency';
+import BackgroundScanHeadless from './helpers/silent-payments/BackgroundScanHeadless';
+import { requestBackgroundScanCancel } from './helpers/silent-payments/BackgroundScanTask';
+import { getBackgroundScanEventEmitter } from './modules/BackgroundScanManager';
if (!Error.captureStackTrace) {
// captureStackTrace is only available when debugging
@@ -31,3 +34,15 @@ const ShroudAppComponent = () => {
};
AppRegistry.registerComponent('Shroud', () => ShroudAppComponent);
+
+// Android: WorkManager → BackgroundScanService starts this headless task.
+AppRegistry.registerHeadlessTask('BackgroundScan', () => BackgroundScanHeadless);
+
+// iOS: BGTaskScheduler tasks arrive as events from the BackgroundScanManager
+// native module. Listeners attach at module scope (not in a component) so a
+// cold background launch reaches JS as soon as the bundle loads.
+const backgroundScanEmitter = getBackgroundScanEventEmitter();
+if (backgroundScanEmitter) {
+ backgroundScanEmitter.addListener('onBackgroundScanStart', event => BackgroundScanHeadless(event));
+ backgroundScanEmitter.addListener('onBackgroundScanCancel', () => requestBackgroundScanCancel());
+}
diff --git a/ios/BackgroundScan/BackgroundScanManager.m b/ios/BackgroundScan/BackgroundScanManager.m
new file mode 100644
index 000000000..5787ba3fa
--- /dev/null
+++ b/ios/BackgroundScan/BackgroundScanManager.m
@@ -0,0 +1,13 @@
+#import
+#import
+
+@interface RCT_EXTERN_MODULE(BackgroundScanManager, RCTEventEmitter)
+
+RCT_EXTERN_METHOD(start:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject)
+RCT_EXTERN_METHOD(stop:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject)
+RCT_EXTERN_METHOD(getStatus:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject)
+RCT_EXTERN_METHOD(finish:(NSString *)taskId success:(BOOL)success)
+RCT_EXTERN_METHOD(postNotification:(NSString *)title body:(NSString *)body resolver:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject)
+RCT_EXTERN_METHOD(requestNotificationPermission:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject)
+
+@end
diff --git a/ios/BackgroundScan/BackgroundScanManager.swift b/ios/BackgroundScan/BackgroundScanManager.swift
new file mode 100644
index 000000000..3ba1b1419
--- /dev/null
+++ b/ios/BackgroundScan/BackgroundScanManager.swift
@@ -0,0 +1,247 @@
+import BackgroundTasks
+import Foundation
+import React
+import UserNotifications
+
+/// Bridges BGTaskScheduler to the JS background scan task.
+///
+/// Flow: a BGTask fires → `onBackgroundScanStart {taskId, timeBudgetMs, reason}`
+/// is emitted to JS (retrying until the bundle has loaded and a listener is
+/// attached — cold background launches load JS asynchronously) → JS runs the
+/// scan and calls `finish(taskId, success)` → `setTaskCompleted`. A watchdog
+/// force-completes the task if JS never answers, since iOS terminates apps
+/// that leave BGTasks dangling.
+///
+/// IMPORTANT: AppDelegate creates a pre-bridge instance for registerBGTasks(),
+/// and React Native later instantiates its own bridge-attached one (which
+/// overwrites `instance` in init, same as MenuElementsEmitter). All task state
+/// is therefore static, and events always route through the CURRENT instance —
+/// never a captured self.
+@objc(BackgroundScanManager)
+class BackgroundScanManager: RCTEventEmitter {
+
+ static let appRefreshTaskId = "org.bitshala.shroud.fetchTxsForWallet"
+ static let processingTaskId = "org.bitshala.shroud.scanCatchup"
+
+ private static let enabledKey = "background_scan_enabled"
+ private static let lastRunAtKey = "background_scan_last_run_at"
+
+ private static let refreshBudgetMs = 25_000
+ private static let processingBudgetMs = 4 * 60_000
+ private static let listenerRetryIntervalMs = 250
+ private static let listenerRetryTimeoutMs = 15_000
+
+ private static var instance: BackgroundScanManager?
+ /// Main-queue-confined. Keyed by our UUID taskId.
+ private static var pendingTasks: [String: BGTask] = [:]
+
+ private var hasListeners = false
+
+ override init() {
+ super.init()
+ BackgroundScanManager.instance = self
+ }
+
+ @objc
+ class func sharedInstance() -> BackgroundScanManager {
+ if instance == nil {
+ instance = BackgroundScanManager()
+ }
+ return instance!
+ }
+
+ override func supportedEvents() -> [String]! {
+ return ["onBackgroundScanStart", "onBackgroundScanCancel"]
+ }
+
+ override class func requiresMainQueueSetup() -> Bool {
+ return true
+ }
+
+ override func startObserving() {
+ hasListeners = true
+ }
+
+ override func stopObserving() {
+ hasListeners = false
+ }
+
+ private static var isEnabled: Bool {
+ return UserDefaults.standard.bool(forKey: enabledKey)
+ }
+
+ /// Emit through whichever instance currently owns the bridge.
+ private static func emit(_ name: String, body: [String: Any]) -> Bool {
+ guard let current = instance, current.hasListeners else { return false }
+ current.sendEvent(withName: name, body: body)
+ return true
+ }
+
+ // MARK: - BGTask registration & scheduling
+
+ /// Must be called from didFinishLaunchingWithOptions, before launching finishes.
+ @objc
+ class func registerBGTasks() {
+ BGTaskScheduler.shared.register(forTaskWithIdentifier: appRefreshTaskId, using: nil) { task in
+ handle(task, timeBudgetMs: refreshBudgetMs, reason: "ios-refresh")
+ }
+ BGTaskScheduler.shared.register(forTaskWithIdentifier: processingTaskId, using: nil) { task in
+ handle(task, timeBudgetMs: processingBudgetMs, reason: "ios-processing")
+ }
+ }
+
+ @objc
+ class func scheduleBGTasks() {
+ guard isEnabled else { return }
+
+ let refreshRequest = BGAppRefreshTaskRequest(identifier: appRefreshTaskId)
+ refreshRequest.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60)
+ do {
+ try BGTaskScheduler.shared.submit(refreshRequest)
+ } catch {
+ NSLog("[BackgroundScan] Failed to submit refresh request: \(error)")
+ }
+
+ let processingRequest = BGProcessingTaskRequest(identifier: processingTaskId)
+ processingRequest.requiresNetworkConnectivity = true
+ processingRequest.requiresExternalPower = false
+ processingRequest.earliestBeginDate = Date(timeIntervalSinceNow: 30 * 60)
+ do {
+ try BGTaskScheduler.shared.submit(processingRequest)
+ } catch {
+ NSLog("[BackgroundScan] Failed to submit processing request: \(error)")
+ }
+ }
+
+ private static func handle(_ task: BGTask, timeBudgetMs: Int, reason: String) {
+ guard isEnabled else {
+ task.setTaskCompleted(success: true)
+ return
+ }
+
+ // Apple recommends rescheduling the next occurrence as soon as a task runs.
+ scheduleBGTasks()
+
+ let taskId = UUID().uuidString
+
+ DispatchQueue.main.async {
+ pendingTasks[taskId] = task
+ }
+
+ task.expirationHandler = {
+ NSLog("[BackgroundScan] Task \(taskId) expired")
+ _ = emit("onBackgroundScanCancel", body: ["taskId": taskId])
+ complete(taskId: taskId, success: false)
+ }
+
+ // Watchdog: never leave a BGTask dangling if JS fails to answer.
+ DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(timeBudgetMs + 5_000)) {
+ if pendingTasks[taskId] != nil {
+ NSLog("[BackgroundScan] Watchdog firing for task \(taskId)")
+ complete(taskId: taskId, success: false)
+ }
+ }
+
+ emitStartWhenListenerReady(taskId: taskId, timeBudgetMs: timeBudgetMs, reason: reason, elapsedMs: 0)
+ }
+
+ /// RCTEventEmitter silently drops events with no listeners. On a cold
+ /// background launch the JS bundle loads asynchronously, so retry until the
+ /// index.js module-scope listener attaches (or give up and complete).
+ private static func emitStartWhenListenerReady(taskId: String, timeBudgetMs: Int, reason: String, elapsedMs: Int) {
+ DispatchQueue.main.async {
+ guard pendingTasks[taskId] != nil else { return } // already expired/completed
+
+ let emitted = emit("onBackgroundScanStart", body: [
+ "taskId": taskId,
+ "timeBudgetMs": timeBudgetMs - elapsedMs,
+ "reason": reason,
+ ])
+ if emitted { return }
+
+ if elapsedMs >= listenerRetryTimeoutMs {
+ NSLog("[BackgroundScan] No JS listener after \(elapsedMs)ms, giving up on task \(taskId)")
+ complete(taskId: taskId, success: false)
+ return
+ }
+
+ DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(listenerRetryIntervalMs)) {
+ emitStartWhenListenerReady(
+ taskId: taskId,
+ timeBudgetMs: timeBudgetMs,
+ reason: reason,
+ elapsedMs: elapsedMs + listenerRetryIntervalMs
+ )
+ }
+ }
+ }
+
+ private static func complete(taskId: String, success: Bool) {
+ DispatchQueue.main.async {
+ guard let task = pendingTasks.removeValue(forKey: taskId) else { return }
+ UserDefaults.standard.set(Date().timeIntervalSince1970 * 1000, forKey: lastRunAtKey)
+ task.setTaskCompleted(success: success)
+ }
+ }
+
+ // MARK: - JS-facing API
+
+ @objc(start:rejecter:)
+ func start(_ resolve: @escaping RCTPromiseResolveBlock, rejecter reject: @escaping RCTPromiseRejectBlock) {
+ UserDefaults.standard.set(true, forKey: BackgroundScanManager.enabledKey)
+ BackgroundScanManager.scheduleBGTasks()
+ resolve(true)
+ }
+
+ @objc(stop:rejecter:)
+ func stop(_ resolve: @escaping RCTPromiseResolveBlock, rejecter reject: @escaping RCTPromiseRejectBlock) {
+ UserDefaults.standard.set(false, forKey: BackgroundScanManager.enabledKey)
+ BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: BackgroundScanManager.appRefreshTaskId)
+ BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: BackgroundScanManager.processingTaskId)
+ resolve(true)
+ }
+
+ @objc(getStatus:rejecter:)
+ func getStatus(_ resolve: @escaping RCTPromiseResolveBlock, rejecter reject: @escaping RCTPromiseRejectBlock) {
+ let lastRunAt = UserDefaults.standard.double(forKey: BackgroundScanManager.lastRunAtKey)
+ resolve([
+ "enabled": BackgroundScanManager.isEnabled,
+ "lastRunAt": lastRunAt > 0 ? lastRunAt : NSNull(),
+ "available": true,
+ ] as [String: Any])
+ }
+
+ @objc(finish:success:)
+ func finish(_ taskId: String, success: Bool) {
+ BackgroundScanManager.complete(taskId: taskId, success: success)
+ }
+
+ @objc(postNotification:body:resolver:rejecter:)
+ func postNotification(
+ _ title: String,
+ body: String,
+ resolver resolve: @escaping RCTPromiseResolveBlock,
+ rejecter reject: @escaping RCTPromiseRejectBlock
+ ) {
+ let content = UNMutableNotificationContent()
+ content.title = title
+ content.body = body
+ content.sound = .default
+
+ let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)
+ UNUserNotificationCenter.current().add(request) { error in
+ if let error = error {
+ reject("bg_scan_notify_failed", error.localizedDescription, error)
+ } else {
+ resolve(nil)
+ }
+ }
+ }
+
+ @objc(requestNotificationPermission:rejecter:)
+ func requestNotificationPermission(_ resolve: @escaping RCTPromiseResolveBlock, rejecter reject: @escaping RCTPromiseRejectBlock) {
+ UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, _ in
+ resolve(granted)
+ }
+ }
+}
diff --git a/ios/Shroud.xcodeproj/project.pbxproj b/ios/Shroud.xcodeproj/project.pbxproj
index 77e93edff..61baa4688 100644
--- a/ios/Shroud.xcodeproj/project.pbxproj
+++ b/ios/Shroud.xcodeproj/project.pbxproj
@@ -35,6 +35,8 @@
782F075B5DD048449E2DECE9 /* libz.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = B9D9B3A7B2CB4255876B67AF /* libz.tbd */; };
84E05A842721191B001A0D3A /* Settings.bundle in Resources */ = {isa = PBXBuildFile; fileRef = 84E05A832721191B001A0D3A /* Settings.bundle */; };
B409AB062D71E07500BA06F8 /* MenuElementsEmitter.swift in Sources */ = {isa = PBXBuildFile; fileRef = B409AB052D71E07500BA06F8 /* MenuElementsEmitter.swift */; };
+ B6BC5CAA2E1207000000B003 /* BackgroundScanManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6BC5CAA2E1207000000B001 /* BackgroundScanManager.swift */; };
+ B6BC5CAA2E1207000000B004 /* BackgroundScanManager.m in Sources */ = {isa = PBXBuildFile; fileRef = B6BC5CAA2E1207000000B002 /* BackgroundScanManager.m */; };
B40FC3FA29CCD1D00007EBAC /* SwiftTCPClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = B40FC3F829CCD1AC0007EBAC /* SwiftTCPClient.swift */; };
B41C2E562BB3DCB8000FE097 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = B41C2E552BB3DCB8000FE097 /* PrivacyInfo.xcprivacy */; };
B41C2E582BB3DCB8000FE097 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = B41C2E552BB3DCB8000FE097 /* PrivacyInfo.xcprivacy */; };
@@ -215,6 +217,8 @@
A7C4B1FDAD264618BAF8C335 /* libRNCWebView.a */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = archive.ar; path = libRNCWebView.a; sourceTree = ""; };
AB2325650CE04F018697ACFE /* libRNReactNativeHapticFeedback.a */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = archive.ar; path = libRNReactNativeHapticFeedback.a; sourceTree = ""; };
B409AB052D71E07500BA06F8 /* MenuElementsEmitter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MenuElementsEmitter.swift; path = MenuElementsEmitter/MenuElementsEmitter.swift; sourceTree = SOURCE_ROOT; };
+ B6BC5CAA2E1207000000B001 /* BackgroundScanManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = BackgroundScanManager.swift; path = BackgroundScan/BackgroundScanManager.swift; sourceTree = SOURCE_ROOT; };
+ B6BC5CAA2E1207000000B002 /* BackgroundScanManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = BackgroundScanManager.m; path = BackgroundScan/BackgroundScanManager.m; sourceTree = SOURCE_ROOT; };
B40FC3F829CCD1AC0007EBAC /* SwiftTCPClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftTCPClient.swift; sourceTree = ""; };
B41C2E552BB3DCB8000FE097 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; };
B43B69BA225C46D800925B1E /* libRCTLinking.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; path = libRCTLinking.a; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -562,6 +566,8 @@
B4AA75232DAA339E00CF5CBE /* MenuElementsEmitter.m */,
B4B3EC232D69FF8700327F3D /* EventEmitter.swift */,
B409AB052D71E07500BA06F8 /* MenuElementsEmitter.swift */,
+ B6BC5CAA2E1207000000B001 /* BackgroundScanManager.swift */,
+ B6BC5CAA2E1207000000B002 /* BackgroundScanManager.m */,
B4B3EC202D69FF6C00327F3D /* CustomSegmentedControl.swift */,
B4B1A4612BFA73110072E3BB /* WidgetHelper.swift */,
);
@@ -905,6 +911,8 @@
B4B3EC252D69FF8700327F3D /* EventEmitter.swift in Sources */,
B48630ED2CCEEEB000A8425C /* WalletAppShortcuts.swift in Sources */,
B409AB062D71E07500BA06F8 /* MenuElementsEmitter.swift in Sources */,
+ B6BC5CAA2E1207000000B003 /* BackgroundScanManager.swift in Sources */,
+ B6BC5CAA2E1207000000B004 /* BackgroundScanManager.m in Sources */,
B44033CE2BCC352900162242 /* UserDefaultsGroup.swift in Sources */,
B461B852299599F800E431AA /* AppDelegate.swift in Sources */,
B44033F42BCC377F00162242 /* WidgetData.swift in Sources */,
diff --git a/ios/Shroud/AppDelegate.swift b/ios/Shroud/AppDelegate.swift
index d8b064176..349d073c4 100644
--- a/ios/Shroud/AppDelegate.swift
+++ b/ios/Shroud/AppDelegate.swift
@@ -56,14 +56,21 @@ class AppDelegate: RCTAppDelegate, UNUserNotificationCenterDelegate {
setupUserDefaultsListener()
registerNotificationCategories()
-
+
// Access the singleton via the class method
_ = MenuElementsEmitter.sharedInstance()
NSLog("[MenuElements] AppDelegate: Initialized emitter singleton")
-
+
+ // BGTaskScheduler handlers must be registered before the app finishes launching.
+ BackgroundScanManager.registerBGTasks()
+
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
+ override func applicationDidEnterBackground(_ application: UIApplication) {
+ BackgroundScanManager.scheduleBGTasks()
+ }
+
override func sourceURL(for bridge: RCTBridge) -> URL? {
return bundleURL()
}
@@ -212,6 +219,8 @@ class AppDelegate: RCTAppDelegate, UNUserNotificationCenterDelegate {
defaults.synchronize()
DispatchQueue.main.async {
+ // Don't try to present UI during a background (BGTask) launch.
+ guard UIApplication.shared.applicationState != .background else { return }
let alert = UIAlertController(
title: "Cache Cleared",
message: "The document, cache, and temp directories have been cleared.",
diff --git a/ios/Shroud/Info.plist b/ios/Shroud/Info.plist
index dbd2529fa..0ec5fa608 100644
--- a/ios/Shroud/Info.plist
+++ b/ios/Shroud/Info.plist
@@ -5,6 +5,7 @@
BGTaskSchedulerPermittedIdentifiers
org.bitshala.shroud.fetchTxsForWallet
+ org.bitshala.shroud.scanCatchup
CADisableMinimumFrameDurationOnPhone
diff --git a/loc/en.json b/loc/en.json
index 0314e103b..def082943 100644
--- a/loc/en.json
+++ b/loc/en.json
@@ -159,6 +159,8 @@
"about_selftest_ok": "All internal tests have passed successfully. The wallet works well.",
"about_sm_github": "GitHub",
"about_sm_discord": "Discord Server",
+ "background_scan": "Background Scanning",
+ "background_scan_explain": "Periodically check for incoming payments while the app is closed and notify you when bitcoin arrives.",
"biometrics": "Biometrics",
"biometrics_no_longer_available": "Your device settings have changed and no longer match the selected security settings in the app. Please re-enable biometrics or passcode, then restart the app to apply these changes.",
"biom_10times": "You have attempted to enter your password 10 times. Would you like to reset your storage? This will remove all wallets and decrypt your storage.",
@@ -372,6 +374,10 @@
"not_supported": "Silent Payments not supported by this wallet",
"explanation": "Silent Payment addresses are reusable. Share one address for multiple payments without compromising privacy."
},
+ "notifications": {
+ "received_title": "Payment received",
+ "received_body": "You received bitcoin. Open Shroud to see it."
+ },
"onboarding": {
"shroud": "Shroud",
"subtitle": "A simple bitcoin wallet for\nall your payments.",
@@ -442,6 +448,7 @@
"btn_retry": "Retry",
"privacy_info": "Shroud checks for your payments on your device, so the server never sees your address. Tor hides your connection.",
"banner_scanning": "Checking pending payments.....",
+ "banner_pending": "Waiting to sync…",
"banner_paused_at": "Sync paused at {blockHeight}",
"banner_synced": "Synced to block {blockHeight}",
"banner_error": "Can't connect right now",
diff --git a/modules/BackgroundScanManager.ts b/modules/BackgroundScanManager.ts
new file mode 100644
index 000000000..13af4559a
--- /dev/null
+++ b/modules/BackgroundScanManager.ts
@@ -0,0 +1,81 @@
+import { NativeEventEmitter, NativeModules, Platform } from 'react-native';
+
+const LINKING_ERROR =
+ `The 'BackgroundScanManager' module is not properly linked. ` +
+ `Please ensure you've rebuilt the app after adding the native module.\n\n` +
+ Platform.select({
+ ios: "- Run 'cd ios && pod install && cd ..'\n",
+ android: '- Ensure the BackgroundScanPackage is registered in MainApplication\n',
+ default: '',
+ }) +
+ `- Rebuild the app (npx react-native run-ios or run-android)`;
+
+const BackgroundScanManagerModule = NativeModules.BackgroundScanManager
+ ? NativeModules.BackgroundScanManager
+ : new Proxy(
+ {},
+ {
+ get() {
+ throw new Error(LINKING_ERROR);
+ },
+ },
+ );
+
+export interface BackgroundScanStartEvent {
+ taskId: string;
+ timeBudgetMs: number;
+}
+
+export interface BackgroundScanCancelEvent {
+ taskId: string;
+}
+
+export interface BackgroundScanStatus {
+ enabled: boolean;
+ lastRunAt: number | null;
+ available: boolean;
+}
+
+/** Enable + schedule periodic background scanning (WorkManager / BGTaskScheduler). */
+export function startBackgroundScanning(): Promise {
+ return BackgroundScanManagerModule.start();
+}
+
+/** Cancel all scheduled background scanning. */
+export function stopBackgroundScanning(): Promise {
+ return BackgroundScanManagerModule.stop();
+}
+
+export function getBackgroundScanStatus(): Promise {
+ return BackgroundScanManagerModule.getStatus();
+}
+
+/**
+ * Signal the native side that the background scan for `taskId` is done.
+ * iOS: maps to BGTask.setTaskCompleted. Android: no-op (the headless task's
+ * promise resolution notifies completion), kept for API symmetry.
+ */
+export function finishBackgroundScan(taskId: string, success: boolean): void {
+ BackgroundScanManagerModule.finish(taskId, success);
+}
+
+/** Post a local notification. Safe to call from a headless/background context. */
+export function postLocalNotification(title: string, body: string): Promise {
+ return BackgroundScanManagerModule.postNotification(title, body);
+}
+
+/** Foreground-only: prompts the user for notification permission. */
+export function requestNotificationPermission(): Promise {
+ return BackgroundScanManagerModule.requestNotificationPermission();
+}
+
+/**
+ * iOS-only event channel: a fired BGTask emits onBackgroundScanStart, and the
+ * expiration handler emits onBackgroundScanCancel. Android drives the scan via
+ * the registered headless task instead. Listeners must attach at module load
+ * (index.js) so a cold background launch can reach JS.
+ */
+export function getBackgroundScanEventEmitter(): NativeEventEmitter | null {
+ if (Platform.OS !== 'ios' || !NativeModules.BackgroundScanManager) return null;
+ return new NativeEventEmitter(NativeModules.BackgroundScanManager);
+}
diff --git a/modules/SilentPaymentIndexer.ts b/modules/SilentPaymentIndexer.ts
index 1a7093292..1dc75c796 100644
--- a/modules/SilentPaymentIndexer.ts
+++ b/modules/SilentPaymentIndexer.ts
@@ -10,7 +10,7 @@ import type {
TransactionByTxidResponse,
} from '../helpers/silent-payments/types';
-class SilentPaymentIndexer {
+export class SilentPaymentIndexer {
private httpClient: IndexerHttpClient;
constructor(config: SilentPaymentIndexerConfig) {
diff --git a/screen/settings/Settings.tsx b/screen/settings/Settings.tsx
index 20cecce05..b873b70a5 100644
--- a/screen/settings/Settings.tsx
+++ b/screen/settings/Settings.tsx
@@ -1,5 +1,7 @@
-import React from 'react';
+import React, { useCallback, useEffect, useState } from 'react';
+import { TouchableWithoutFeedback } from 'react-native';
import ListItem from '../../components/ListItem';
+import { isBackgroundScanningEnabledByUser, setBackgroundScanningUserPref } from '../../helpers/silent-payments/BackgroundScanSetup';
import { useExtendedNavigation } from '../../hooks/useExtendedNavigation';
import loc from '../../loc';
import SafeAreaScrollView from '../../components/SafeAreaScrollView';
@@ -7,11 +9,36 @@ import DeleteWallet from './DeleteWallet';
const Settings = () => {
const { navigate } = useExtendedNavigation();
+ const [backgroundScanEnabled, setBackgroundScanEnabled] = useState(true);
+
+ useEffect(() => {
+ isBackgroundScanningEnabledByUser().then(setBackgroundScanEnabled);
+ }, []);
+
+ const onBackgroundScanSwitch = useCallback(async (value: boolean) => {
+ setBackgroundScanEnabled(value);
+ try {
+ await setBackgroundScanningUserPref(value);
+ } catch (error) {
+ console.warn('[Settings] Failed to toggle background scanning:', error);
+ setBackgroundScanEnabled(!value);
+ }
+ }, []);
return (
navigate('Currency')} testID="Currency" chevron />
navigate('EncryptStorage')} testID="SecurityButton" chevron />
+
{/* TODO: Eventually make this a separate screen with proper description */}
navigate('About')} testID="AboutButton" chevron />
diff --git a/screen/wallets/SyncScreen.tsx b/screen/wallets/SyncScreen.tsx
index 93c23b92c..e135c1816 100644
--- a/screen/wallets/SyncScreen.tsx
+++ b/screen/wallets/SyncScreen.tsx
@@ -82,7 +82,16 @@ const SyncScreen: React.FC = () => {
return () => clearInterval(id);
}, [scanState.status, scanState.eta, scanState.etaComputedAt]);
- const effectiveStatus: EffectiveStatus = showDone ? 'done' : scanState.status === 'idle' ? 'done' : scanState.status;
+ // A never-scanned idle wallet (lastScannedBlock === 0) is "about to sync", not "caught up" —
+ // the home banner advertises it as "waiting to sync", so don't claim done here. Present it as
+ // scanning: the first real scan starts moments after mount and the state self-corrects.
+ const effectiveStatus: EffectiveStatus = showDone
+ ? 'done'
+ : scanState.status !== 'idle'
+ ? scanState.status
+ : scanState.lastScannedBlock > 0
+ ? 'done'
+ : 'scanning';
const effectiveProgress = showDone ? (lastProgressRef.current ?? scanState.progress) : scanState.progress;
const effectivePct = effectiveStatus === 'done' ? 100 : (effectiveProgress?.percentComplete ?? 0);
diff --git a/screen/wallets/WalletsList.tsx b/screen/wallets/WalletsList.tsx
index 8ab6b0d2d..ba2121544 100644
--- a/screen/wallets/WalletsList.tsx
+++ b/screen/wallets/WalletsList.tsx
@@ -506,9 +506,10 @@ const WalletsList: React.FC = () => {
const TRACK_PAYMENT_BANNER_HEIGHT = 90;
const SCAN_BANNER_HEIGHT = 66; // ScanProgressBar: 50 height + 8+8 vertical margins
- // The scan banner renders inside the WALLET section (below the balance) only while a scan is
- // active, so factor its height into the wallet section height to keep getItemLayout offsets correct.
- const isScanBannerVisible = !!scanWallet && (scanState.status !== 'idle' || scanState.lastScannedBlock > 0);
+ // The scan banner renders inside the WALLET section (below the balance) whenever a scannable
+ // wallet exists, so factor its height into the wallet section height to keep getItemLayout
+ // offsets correct.
+ const isScanBannerVisible = !!scanWallet;
const walletSectionHeight = WALLET_HEIGHT + (isScanBannerVisible ? SCAN_BANNER_HEIGHT : 0);
const getSectionHeaderHeight = useCallback(() => {
diff --git a/tests/setup.js b/tests/setup.js
index 88dbe9f99..182d989e3 100644
--- a/tests/setup.js
+++ b/tests/setup.js
@@ -241,10 +241,17 @@ const mockKeychain = {
SECURITY_LEVEL_ANY: 'MOCK_SECURITY_LEVEL_ANY',
SECURITY_LEVEL_SECURE_SOFTWARE: 'MOCK_SECURITY_LEVEL_SECURE_SOFTWARE',
SECURITY_LEVEL_SECURE_HARDWARE: 'MOCK_SECURITY_LEVEL_SECURE_HARDWARE',
+ ACCESSIBLE: {
+ WHEN_UNLOCKED: 'AccessibleWhenUnlocked',
+ AFTER_FIRST_UNLOCK: 'AccessibleAfterFirstUnlock',
+ WHEN_UNLOCKED_THIS_DEVICE_ONLY: 'AccessibleWhenUnlockedThisDeviceOnly',
+ AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY: 'AccessibleAfterFirstUnlockThisDeviceOnly',
+ },
setGenericPassword: jest.fn().mockResolvedValue(),
getGenericPassword: jest.fn().mockResolvedValue(),
resetGenericPassword: jest.fn().mockResolvedValue(),
};
+mockKeychain.default = mockKeychain; // support both `import Keychain from` and named imports
jest.mock('react-native-keychain', () => mockKeychain);
jest.mock('react-native-tcp-socket', () => mockKeychain);
diff --git a/tests/unit/background-scan-merge.test.ts b/tests/unit/background-scan-merge.test.ts
new file mode 100644
index 000000000..92f78a26f
--- /dev/null
+++ b/tests/unit/background-scan-merge.test.ts
@@ -0,0 +1,171 @@
+import { HDSilentPaymentsWallet } from '../../class/wallets/hd-bip352-wallet.ts';
+import { BIP352_ACTIVATION_HEIGHT } from '../../modules/constants.ts';
+import { isScannable } from '../../helpers/silent-payments/types.ts';
+import type { SilentPaymentUTXOSerializable, StagedScanData } from '../../helpers/silent-payments/types.ts';
+
+const SEED = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
+
+function makeWallet(birthHeight: number = BIP352_ACTIVATION_HEIGHT): HDSilentPaymentsWallet {
+ const wallet = new HDSilentPaymentsWallet();
+ wallet.setSecret(SEED);
+ wallet.setBirthHeight(birthHeight);
+ return wallet;
+}
+
+function makeStagedUtxo(overrides: Partial = {}): SilentPaymentUTXOSerializable {
+ return {
+ txid: 'a'.repeat(64),
+ vout: 0,
+ value: 50000,
+ height: BIP352_ACTIVATION_HEIGHT + 10,
+ address: 'bc1p...test',
+ silentPaymentAddress: 'sp1q...test',
+ pubKey: '02'.repeat(33).slice(0, 66),
+ tweakHex: 'cc'.repeat(32),
+ blockHash: 'b'.repeat(64),
+ blockTime: 1715000000,
+ isSpent: false,
+ ...overrides,
+ };
+}
+
+function makeStaged(wallet: HDSilentPaymentsWallet, overrides: Partial = {}): StagedScanData {
+ return {
+ walletID: wallet.getID(),
+ cursor: BIP352_ACTIVATION_HEIGHT + 100,
+ utxos: [makeStagedUtxo()],
+ updatedAt: Date.now(),
+ schema: 1,
+ ...overrides,
+ };
+}
+
+describe('HDSilentPaymentsWallet scan contract', () => {
+ it('satisfies the isScannable() structural guard', () => {
+ // The entire scan UI (home banner, SyncScreen, scan-state callback wiring)
+ // is gated on this guard. It once silently broke when isScanActive() was
+ // deleted as "unused" — every method in IScannableWallet is load-bearing.
+ expect(isScannable(makeWallet())).toBe(true);
+ });
+});
+
+describe('HDSilentPaymentsWallet.mergeStagedScanResults', () => {
+ it('merges staged UTXOs and adopts the staged cursor', () => {
+ const wallet = makeWallet();
+ const staged = makeStaged(wallet);
+
+ const added = wallet.mergeStagedScanResults(staged);
+
+ expect(added).toBe(1);
+ expect(wallet.getLastScannedBlock()).toBe(staged.cursor);
+ const utxos = wallet.getUTXOs();
+ expect(utxos).toHaveLength(1);
+ expect(utxos[0].txid).toBe('a'.repeat(64));
+ // tweak must be rehydrated from hex into a Uint8Array
+ expect(utxos[0].tweak).toBeInstanceOf(Uint8Array);
+ expect(Buffer.from(utxos[0].tweak).toString('hex')).toBe('cc'.repeat(32));
+ });
+
+ it('returns 0 and changes nothing for null staging', () => {
+ const wallet = makeWallet();
+ expect(wallet.mergeStagedScanResults(null)).toBe(0);
+ expect(wallet.getLastScannedBlock()).toBe(0);
+ expect(wallet.getUTXOs()).toHaveLength(0);
+ });
+
+ it('ignores staging that belongs to a different wallet', () => {
+ const wallet = makeWallet();
+ const staged = makeStaged(wallet, { walletID: 'some-other-wallet-id' });
+
+ expect(wallet.mergeStagedScanResults(staged)).toBe(0);
+ expect(wallet.getLastScannedBlock()).toBe(0);
+ expect(wallet.getUTXOs()).toHaveLength(0);
+ });
+
+ it('dedups staged UTXOs against existing ones (re-merge is idempotent)', () => {
+ const wallet = makeWallet();
+ const staged = makeStaged(wallet);
+
+ expect(wallet.mergeStagedScanResults(staged)).toBe(1);
+ // merging the same staging again must be a no-op
+ expect(wallet.mergeStagedScanResults(staged)).toBe(0);
+ expect(wallet.getUTXOs()).toHaveLength(1);
+ });
+
+ it('never regresses the cursor', () => {
+ const wallet = makeWallet();
+ wallet.mergeStagedScanResults(makeStaged(wallet, { cursor: BIP352_ACTIVATION_HEIGHT + 500, utxos: [] }));
+ expect(wallet.getLastScannedBlock()).toBe(BIP352_ACTIVATION_HEIGHT + 500);
+
+ wallet.mergeStagedScanResults(makeStaged(wallet, { cursor: BIP352_ACTIVATION_HEIGHT + 100, utxos: [] }));
+ expect(wallet.getLastScannedBlock()).toBe(BIP352_ACTIVATION_HEIGHT + 500);
+ });
+
+ it('does not adopt a staged cursor below the effective birth height (stale credentials)', () => {
+ // wallet born at tip; a background run with stale credentials scanned from activation
+ const birthHeight = 900000;
+ const wallet = makeWallet(birthHeight);
+ const staged = makeStaged(wallet, { cursor: BIP352_ACTIVATION_HEIGHT + 100 });
+
+ const added = wallet.mergeStagedScanResults(staged);
+
+ // UTXOs still merge (dedup makes this harmless) but the cursor must not
+ // drag a never-scanned wallet years behind its birth height
+ expect(added).toBe(1);
+ expect(wallet.getLastScannedBlock()).toBe(0);
+ });
+
+ it('adopts a staged cursor at or past the birth height', () => {
+ const birthHeight = 900000;
+ const wallet = makeWallet(birthHeight);
+ const staged = makeStaged(wallet, { cursor: birthHeight + 5, utxos: [] });
+
+ wallet.mergeStagedScanResults(staged);
+ expect(wallet.getLastScannedBlock()).toBe(birthHeight + 5);
+ });
+
+ it('fires balance and persist callbacks only when something changed', () => {
+ const wallet = makeWallet();
+ const onBalanceChange = jest.fn();
+ const onPersist = jest.fn();
+ wallet.setOnBalanceChangeCallback(onBalanceChange);
+ wallet.setOnPersistCallback(onPersist);
+
+ wallet.mergeStagedScanResults(makeStaged(wallet));
+ expect(onBalanceChange).toHaveBeenCalledTimes(1);
+ expect(onPersist).toHaveBeenCalledTimes(1);
+
+ // idempotent re-merge: no new UTXOs, no cursor advance → no callbacks
+ wallet.mergeStagedScanResults(makeStaged(wallet));
+ expect(onBalanceChange).toHaveBeenCalledTimes(1);
+ expect(onPersist).toHaveBeenCalledTimes(1);
+ });
+
+ it('emits scan state with the advanced cursor so the UI sees the merge', () => {
+ const wallet = makeWallet();
+ const onScanStateChange = jest.fn();
+ wallet.setOnScanStateChangeCallback(onScanStateChange);
+
+ const staged = makeStaged(wallet);
+ wallet.mergeStagedScanResults(staged);
+
+ expect(onScanStateChange).toHaveBeenCalledTimes(1);
+ expect(onScanStateChange).toHaveBeenCalledWith(expect.objectContaining({ status: 'idle', lastScannedBlock: staged.cursor }));
+
+ // idempotent re-merge: cursor unchanged → no emission
+ wallet.mergeStagedScanResults(staged);
+ expect(onScanStateChange).toHaveBeenCalledTimes(1);
+ });
+
+ it('survives serialization round-trip after merge', () => {
+ const wallet = makeWallet();
+ wallet.mergeStagedScanResults(makeStaged(wallet));
+
+ wallet.prepareForSerialization();
+ const restored = HDSilentPaymentsWallet.fromJson(JSON.stringify(wallet));
+
+ expect(restored.getLastScannedBlock()).toBe(wallet.getLastScannedBlock());
+ expect(restored.getUTXOs()).toHaveLength(1);
+ expect(restored.getUTXOs()[0].tweak).toBeInstanceOf(Uint8Array);
+ });
+});
diff --git a/tests/unit/background-scan-task.test.ts b/tests/unit/background-scan-task.test.ts
new file mode 100644
index 000000000..e0cce1bd2
--- /dev/null
+++ b/tests/unit/background-scan-task.test.ts
@@ -0,0 +1,227 @@
+import { BIP352_ACTIVATION_HEIGHT } from '../../modules/constants.ts';
+import type { ScanCredentials, StagedScanData } from '../../helpers/silent-payments/types.ts';
+// jest.mock calls below are hoisted above this import by ts-jest
+import { runBackgroundScan } from '../../helpers/silent-payments/BackgroundScanTask.ts';
+
+const mockReadScanCredentials = jest.fn();
+const mockReadStaging = jest.fn();
+const mockAppendStagedUtxos = jest.fn();
+const mockIsForegroundActive = jest.fn();
+const mockPostLocalNotification = jest.fn();
+const mockGetLatestBlockHeight = jest.fn();
+const mockGetTransactionsByRange = jest.fn();
+const mockProcessBatch = jest.fn();
+
+jest.mock('../../helpers/silent-payments/BackgroundScanCredentials', () => ({
+ readScanCredentials: () => mockReadScanCredentials(),
+}));
+
+jest.mock('../../helpers/silent-payments/ScanStagingStore', () => ({
+ readStaging: () => mockReadStaging(),
+ appendStagedUtxos: (...args: unknown[]) => mockAppendStagedUtxos(...args),
+}));
+
+jest.mock('../../helpers/silent-payments/ScanLock', () => ({
+ isForegroundActive: () => mockIsForegroundActive(),
+}));
+
+jest.mock('../../modules/BackgroundScanManager', () => ({
+ postLocalNotification: (...args: unknown[]) => mockPostLocalNotification(...args),
+}));
+
+jest.mock('../../modules/RustJsiBridge', () => ({
+ initializeRustJsiBridge: () => true,
+}));
+
+jest.mock('../../modules/SilentPaymentIndexer', () => ({
+ SilentPaymentIndexer: jest.fn().mockImplementation(() => ({
+ getLatestBlockHeight: () => mockGetLatestBlockHeight(),
+ getTransactionsByRange: (start: number, end: number) => mockGetTransactionsByRange(start, end),
+ })),
+}));
+
+jest.mock('../../helpers/silent-payments/RustTransactionProcessor', () => ({
+ RustTransactionProcessor: jest.fn().mockImplementation(() => ({
+ processBatch: (...args: unknown[]) => mockProcessBatch(...args),
+ })),
+}));
+
+// loc pulls in currency/AsyncStorage at module scope; stub the one string table we use
+jest.mock('../../loc', () => ({
+ notifications: { received_title: 'Payment received', received_body: 'You received bitcoin.' },
+}));
+
+const BIRTH = BIP352_ACTIVATION_HEIGHT + 1000;
+
+function credentials(overrides: Partial = {}): ScanCredentials {
+ return {
+ walletID: 'wallet-1',
+ scanPrivkeyHex: '11'.repeat(32),
+ spendPubkeyHex: '02' + '22'.repeat(32),
+ silentPaymentAddress: 'sp1qtest',
+ baseUrl: 'http://indexer.test',
+ cursor: 0,
+ birthHeight: BIRTH,
+ schema: 1,
+ };
+}
+
+function staging(overrides: Partial = {}): StagedScanData {
+ return { walletID: 'wallet-1', cursor: 0, utxos: [], updatedAt: 0, schema: 1, ...overrides };
+}
+
+describe('runBackgroundScan', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockIsForegroundActive.mockResolvedValue(false);
+ mockReadScanCredentials.mockResolvedValue(credentials());
+ mockReadStaging.mockResolvedValue(null);
+ mockAppendStagedUtxos.mockResolvedValue(undefined);
+ mockPostLocalNotification.mockResolvedValue(undefined);
+ mockGetTransactionsByRange.mockResolvedValue({ transactions: [] });
+ mockProcessBatch.mockResolvedValue([]);
+ });
+
+ it('bails when the foreground app is active', async () => {
+ mockIsForegroundActive.mockResolvedValue(true);
+
+ const result = await runBackgroundScan({ timeBudgetMs: 25000 });
+
+ expect(result.bailedReason).toBe('foreground-active');
+ expect(mockReadScanCredentials).not.toHaveBeenCalled();
+ });
+
+ it('bails when no credentials are provisioned', async () => {
+ mockReadScanCredentials.mockResolvedValue(null);
+
+ const result = await runBackgroundScan({ timeBudgetMs: 25000 });
+
+ expect(result.bailedReason).toBe('no-credentials');
+ });
+
+ it('bails when the indexer is unreachable', async () => {
+ mockGetLatestBlockHeight.mockRejectedValue(new Error('network down'));
+
+ const result = await runBackgroundScan({ timeBudgetMs: 25000 });
+
+ expect(result.bailedReason).toBe('indexer-unreachable');
+ expect(mockAppendStagedUtxos).not.toHaveBeenCalled();
+ });
+
+ it('scans from the birth height for a never-scanned wallet and stages per range', async () => {
+ mockGetLatestBlockHeight.mockResolvedValue({ height: BIRTH + 99 }); // 100 blocks = 2 ranges
+
+ const result = await runBackgroundScan({ timeBudgetMs: 60000 });
+
+ expect(result.bailedReason).toBeUndefined();
+ expect(result.caughtUp).toBe(true);
+ expect(result.blocksScanned).toBe(100);
+ expect(mockGetTransactionsByRange).toHaveBeenNthCalledWith(1, BIRTH, BIRTH + 49);
+ expect(mockGetTransactionsByRange).toHaveBeenNthCalledWith(2, BIRTH + 50, BIRTH + 99);
+ // cursor staged once per range, only after that range's fetch+scan succeeded
+ expect(mockAppendStagedUtxos).toHaveBeenNthCalledWith(1, 'wallet-1', [], BIRTH + 49);
+ expect(mockAppendStagedUtxos).toHaveBeenNthCalledWith(2, 'wallet-1', [], BIRTH + 99);
+ expect(mockPostLocalNotification).not.toHaveBeenCalled();
+ });
+
+ it('resumes from the highest of credentials and staging cursors', async () => {
+ mockReadScanCredentials.mockResolvedValue(credentials({ cursor: BIRTH + 10 }));
+ mockReadStaging.mockResolvedValue(staging({ cursor: BIRTH + 60 }));
+ mockGetLatestBlockHeight.mockResolvedValue({ height: BIRTH + 80 });
+
+ const result = await runBackgroundScan({ timeBudgetMs: 60000 });
+
+ expect(mockGetTransactionsByRange).toHaveBeenCalledTimes(1);
+ expect(mockGetTransactionsByRange).toHaveBeenCalledWith(BIRTH + 61, BIRTH + 80);
+ expect(result.newCursor).toBe(BIRTH + 80);
+ });
+
+ it('ignores staging that belongs to another wallet', async () => {
+ mockReadStaging.mockResolvedValue(staging({ walletID: 'other-wallet', cursor: BIRTH + 60 }));
+ mockGetLatestBlockHeight.mockResolvedValue({ height: BIRTH + 49 });
+
+ await runBackgroundScan({ timeBudgetMs: 60000 });
+
+ expect(mockGetTransactionsByRange).toHaveBeenCalledWith(BIRTH, BIRTH + 49);
+ });
+
+ it('stops at a failed range without advancing the cursor past it', async () => {
+ mockGetLatestBlockHeight.mockResolvedValue({ height: BIRTH + 149 }); // 3 ranges
+ mockGetTransactionsByRange.mockResolvedValueOnce({ transactions: [] }).mockRejectedValueOnce(new Error('range fetch failed'));
+
+ const result = await runBackgroundScan({ timeBudgetMs: 60000 });
+
+ expect(result.bailedReason).toBeUndefined();
+ expect(result.caughtUp).toBe(false);
+ expect(result.newCursor).toBe(BIRTH + 49); // only the successful range
+ expect(mockAppendStagedUtxos).toHaveBeenCalledTimes(1);
+ });
+
+ it('stops when the time budget is exhausted', async () => {
+ mockGetLatestBlockHeight.mockResolvedValue({ height: BIRTH + 10_000 });
+ // Budget = safety margin → deadline already passed when the loop starts
+ const result = await runBackgroundScan({ timeBudgetMs: 2000 });
+
+ expect(result.blocksScanned).toBe(0);
+ expect(result.caughtUp).toBe(false);
+ expect(mockGetTransactionsByRange).not.toHaveBeenCalled();
+ });
+
+ it('posts a notification when new UTXOs are found and stages them with the cursor', async () => {
+ mockGetLatestBlockHeight.mockResolvedValue({ height: BIRTH + 49 });
+ const tx = { id: 'tx1', blockHeight: BIRTH + 5, blockHash: 'h', blockTime: 1, scanTweak: 'ab', outputs: [{}] };
+ mockGetTransactionsByRange.mockResolvedValue({ transactions: [tx] });
+ const found = {
+ txid: 'f'.repeat(64),
+ vout: 0,
+ value: 1234,
+ height: BIRTH + 5,
+ address: 'bc1ptest',
+ silentPaymentAddress: 'sp1qtest',
+ pubKey: '02ab',
+ tweak: new Uint8Array([0xaa, 0xbb]),
+ blockHash: 'h',
+ blockTime: 1,
+ isSpent: false,
+ };
+ mockProcessBatch.mockResolvedValue([found]);
+
+ const result = await runBackgroundScan({ timeBudgetMs: 60000 });
+
+ expect(result.newUtxos).toBe(1);
+ expect(mockAppendStagedUtxos).toHaveBeenCalledWith(
+ 'wallet-1',
+ [expect.objectContaining({ txid: 'f'.repeat(64), tweakHex: 'aabb' })],
+ BIRTH + 49,
+ );
+ expect(mockPostLocalNotification).toHaveBeenCalledTimes(1);
+ });
+
+ it('still succeeds when the notification fails to post', async () => {
+ mockGetLatestBlockHeight.mockResolvedValue({ height: BIRTH + 49 });
+ mockGetTransactionsByRange.mockResolvedValue({
+ transactions: [{ id: 'tx1', blockHeight: BIRTH + 5, blockHash: 'h', blockTime: 1, scanTweak: 'ab', outputs: [{}] }],
+ });
+ mockProcessBatch.mockResolvedValue([
+ {
+ txid: 'f'.repeat(64),
+ vout: 0,
+ value: 1,
+ height: BIRTH + 5,
+ address: 'a',
+ silentPaymentAddress: 's',
+ pubKey: 'p',
+ tweak: new Uint8Array([1]),
+ blockHash: 'h',
+ blockTime: 1,
+ isSpent: false,
+ },
+ ]);
+ mockPostLocalNotification.mockRejectedValue(new Error('not linked'));
+
+ const result = await runBackgroundScan({ timeBudgetMs: 60000 });
+
+ expect(result.bailedReason).toBeUndefined();
+ expect(result.newUtxos).toBe(1);
+ });
+});