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); + }); +});