What changed, and why it matters
This commit is a routine feature patch that wires up background and foreground wallet synchronization and adds incoming transaction notifications across mobile and desktop. It does not appear to fix a security vulnerability; it is part of an ongoing migration to a shared 'wallet-core' backend. The changes mostly move existing logic into helper functions and add notification gating so users are not spammed or re-notified.
No security action required. Treat as a normal feature/refactor commit. Reviewers may want to ensure the temporary engine log line in main.dart is removed before release, and verify that background isolate wallet lifecycle management does not leave files or listeners open longer than intended.
Security signals we found
No security-relevant keywords in commit title or message
Refactoring of background isolate wallet opening (could affect resource/notification behavior but not a vulnerability fix)
Notification gating added to respect user toggle and avoid duplicate announcements
Temporary debug logging added (not a security issue on its own)
No changes to secrets, keys, seed handling, or authentication
Evidence from the diff
The diff refactors background sync and transaction notification handling. It introduces openBackgroundWallet() in wallet_core_glue.dart to unify how WorkManager tasks and the Android foreground service open the wallet, applying node/Tor/background-sync gates before the expensive open. It adds a loadTxHistory() method to the AppWallet interface and a notifyNewIncomingTxs({bool announce = true}) parameter so the foreground can mark transactions as seen without firing an OS notification. Desktop gets a foreground-only announce path; mobile relies on background isolates. A temporary log line is added to main() to identify which engine is active. No cryptographic, authentication, or access-control changes are present.
Changed components
lib/main.dartlib/models/app_wallet.dartlib/models/monero_wallet_adapter.dartlib/models/wallet_model.dartlib/periodic_tasks.dartlib/services/foreground_sync_service.dartlib/wallet_core_glue.dartInspect captured patch +181 / −71
diff --git a/lib/main.dart b/lib/main.dart
index b63c300..3a05239 100644
--- a/lib/main.dart
+++ b/lib/main.dart
@@ -21,6 +21,7 @@ import 'package:skylight_wallet/models/theme_model.dart';
import 'package:skylight_wallet/l10n/app_localizations.dart';
import 'package:skylight_wallet/screens/settings.dart';
import 'package:skylight_wallet/models/wallet_model.dart';
+import 'package:skylight_wallet/models/app_wallet.dart';
import 'package:skylight_wallet/screens/connection_setup.dart';
import 'package:skylight_wallet/screens/fiat_api_setup_screen.dart';
import 'package:skylight_wallet/screens/generate_seed.dart';
@@ -56,6 +57,9 @@ void main() async {
() async {
WidgetsFlutterBinding.ensureInitialized();
+ // TEMP: confirm which engine the build is running. Remove when done.
+ log(LogLevel.warn, '▶ ENGINE: ${useSharedWalletCore ? 'wallet-core (flag ON)' : 'WalletModel (flag off)'}');
+
if (useSharedWalletCore) {
installWalletCore();
}
@@ -150,7 +154,7 @@ class _AppRoot extends StatefulWidget {
State<_AppRoot> createState() => _AppRootState();
}
-class _AppRootState extends State<_AppRoot> {
+class _AppRootState extends State<_AppRoot> with WidgetsBindingObserver {
// Started once, on the first build. Building it inside the builder would
// re-run it on every theme/language change, opening the wallet again — a
// second wallet on the same file, with its own sync loop, while the first is
@@ -158,6 +162,48 @@ class _AppRootState extends State<_AppRoot> {
Future<List<Object>>? _startup;
// Services that must fire once the startup work is done, not on every build.
var _startedServices = false;
+ // Desktop-only foreground announce: listens for tx-history growth (see below).
+ AppWallet? _announceWallet;
+ int _lastAnnouncedTxCount = 0;
+
+ @override
+ void initState() {
+ super.initState();
+ WidgetsBinding.instance.addObserver(this);
+ }
+
+ @override
+ void dispose() {
+ WidgetsBinding.instance.removeObserver(this);
+ _announceWallet?.removeListener(_announceNewTxsOnGrowth);
+ super.dispose();
+ }
+
+ @override
+ void didChangeAppLifecycleState(AppLifecycleState state) {
+ // Mobile only: leaving the app marks everything on screen as seen so a
+ // background isolate won't re-notify a tx the user just watched arrive.
+ // Desktop has no background isolate — and doing this would pre-empt its
+ // foreground announce. Marks only synced history (hash-based), so an
+ // unsynced receipt is still announced later.
+ if (state == AppLifecycleState.paused && isMobile) {
+ unawaited(appWalletOf(context, listen: false).notifyNewIncomingTxs(announce: false));
+ }
+ }
+
+ // Desktop has no background isolate to announce incoming txs, so the
+ // foreground announces when the wallet's history grows. notifyNewIncomingTxs
+ // is the decider (hash-based, net-receipt only, respects the notifications
+ // toggle); the count is a cheap gate so unrelated notifications (connectivity,
+ // balance) don't hit the keystore.
+ void _announceNewTxsOnGrowth() {
+ final wallet = _announceWallet;
+ if (wallet == null) return;
+ final count = wallet.txHistory.length;
+ if (count <= _lastAnnouncedTxCount) return;
+ _lastAnnouncedTxCount = count;
+ unawaited(wallet.notifyNewIncomingTxs());
+ }
Future<List<Object>> _runStartup() {
// Wallet existence drives the initial route; done quickly without a full load.
@@ -201,6 +247,15 @@ class _AppRootState extends State<_AppRoot> {
if (walletExists) {
fiatRate.startService();
}
+
+ // Desktop has no background isolate to announce incoming txs, so
+ // the foreground announces on tx-history growth. wallet-core only:
+ // the legacy WalletModel still announces inline from loadTxHistory
+ // on desktop. Mobile announces from its background isolates.
+ if (useSharedWalletCore && isDesktop) {
+ _announceWallet = appWalletOf(context, listen: false)
+ ..addListener(_announceNewTxsOnGrowth);
+ }
}
return MaterialApp(
diff --git a/lib/models/app_wallet.dart b/lib/models/app_wallet.dart
index 8bff4dc..e6da35c 100644
--- a/lib/models/app_wallet.dart
+++ b/lib/models/app_wallet.dart
@@ -51,6 +51,7 @@ abstract interface class AppWallet implements Listenable {
// Lifecycle
Future<bool> hasExistingWallet();
Future<void> load();
+ Future<void> loadTxHistory();
Future<int> getRestoreHeight();
Future<void> pauseSyncAndStore();
@@ -77,7 +78,10 @@ abstract interface class AppWallet implements Listenable {
// Notifications
Future<void> markExistingTxsAsNotified();
- Future<void> notifyNewIncomingTxs();
+ /// [announce] false records current history as seen without firing an OS
+ /// notification — the foreground uses it so a tx the user watched arrive is
+ /// not re-notified by a background isolate.
+ Future<void> notifyNewIncomingTxs({bool announce});
// Key export (secret_keys / lws screens)
Future<String> readSecretViewKey();
diff --git a/lib/models/monero_wallet_adapter.dart b/lib/models/monero_wallet_adapter.dart
index d479883..02e7d6d 100644
--- a/lib/models/monero_wallet_adapter.dart
+++ b/lib/models/monero_wallet_adapter.dart
@@ -101,6 +101,8 @@ class MoneroWalletAdapter extends ChangeNotifier implements AppWallet {
@override
Future<void> load() => _wallet.load();
@override
+ Future<void> loadTxHistory() => _wallet.loadTxHistory();
+ @override
Future<int> getRestoreHeight() => _wallet.getRestoreHeight();
@override
Future<void> pauseSyncAndStore() => _wallet.pauseSyncAndStore();
@@ -162,7 +164,8 @@ class MoneroWalletAdapter extends ChangeNotifier implements AppWallet {
@override
Future<void> markExistingTxsAsNotified() => _wallet.markExistingTxsAsNotified();
@override
- Future<void> notifyNewIncomingTxs() => _wallet.notifyNewIncomingTxs();
+ Future<void> notifyNewIncomingTxs({bool announce = true}) =>
+ _wallet.notifyNewIncomingTxs(announce: announce);
// Key export
@override
diff --git a/lib/models/wallet_model.dart b/lib/models/wallet_model.dart
index 3591d65..3ef1999 100644
--- a/lib/models/wallet_model.dart
+++ b/lib/models/wallet_model.dart
@@ -582,7 +582,7 @@ class WalletModel with ChangeNotifier implements AppWallet {
/// Safe to call from any isolate and as often as you like: what has been
/// announced is persisted, so the background task, the foreground service and
/// a future caller can't double-announce or cancel each other out.
- Future<void> notifyNewIncomingTxs() async {
+ Future<void> notifyNewIncomingTxs({bool announce = true}) async {
final state = await readTxNotificationState();
// Never seeded (fresh install, or an upgrade from the old counter): take
@@ -602,7 +602,7 @@ class WalletModel with ChangeNotifier implements AppWallet {
await SharedPreferencesService.get<bool>(SharedPreferencesKeys.notificationsEnabled) ??
false;
- if (notificationsEnabled) {
+ if (announce && notificationsEnabled) {
for (final tx in decision.toAnnounce) {
await NotificationService().showIncomingTxNotification(tx.amount);
}
diff --git a/lib/periodic_tasks.dart b/lib/periodic_tasks.dart
index 9a63f24..5c300f0 100644
--- a/lib/periodic_tasks.dart
+++ b/lib/periodic_tasks.dart
@@ -1,10 +1,9 @@
import 'dart:io';
-import 'package:skylight_wallet/models/wallet_model.dart';
import 'package:skylight_wallet/services/shared_preferences_service.dart';
import 'package:skylight_wallet/services/tor_service.dart';
import 'package:skylight_wallet/util/logging.dart';
-import 'package:skylight_wallet/wallet_core_glue.dart' show useSharedWalletCore;
+import 'package:skylight_wallet/wallet_core_glue.dart' show openBackgroundWallet;
import 'package:workmanager/workmanager.dart';
class PeriodicTasks {
@@ -60,41 +59,15 @@ Future<bool> runTxNotifier({
bool allowTor = true,
bool allowNode = true,
}) async {
- final wallet = WalletModel();
-
- if (!await wallet.hasExistingWallet()) {
- return true;
- }
-
- // Load the connection first so the correct-mode wallet file is opened.
- await wallet.loadPersistedConnection();
-
- if (!allowNode && wallet.connectionType == 'node') {
- log(LogLevel.info, '[Background sync] Node connection; not syncing in this window.');
- return true;
- }
-
- if (!allowTor && wallet.usingTor) {
- log(LogLevel.info, '[Background sync] Tor connection; needs the longer window.');
- return true;
- }
-
- final backgroundSync =
- await SharedPreferencesService.get<bool>(SharedPreferencesKeys.backgroundSyncEnabled) ??
- false;
-
- // A full-node scan is heavy and only runs when Background Sync is on; an LWS
- // wallet always syncs (server-side, cheap). Decided before the wallet is
- // opened: this task stays scheduled for notifications alone, so a node wallet
- // with Background Sync off lands here every cycle, and opening the wallet
- // (with its cached-stats read) is the expensive part of a run that is about
- // to do nothing. Leaving one open would also give the model's own timers
- // something to connect.
- if (wallet.connectionType == 'node' && !backgroundSync) {
- return true;
- }
-
- await wallet.openExisting();
+ // Loads the connection, applies the node/Tor/background-sync gates, and opens
+ // the correct-mode wallet — null when there's no wallet or this window won't
+ // sync it. A node scan is heavy, so it only runs with Background Sync on.
+ final wallet = await openBackgroundWallet(
+ allowTor: allowTor,
+ allowNode: allowNode,
+ requireBackgroundSyncForNode: true,
+ );
+ if (wallet == null) return true;
if (wallet.usingTor) {
await TorService.sharedInstance.start();
@@ -193,9 +166,6 @@ void _callbackDispatcher() {
/// Call after anything that changes the answer: the notifications toggle, the
/// background-sync toggle, or the connection itself.
Future<void> applyBackgroundTaskRegistration() async {
- // These tasks still drive the legacy WalletModel; under wallet-core they would
- // open the wallet a second time. Skip until they're migrated (Phase 6).
- if (useSharedWalletCore) return;
if (Platform.isIOS) return _applyIosBackgroundTasks();
if (!Platform.isAndroid) return;
@@ -272,16 +242,6 @@ Future<void> registerPeriodicTasks() async {
return;
}
- if (useSharedWalletCore) {
- // Not migrated to wallet-core yet. Cancel anything a prior legacy-mode
- // install left scheduled, so WorkManager can't wake a second WalletModel in
- // the background isolate (and stops tracking its constraints).
- await Workmanager().cancelByUniqueName(PeriodicTasks.txNotifier);
- await Workmanager().cancelByUniqueName(_iosRefreshTaskId);
- await Workmanager().cancelByUniqueName(_iosProcessingTaskId);
- return;
- }
-
Workmanager().initialize(_callbackDispatcher);
await applyBackgroundTaskRegistration();
}
diff --git a/lib/services/foreground_sync_service.dart b/lib/services/foreground_sync_service.dart
index 59f6538..779d8d6 100644
--- a/lib/services/foreground_sync_service.dart
+++ b/lib/services/foreground_sync_service.dart
@@ -3,11 +3,11 @@ import 'dart:io';
import 'package:flutter_foreground_task/flutter_foreground_task.dart';
-import 'package:skylight_wallet/models/wallet_model.dart';
+import 'package:skylight_wallet/models/app_wallet.dart';
import 'package:skylight_wallet/services/shared_preferences_service.dart';
import 'package:skylight_wallet/services/tor_service.dart';
import 'package:skylight_wallet/util/logging.dart';
-import 'package:skylight_wallet/wallet_core_glue.dart' show useSharedWalletCore;
+import 'package:skylight_wallet/wallet_core_glue.dart' show openBackgroundWallet;
/// Android foreground service that keeps the Monero wallet syncing while the
/// app is backgrounded — a persistent-notification alternative to the
@@ -23,17 +23,16 @@ void foregroundSyncCallback() {
}
class _SyncTaskHandler extends TaskHandler {
- WalletModel? _wallet;
+ AppWallet? _wallet;
@override
Future<void> onStart(DateTime timestamp, TaskStarter starter) async {
- final wallet = WalletModel();
- _wallet = wallet;
try {
- if (!await wallet.hasExistingWallet()) return;
- // Load the connection first so the correct-mode wallet file is opened.
- await wallet.loadPersistedConnection();
- await wallet.openExisting();
+ // Opens the correct-mode wallet (or null if there's none). The foreground
+ // service syncs whatever the user connected to, so no node/Tor gating.
+ final wallet = await openBackgroundWallet();
+ _wallet = wallet;
+ if (wallet == null) return;
if (wallet.usingTor) {
await TorService.sharedInstance.start();
@@ -120,9 +119,6 @@ void initForegroundSync() {
}
Future<void> startForegroundSync() async {
- // Runs the legacy WalletModel in its own isolate; under wallet-core that opens
- // the wallet a second time. Skip until it's migrated (Phase 6).
- if (useSharedWalletCore) return;
if (!Platform.isAndroid) return;
initForegroundSync();
await FlutterForegroundTask.requestNotificationPermission();
diff --git a/lib/wallet_core_glue.dart b/lib/wallet_core_glue.dart
index e36f64a..b35d076 100644
--- a/lib/wallet_core_glue.dart
+++ b/lib/wallet_core_glue.dart
@@ -30,21 +30,113 @@ const _moneroDecimals = 12;
bool get _isMobile => Platform.isAndroid || Platform.isIOS;
-/// Installs wallet-core's app config + injectable seams. Call once from main().
+bool _walletCoreInstalled = false;
+
+/// Installs wallet-core's app config + injectable seams. Idempotent: the main
+/// isolate calls it from main(), and each background isolate calls it too (a
+/// fresh isolate does not inherit the main one's statics).
void installWalletCore() {
+ if (_walletCoreInstalled) return;
+ _walletCoreInstalled = true;
+
WalletAppConfig.install(WalletAppConfig.skylight);
CryptoWallet.aliasResolver = resolveOpenAlias;
wcore.WalletLog.sink = const _SkylightLogSink();
wcore.WalletLog.isVerbose = () async =>
- await SharedPreferencesService.get<bool>(SharedPreferencesKeys.verboseLoggingEnabled) ?? false;
+ await SharedPreferencesService.get<bool>(SharedPreferencesKeys.verboseLoggingEnabled) ??
+ false;
CryptoWallet.incomingTxNotifier = (tx, _) {
- final amount = double.tryParse(baseUnitsToDecimalString(tx.amountBaseUnits, _moneroDecimals)) ?? 0;
- NotificationService().showIncomingTxNotification(amount);
+ final amount =
+ double.tryParse(baseUnitsToDecimalString(tx.amountBaseUnits, _moneroDecimals)) ?? 0;
+ if (!_isMobile) {
+ // Desktop has no notifications toggle (it's Android/iOS-only), and always
+ // showed from the legacy WalletModel's loadTxHistory. Keep that.
+ NotificationService().showIncomingTxNotification(amount);
+ return;
+ }
+ // Mobile respects the toggle. notifyNewIncomingTxs still records the tx as
+ // seen whether or not this fires, so turning it on later does not replay a
+ // backlog.
+ SharedPreferencesService.get<bool>(SharedPreferencesKeys.notificationsEnabled).then((on) {
+ if (on ?? false) NotificationService().showIncomingTxNotification(amount);
+ });
};
}
+/// Opens the XMR wallet inside a background isolate (WorkManager / foreground
+/// service), or returns null when there is no wallet or this window should not
+/// sync it. The connection is loaded first so the correct-mode file opens and
+/// the node/Tor gates can be checked before the expensive open.
+///
+/// [allowTor]/[allowNode] describe what the scheduling window can accommodate;
+/// [requireBackgroundSyncForNode] additionally skips a node wallet unless the
+/// user turned Background Sync on (a node scan is heavy — the periodic task
+/// sets it, the foreground service does not). All flag-branched here so the
+/// isolates drive a single [AppWallet]. Under the flag the manager is kept
+/// alive for the isolate's lifetime by the wallet's listener back to it.
+Future<AppWallet?> openBackgroundWallet({
+ bool allowTor = true,
+ bool allowNode = true,
+ bool requireBackgroundSyncForNode = false,
+}) async {
+ if (useSharedWalletCore) {
+ installWalletCore();
+ final manager = WalletManager(coins: () => [MoneroWallet()]);
+ if (!await manager.hasAnyExistingWallet()) return null;
+
+ // Loads the persisted connection for each coin without opening files.
+ await manager.loadCachedDisplayState();
+ final wallet = manager.getWallet('XMR') as MoneroWallet;
+ if (!await _shouldBackgroundSync(
+ connectionType: wallet.connectionType,
+ usingTor: wallet.usingTor,
+ allowTor: allowTor,
+ allowNode: allowNode,
+ requireBackgroundSyncForNode: requireBackgroundSyncForNode,
+ )) {
+ return null;
+ }
+
+ await manager.openAll();
+ return MoneroWalletAdapter(wallet);
+ }
+
+ final wallet = WalletModel();
+ if (!await wallet.hasExistingWallet()) return null;
+ await wallet.loadPersistedConnection();
+ if (!await _shouldBackgroundSync(
+ connectionType: wallet.connectionType,
+ usingTor: wallet.usingTor,
+ allowTor: allowTor,
+ allowNode: allowNode,
+ requireBackgroundSyncForNode: requireBackgroundSyncForNode,
+ )) {
+ return null;
+ }
+ await wallet.openExisting();
+ return wallet;
+}
+
+Future<bool> _shouldBackgroundSync({
+ required String connectionType,
+ required bool usingTor,
+ required bool allowTor,
+ required bool allowNode,
+ required bool requireBackgroundSyncForNode,
+}) async {
+ if (!allowNode && connectionType == 'node') return false;
+ if (!allowTor && usingTor) return false;
+ if (requireBackgroundSyncForNode && connectionType == 'node') {
+ final on =
+ await SharedPreferencesService.get<bool>(SharedPreferencesKeys.backgroundSyncEnabled) ??
+ false;
+ if (!on) return false;
+ }
+ return true;
+}
+
/// WalletManager provider, gated. Coexists with WalletModel during migration.
ChangeNotifierProvider<WalletManager> walletManagerProvider() =>
ChangeNotifierProvider(create: (_) => WalletManager(coins: () => [MoneroWallet()]));
Why this scored 19/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.