What changed, and why it matters
This commit removes the old 'WalletModel' wallet engine and switches the Skylight Wallet app to use only the newer 'wallet-core' engine. It is a large cleanup/refactoring change: about 2,000 lines of the old engine code are deleted, feature flags are removed, and screens now always talk to the shared wallet-core adapter. There is no direct evidence in the commit of a security vulnerability being fixed; it reads as a completion of a migration that had already been running behind a feature flag.
Treat this as a significant refactoring commit rather than an emergency security patch. Reviewers should verify that the wallet-core adapter correctly reproduces the legacy behavior for key operations (restore, open, send, address validation, Tor/clearnet routing, and notification state), and that no secrets or wallet files are left in an inconsistent state during the migration. Regression testing on both mobile and desktop is warranted before release.
Security signals we found
Large deletion of a legacy wallet engine that handled raw FFI pointers, isolate hops, and secret keys
Removal of conditional code paths that could have led to divergent behavior between legacy and wallet-core modes
Centralization of address validation behind the AppWallet interface instead of direct FFI usage
No explicit security fix, CVE, or vulnerability description in commit message or diff
Evidence from the diff
The diff deletes lib/models/wallet_model.dart (the legacy Monero FFI wallet implementation) and introduces lib/models/wallet_types.dart containing only the neutral data classes (TxDetails, TxRecipient, LWSConnectionDetails, ResolvedOpenAlias) that screens need. The useSharedWalletCore compile-time flag and all conditional branches choosing between WalletModel and wallet-core are removed. Main.dart, wallet_core_glue.dart, and several screens are simplified to always use wallet-core’s WalletManager and MoneroWalletAdapter. Address validation in the address book is moved from a direct monero.Wallet_addressValid FFI call to the AppWallet abstraction.
Changed components
lib/main.dartlib/models/app_wallet.dartlib/models/monero_wallet_adapter.dartlib/models/wallet_model.dartlib/models/wallet_types.dartlib/screens/address_book.dartlib/screens/send.dartlib/screens/wallet_home.dartlib/util/tx_notifications.dartlib/wallet_core_glue.dartlib/widgets/tx_details.dartInspect captured patch +165 / −2193
diff --git a/lib/main.dart b/lib/main.dart
index 3a05239..28335ac 100644
--- a/lib/main.dart
+++ b/lib/main.dart
@@ -20,7 +20,6 @@ import 'package:skylight_wallet/models/language_model.dart';
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';
@@ -57,12 +56,7 @@ 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();
- }
+ installWalletCore();
// Catch Flutter framework errors
FlutterError.onError = (FlutterErrorDetails details) {
@@ -112,22 +106,6 @@ void main() async {
);
}
-Future<bool> loadExistingWalletIfExists(WalletModel wallet) async {
- if (await wallet.hasExistingWallet()) {
- if (isMobile) {
- // Load the persisted connection first so the wallet opens the file for
- // the correct mode (LWS vs node).
- await wallet.loadPersistedConnection();
- await wallet.openExisting();
- wallet.load();
- }
-
- return true;
- }
-
- return false;
-}
-
class MyApp extends StatelessWidget {
const MyApp({super.key});
@@ -135,8 +113,7 @@ class MyApp extends StatelessWidget {
Widget build(BuildContext context) {
return MultiProvider(
providers: [
- if (useSharedWalletCore) walletManagerProvider(),
- ChangeNotifierProvider(create: (context) => WalletModel()),
+ walletManagerProvider(),
ChangeNotifierProvider(create: (context) => LanguageModel()),
ChangeNotifierProvider(create: (context) => ThemeModel()),
ChangeNotifierProvider(create: (context) => FiatRateModel()),
@@ -207,9 +184,7 @@ class _AppRootState extends State<_AppRoot> with WidgetsBindingObserver {
Future<List<Object>> _runStartup() {
// Wallet existence drives the initial route; done quickly without a full load.
- final walletExists = useSharedWalletCore
- ? startupWalletManager(context)
- : loadExistingWalletIfExists(Provider.of<WalletModel>(context, listen: false));
+ final walletExists = startupWalletManager(context);
return Future.wait([SharedPreferences.getInstance(), walletExists]);
}
@@ -249,10 +224,9 @@ class _AppRootState extends State<_AppRoot> with WidgetsBindingObserver {
}
// 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) {
+ // the foreground announces on tx-history growth. Mobile announces
+ // from its background isolates.
+ if (isDesktop) {
_announceWallet = appWalletOf(context, listen: false)
..addListener(_announceNewTxsOnGrowth);
}
diff --git a/lib/models/app_wallet.dart b/lib/models/app_wallet.dart
index 9ae9f8f..6d3a91a 100644
--- a/lib/models/app_wallet.dart
+++ b/lib/models/app_wallet.dart
@@ -1,6 +1,6 @@
import 'package:flutter/foundation.dart' show Listenable;
-import 'package:skylight_wallet/models/wallet_model.dart'
+import 'package:skylight_wallet/models/wallet_types.dart'
show TxDetails, LWSConnectionDetails, ResolvedOpenAlias;
/// A transaction built but not yet broadcast, in display (XMR) units. The
@@ -16,10 +16,9 @@ abstract interface class AppPendingTx {
typedef StoredSeed = ({String mnemonic, String format});
typedef StoredSeedReader = Future<StoredSeed?> Function();
-/// The wallet surface screens use. Both the legacy [WalletModel] and the
-/// wallet-core adapter implement it, so a screen reads one type regardless of
-/// useSharedWalletCore. Divergent flows (send/restore/create) are handled at
-/// their screens, not here.
+/// The wallet surface screens use. The wallet-core adapter
+/// ([MoneroWalletAdapter]) implements it, so screens read one neutral type.
+/// Divergent flows (send/restore/create) are handled at their screens, not here.
abstract interface class AppWallet implements Listenable {
// Connection
String get connectionAddress;
diff --git a/lib/models/monero_wallet_adapter.dart b/lib/models/monero_wallet_adapter.dart
index 02e7d6d..c8533ff 100644
--- a/lib/models/monero_wallet_adapter.dart
+++ b/lib/models/monero_wallet_adapter.dart
@@ -1,7 +1,7 @@
import 'package:flutter/foundation.dart';
import 'package:skylight_wallet/models/app_wallet.dart';
-import 'package:skylight_wallet/models/wallet_model.dart'
+import 'package:skylight_wallet/models/wallet_types.dart'
show TxDetails, TxRecipient, LWSConnectionDetails, ResolvedOpenAlias;
import 'package:wallet_domain/wallet_domain.dart' as domain;
diff --git a/lib/models/wallet_model.dart b/lib/models/wallet_model.dart
deleted file mode 100644
index 3ef1999..0000000
--- a/lib/models/wallet_model.dart
+++ /dev/null
@@ -1,2043 +0,0 @@
-// ignore_for_file: implementation_imports, annotate_overrides
-import 'dart:async';
-import 'dart:convert';
-import 'dart:ffi';
-import 'dart:io';
-import 'dart:isolate';
-import 'dart:math';
-import 'package:dart_date/dart_date.dart';
-import 'package:flutter/foundation.dart';
-import 'package:monero/monero.dart' as monero;
-import 'package:monero/src/monero.dart';
-import 'package:monero/src/wallet2.dart';
-import 'package:wallet_openalias/wallet_openalias.dart';
-import 'package:http/http.dart' as http;
-import 'package:polyseed/polyseed.dart';
-import 'package:bip39/bip39.dart' as bip39;
-
-import 'package:skylight_wallet/services/notifications_service.dart';
-import 'package:skylight_wallet/services/shared_preferences_service.dart';
-import 'package:skylight_wallet/models/app_wallet.dart';
-import 'package:skylight_wallet/services/tor_service.dart';
-import 'package:skylight_wallet/services/tor_settings_service.dart';
-import 'package:skylight_wallet/util/amount_units.dart';
-import 'package:skylight_wallet/util/bip39.dart';
-import 'package:skylight_wallet/util/cacert.dart';
-import 'package:skylight_wallet/util/contacts_store.dart';
-import 'package:skylight_wallet/util/formatting.dart';
-import 'package:skylight_wallet/util/get_height_by_date.dart';
-import 'package:skylight_wallet/util/logging.dart';
-import 'package:skylight_wallet/util/socks_http.dart';
-import 'package:skylight_wallet/util/tx_notification_state.dart';
-import 'package:skylight_wallet/util/tx_notifications.dart';
-import 'package:skylight_wallet/util/wallet.dart';
-import 'package:skylight_wallet/util/wallet_password.dart';
-import 'package:skylight_wallet/consts.dart' as consts;
-
-String generateHexString(int length) {
- final Random random = Random.secure();
- final Uint8List bytes = Uint8List(length);
-
- for (int i = 0; i < length; i++) {
- bytes[i] = random.nextInt(256);
- }
-
- return bytes.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join();
-}
-
-/// A validated OpenAlias resolution: the Monero address to pay, plus what the
-/// recipient published about themselves, for the confirm screen.
-class ResolvedOpenAlias {
- ResolvedOpenAlias({required this.address, required this.version, this.recipientName});
-
- final String address;
-
- /// 1 if this came from an `oa1:xmr` record, 2 from `_openalias-payment`.
- final int version;
-
- /// The recipient's display name, if they published one. Display only — it has
- /// no bearing on [address].
- final String? recipientName;
-}
-
-/// Wraps a native pending tx as the neutral [AppPendingTx].
-class _MoneroPendingTx implements AppPendingTx {
- _MoneroPendingTx(this.raw);
- final MoneroPendingTransaction raw;
- @override
- double get amount => doubleAmountFromInt(raw.amount());
- @override
- double get fee => doubleAmountFromInt(raw.fee());
-}
-
-class TxDetails {
- final int? index;
- final int direction;
- final String hash;
- final double amount;
- final double fee;
- final List<TxRecipient> recipients;
- final int? accountIndex;
- final List<int> subaddrIndexList;
- final int timestamp;
- final int height;
- final int confirmations;
- final String key;
-
- TxDetails({
- required this.index,
- required this.direction,
- required this.hash,
- required this.amount,
- required this.fee,
- required this.recipients,
- required this.accountIndex,
- required this.subaddrIndexList,
- required this.timestamp,
- required this.height,
- required this.confirmations,
- required this.key,
- });
-
- Map<String, dynamic> toJson() => {
- 'index': index,
- 'direction': direction,
- 'hash': hash,
- 'amount': amount,
- 'fee': fee,
- 'recipients': recipients.map((r) => r.toJson()).toList(),
- 'accountIndex': accountIndex,
- 'subaddrIndexList': subaddrIndexList,
- 'timestamp': timestamp,
- 'height': height,
- 'confirmations': confirmations,
- 'key': key,
- };
-
- factory TxDetails.fromJson(Map<String, dynamic> json) => TxDetails(
- index: json['index'] as int?,
- direction: json['direction'] as int,
- hash: json['hash'] as String,
- amount: (json['amount'] as num).toDouble(),
- fee: (json['fee'] as num).toDouble(),
- recipients: (json['recipients'] as List<dynamic>)
- .map((r) => TxRecipient.fromJson(r as Map<String, dynamic>))
- .toList(),
- accountIndex: json['accountIndex'] as int?,
- subaddrIndexList: (json['subaddrIndexList'] as List<dynamic>).cast<int>(),
- timestamp: json['timestamp'] as int,
- height: json['height'] as int,
- confirmations: json['confirmations'] as int,
- key: json['key'] as String,
- );
-}
-
-class TxRecipient {
- final String address;
- final double amount;
-
- TxRecipient(this.address, this.amount);
-
- Map<String, dynamic> toJson() => {'address': address, 'amount': amount};
-
- factory TxRecipient.fromJson(Map<String, dynamic> json) =>
- TxRecipient(json['address'] as String, (json['amount'] as num).toDouble());
-}
-
-class LWSConnectionDetails {
- final String address;
- final String proxyPort;
- final bool useTor;
- final bool useSsl;
- final String connectionType;
-
- LWSConnectionDetails({
- required this.address,
- required this.proxyPort,
- required this.useTor,
- required this.useSsl,
- this.connectionType = 'lws',
- });
-}
-
-class WalletModel with ChangeNotifier implements AppWallet {
- // Which factory built the cached manager: 'lws' (LWSF) or 'node' (wallet2).
- Wallet2WalletManager? _w2WalletManager;
- String? _managerType;
- // Mode the currently-open _w2Wallet was loaded for ('lws' | 'node').
- String? _loadedType;
- // True once Wallet_init has run; daemon-dependent FFI calls abort before it.
- bool _daemonInitialized = false;
-
- Wallet2Wallet? _w2Wallet;
- Wallet2TransactionHistory? _w2TxHistory;
-
- late String _connectionAddress;
- late String _connectionProxyPort;
- late bool _connectionUseTor;
- late bool _connectionUseSsl;
- String _connectionType = 'lws';
- // False until a connection is in memory (loaded from prefs or set by the
- // settings form). Which wallet file exists/opens depends on it, so anything
- // that resolves a wallet path has to wait for it.
- bool _connectionLoaded = false;
-
- int? _daemonTargetHeight;
- DateTime? _lastDaemonHeightFetch;
-
- // Reconnect policy. Seconds to wait before the next attempt, indexed by how
- // many attempts have failed in a row: the first retry is nearly immediate,
- // then it backs off to the refresh cycle's cadence.
- static const _reconnectBackoffSeconds = [1, 2, 5, 10, 20];
- Future<void>? _connectInFlight;
- DateTime? _lastConnectAttempt;
- int _connectFailures = 0;
- // Set when this connection is marked Tor-only but no Tor proxy can be had.
- // Nothing connects while it holds — the alternative is a silent clearnet
- // fallback that leaks the view key and the user's IP to the server.
- bool _torRequirementBroken = false;
-
- // Serialize periodic tasks + teardown: the raw pointer must not be freed
- // while an isolate read is in flight. Skip-if-busy, not a queue.
- bool _walletBusy = false;
- bool _disposing = false;
- Completer<void>? _walletIdle;
-
- final _sessionStartedAt = DateTime.now().secondsSinceEpoch;
- var _hasAttemptedConnection = false;
- var _isConnected = false;
- var _isSynced = false;
- int? _syncedHeight;
- double? _unlockedBalance;
- double? _totalBalance;
- List<TxDetails> _txHistory = [];
- bool? _serverSupportsSubaddresses;
- int? _unusedSubaddressIndex;
- bool? _unusedSubaddressIndexIsSupported;
- String? _desktopWalletPassword;
-
- Wallet2Wallet? get w2Wallet => _w2Wallet;
- String get connectionAddress => _connectionAddress;
- String get connectionProxyPort => _connectionProxyPort;
- bool get connectionUseTor => _connectionUseTor;
- bool get connectionUseSsl => _connectionUseSsl;
- bool get hasAttemptedConnection => _hasAttemptedConnection;
- bool get isConnected => _isConnected;
- bool get isSynced => _isSynced;
- int? get syncedHeight => _syncedHeight;
- double? get unlockedBalance => _unlockedBalance;
- double? get totalBalance => _totalBalance;
- List<TxDetails> get txHistory => _txHistory;
- bool get usingTor => _connectionUseTor;
-
- /// True when this connection requires Tor but none is available, so the app
- /// is deliberately not connecting at all.
- bool get torRequirementBroken => _torRequirementBroken;
- bool? get serverSupportsSubaddresses => _serverSupportsSubaddresses;
- int? get unusedSubaddressIndex => _unusedSubaddressIndex;
- bool? get unusedSubaddressIndexIsSupported => _unusedSubaddressIndexIsSupported;
- String get connectionType => _connectionType;
-
- /// True when configured to talk to a full Monero node rather than an LWS.
- bool get isNodeMode => _connectionType == 'node';
-
- /// Manager factory kind the current connection needs ('lws' | 'node').
- String get _desiredManagerType => isNodeMode ? 'node' : 'lws';
-
- /// Blocks still to scan in node mode while syncing; null in LWS / when synced.
- int? get syncBlocksRemaining {
- if (!isNodeMode || _isSynced) return null;
- final target = _daemonTargetHeight;
- final have = _syncedHeight;
- if (target == null || have == null || target <= 0) return null;
- final remaining = target - have;
- return remaining > 0 ? remaining : null;
- }
-
- WalletModel() {
- _startTimers();
- }
-
- /// Returns the wallet manager built by the factory matching the current
- /// connection type. LWS uses the LWSF manager; a full node uses the standard
- /// wallet2 manager. Switching types rebuilds the manager and tears down any
- /// wallet the previous one opened.
- Future<Wallet2WalletManager> _walletManager() async {
- final type = _desiredManagerType;
- if (_w2WalletManager != null && _managerType == type) return _w2WalletManager!;
-
- if (_w2WalletManager != null && _w2Wallet != null) {
- // Switching factories frees the old wallet; quiesce the timer tasks first.
- await _closeOpenWallet();
- }
-
- final managerFactory = Monero().walletManagerFactory();
- _w2WalletManager = type == 'node'
- ? managerFactory.getWalletManager()
- : managerFactory.getLWSFWalletManager();
- _managerType = type;
- return _w2WalletManager!;
- }
-
- /// Frees the open native wallet safely: blocks new periodic tasks, waits for
- /// any in-flight one (they hold the raw pointer), then closes. Only called
- /// from user flows, never a guarded task, so it can't self-deadlock.
- Future<void> _closeOpenWallet() async {
- _disposing = true;
- try {
- if (_walletBusy) {
- _walletIdle = Completer<void>();
- await _walletIdle!.future;
- }
- if (_w2Wallet != null) {
- _w2Wallet!.pauseRefresh();
- _w2WalletManager?.closeWallet(_w2Wallet!, false);
- _w2Wallet = null;
- _w2TxHistory = null;
- _daemonInitialized = false;
- _loadedType = null;
- }
- } finally {
- _disposing = false;
- }
- }
-
- /// Path of the wallet file for the current mode. LWS keeps the original
- /// `mywallet` path; the node gets a `_node` suffix so toggling modes doesn't
- /// force a rescan.
- Future<String> resolveWalletPath() async {
- return walletPathForType(_connectionType);
- }
-
- /// Path of the wallet file a given mode ('lws' | 'node') would use.
- Future<String> walletPathForType(String connectionType) async {
- final basePath = await getWalletPath();
- return connectionType == 'node' ? '${basePath}_node' : basePath;
- }
-
- /// Runs a periodic task, at most one at a time; teardown waits on the
- /// in-flight one before freeing the wallet.
- Future<void> _runGuarded(Future<void> Function() task) async {
- if (_walletBusy || _disposing || _w2Wallet == null) return;
- _walletBusy = true;
- try {
- await task();
- } catch (e) {
- log(LogLevel.warn, 'Periodic wallet task failed: $e');
- } finally {
- _walletBusy = false;
- _walletIdle?.complete();
- _walletIdle = null;
- }
- }
-
- void _startTimers() {
- Timer.periodic(Duration(seconds: 1), (timer) {
- _runGuarded(_runCheckConnectionTimerTask);
- });
-
- Timer.periodic(Duration(seconds: 20), (timer) {
- _runGuarded(_runRefreshTimerTask);
- });
- }
-
- Future<void> _runCheckConnectionTimerTask() async {
- if (_w2Wallet == null) {
- return;
- }
-
- final isConnected = await getIsConnected();
-
- if (isConnected != _isConnected && _w2Wallet != null) {
- log(LogLevel.info, 'Connection status changed to: $isConnected');
- _isConnected = isConnected;
- notifyListeners();
- }
-
- // Awaited so the connect stays inside the guard, not detached past teardown.
- await _retryConnectIfDue();
-
- // Node sync state flips off the native background thread; poll it here so
- // "blocks remaining" advances between the slower refresh cycles.
- await pollSyncStatus();
- }
-
- /// Retries a connection that isn't up, on a short backoff.
- ///
- /// Without this the only reconnect is the 20s refresh cycle, so a connect
- /// that fails on launch — Tor not ready yet, node unreachable for a moment —
- /// leaves the wallet doing nothing for up to 20 seconds behind a sync
- /// spinner. `load()` also abandons its refresh + stats when its connect
- /// throws, so those are picked up here once a retry gets through.
- Future<void> _retryConnectIfDue() async {
- if (_w2Wallet == null || _isConnected || _connectInFlight != null) return;
- // Nothing to connect to yet.
- if (!_connectionLoaded || _connectionAddress.isEmpty) return;
-
- final lastAttempt = _lastConnectAttempt;
-
- if (lastAttempt != null) {
- final backoffIndex = min(_connectFailures, _reconnectBackoffSeconds.length - 1);
- final wait = Duration(seconds: _reconnectBackoffSeconds[backoffIndex]);
- if (DateTime.now().difference(lastAttempt) < wait) return;
- }
-
- try {
- await connectToDaemon();
- if (!_isConnected) return;
-
- await refresh();
- // Same deferral as the refresh cycle: in node mode the stat reads wait
- // until the background scan has caught up.
- if (isNodeMode && !_isSynced) return;
- await loadAllStats();
- } catch (e) {
- log(LogLevel.warn, 'Reconnect attempt failed: $e');
- }
- }
-
- /// Node-only: reads sync flag + heights off the background scan thread. When
- /// it just caught up, pulls fresh balances/tx immediately.
- Future<void> pollSyncStatus() async {
- if (!isNodeMode || _w2Wallet == null || !_daemonInitialized) return;
-
- final wasSynced = _isSynced;
- await loadIsSynced();
- await loadSyncedHeight();
- notifyListeners();
-
- if (!wasSynced && _isSynced) {
- await loadAllStats();
- }
- }
-
- Future<void> _runRefreshTimerTask() async {
- if (_w2Wallet == null) {
- return;
- }
-
- // Reconnect if the connection dropped since the last cycle.
- if (!_isConnected) {
- try {
- await connectToDaemon();
- } catch (e) {
- log(LogLevel.warn, 'Reconnect attempt failed: $e');
- }
- }
-
- await refresh();
-
- // In node mode, defer the expensive stat reads until the scan has caught
- // up (avoids contending with the native refresh thread). pollSyncStatus
- // handles progress + the one-time catch-up load.
- if (isNodeMode && !_isSynced) {
- await store();
- return;
- }
-
- try {
- await loadAllStats().timeout(Duration(seconds: 20));
- } catch (e) {
- log(LogLevel.error, 'Error loading all stats: $e');
- }
-
- await store();
- }
-
- Future<void> load() async {
- if (_w2Wallet == null) {
- return;
- }
-
- await loadPersistedSubaddressSupport();
- await loadPersistedUnusedSubaddressIndex();
- // Connect first so the daemon-dependent calls below have an initialized
- // wallet (refresh/stats early-return until connect sets _daemonInitialized).
- await connectToDaemon();
- await refresh();
- await loadAllStats();
- await loadSubaddressSupport();
- await loadUnusedSubaddressIndex();
- }
-
- Future<void> loadAllStats() async {
- if (_w2Wallet == null) {
- log(LogLevel.warn, 'Attempted to load all stats but there is no wallet open.');
- return;
- }
-
- await Future.wait([
- loadIsSynced(),
- loadSyncedHeight(),
- loadUnlockedBalance(),
- loadTotalBalance(),
- loadTxHistory(),
- ]);
-
- notifyListeners();
- }
-
- /// Re-reads the transaction list from the wallet's cache.
- ///
- /// Note what this deliberately does *not* do: touch notification state. Every
- /// isolate (UI, foreground service, background task) refreshes history on its
- /// own timer, so anything recorded here would be consumed by whichever one
- /// refreshed first, whether or not it announced anything. That belongs to
- /// [notifyNewIncomingTxs].
- Future<void> loadTxHistory() async {
- final txCount = _w2TxHistory!.count();
- var hasPendingTx = false;
-
- if (_txHistory.isNotEmpty) {
- final lastTx = txHistory[0];
-
- if (lastTx.confirmations < 10) {
- hasPendingTx = true;
- }
- }
-
- if (txCount > _txHistory.length || hasPendingTx) {
- final txCountDiff = txCount - _txHistory.length;
-
- _txHistory = _getTxHistory();
-
- // Notify new transactions on desktop
- if ((Platform.isLinux || Platform.isWindows || Platform.isMacOS) &&
- _isConnected &&
- _isSynced &&
- _syncedHeight is int &&
- _syncedHeight! > 0) {
- for (int i = 0; i < txCountDiff; i++) {
- final tx = _txHistory[i];
-
- if (tx.direction == consts.txDirectionIncoming && tx.timestamp > _sessionStartedAt) {
- NotificationService().showIncomingTxNotification(tx.amount);
- // Only notify one new transaction
- break;
- }
- }
- }
- }
-
- if (txCount > _txHistory.length) {
- await loadUnusedSubaddressIndex();
- }
- }
-
- Future<void> persistCurrentConnection() async {
- await SharedPreferencesService.set(SharedPreferencesKeys.connectionAddress, _connectionAddress);
- await SharedPreferencesService.set(
- SharedPreferencesKeys.connectionProxyPort,
- _connectionProxyPort,
- );
- await SharedPreferencesService.set(SharedPreferencesKeys.connectionUseTor, _connectionUseTor);
- await SharedPreferencesService.set(SharedPreferencesKeys.connectionUseSsl, _connectionUseSsl);
- await SharedPreferencesService.set(SharedPreferencesKeys.connectionType, _connectionType);
- }
-
- Future<LWSConnectionDetails> getPersistedConnection() async {
- return LWSConnectionDetails(
- address:
- await SharedPreferencesService.get<String>(SharedPreferencesKeys.connectionAddress) ?? '',
- proxyPort:
- await SharedPreferencesService.get<String>(SharedPreferencesKeys.connectionProxyPort) ??
- '',
- useTor:
- await SharedPreferencesService.get<bool>(SharedPreferencesKeys.connectionUseTor) ?? false,
- useSsl:
- await SharedPreferencesService.get<bool>(SharedPreferencesKeys.connectionUseSsl) ?? false,
- connectionType:
- await SharedPreferencesService.get<String>(SharedPreferencesKeys.connectionType) ?? 'lws',
- );
- }
-
- Future<void> loadPersistedConnection() async {
- final connectionDetails = await getPersistedConnection();
- setConnection(
- address: connectionDetails.address,
- proxyPort: connectionDetails.proxyPort,
- useTor: connectionDetails.useTor,
- useSsl: connectionDetails.useSsl,
- connectionType: connectionDetails.connectionType,
- );
- }
-
- /// Loads the persisted connection unless one is already in memory.
- Future<void> _ensureConnectionLoaded() async {
- if (_connectionLoaded) return;
- await loadPersistedConnection();
- }
-
- /// Treats everything currently on chain as already seen. Called when a wallet
- /// is created or restored and when notifications are switched on, so the user
- /// is told about what arrives from here on rather than their whole history.
- Future<void> markExistingTxsAsNotified() async {
- await writeTxNotificationState(
- TxNotificationState(cutoff: DateTime.now().secondsSinceEpoch, announcedHashes: const []),
- );
- }
-
- /// Announces incoming transactions the user hasn't been told about yet.
- ///
- /// 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({bool announce = true}) async {
- final state = await readTxNotificationState();
-
- // Never seeded (fresh install, or an upgrade from the old counter): take
- // the current chain as the starting point instead of announcing a backlog.
- if (state.cutoff == null) {
- await markExistingTxsAsNotified();
- return;
- }
-
- final decision = decideTxNotifications(
- txHistory: _txHistory,
- cutoff: state.cutoff!,
- announcedHashes: state.announcedHashes,
- );
-
- final notificationsEnabled =
- await SharedPreferencesService.get<bool>(SharedPreferencesKeys.notificationsEnabled) ??
- false;
-
- if (announce && notificationsEnabled) {
- for (final tx in decision.toAnnounce) {
- await NotificationService().showIncomingTxNotification(tx.amount);
- }
- }
-
- // Recorded either way: with notifications off these are still "seen", so
- // switching the setting on later doesn't replay them.
- await writeTxNotificationState(
- TxNotificationState(cutoff: decision.cutoff, announcedHashes: decision.announcedHashes),
- );
- }
-
- /// Loads balances + tx history from the just-opened monero_c wallet cache,
- /// offline (no daemon). Lets the last-known state render instantly on reopen
- /// while the sync catches up. `TransactionHistory_refresh` here is local — it
- /// re-reads the wallet's cached transfers, not the network.
- Future<void> loadCachedStats() async {
- if (_w2Wallet == null || _w2TxHistory == null) return;
-
- final historyFfiAddr = _w2TxHistory!.ffiAddress();
- await Isolate.run(
- // ignore: deprecated_member_use
- () => monero.TransactionHistory_refresh(Pointer.fromAddress(historyFfiAddr)),
- );
-
- await Future.wait([loadUnlockedBalance(), loadTotalBalance(), loadTxHistory()]);
-
- notifyListeners();
- }
-
- void setConnection({
- required String address,
- required String proxyPort,
- required bool useTor,
- required bool useSsl,
- String connectionType = 'lws',
- }) {
- _connectionAddress = address;
- _connectionProxyPort = proxyPort;
- _connectionUseTor = useTor;
- _connectionUseSsl = useSsl;
- _connectionType = connectionType;
- _connectionLoaded = true;
- // A reconfigured connection gets a clean slate; the next attempt decides
- // again whether Tor is available for it.
- _torRequirementBroken = false;
- notifyListeners();
- }
-
- /// Called when Tor is switched off globally. A connection that requires Tor
- /// is marked broken and reported disconnected straight away, rather than
- /// looking healthy until the next refresh cycle notices.
- void onGlobalTorDisabled() {
- if (!_connectionUseTor || _torRequirementBroken) return;
-
- log(LogLevel.warn, 'Tor disabled globally; a Tor-only connection can no longer be used.');
- _torRequirementBroken = true;
- _isConnected = false;
- notifyListeners();
- }
-
- void setWalletPassword(String password) {
- _desktopWalletPassword = password;
- }
-
- /// Connects the open wallet to the configured server. Callers that arrive
- /// while an attempt is in flight join it instead of starting a second one:
- /// two overlapping `Wallet_init` calls race on the same native wallet, and
- /// over Tor they burn a second circuit for nothing.
- Future<void> connectToDaemon() async {
- final inFlight = _connectInFlight;
- if (inFlight != null) return inFlight;
-
- final attempt = _runConnectAttempt();
- _connectInFlight = attempt;
-
- try {
- await attempt;
- } finally {
- if (identical(_connectInFlight, attempt)) _connectInFlight = null;
- // A connect can fail without throwing — Wallet_connectToDaemon reports
- // failure by logging — so the backoff counts outcomes, not exceptions.
- _connectFailures = _isConnected ? 0 : _connectFailures + 1;
- }
- }
-
- Future<void> _runConnectAttempt() async {
- if (_w2Wallet == null) throw Exception("w2wallet is null");
-
- _lastConnectAttempt = DateTime.now();
-
- // The open wallet is bound to the factory of the mode it was opened in
- // (LWS vs node). Connecting before a rebuild would call Wallet_init with a
- // mismatched lightWallet flag and abort.
- if (_loadedType != null && _loadedType != _desiredManagerType) {
- log(
- LogLevel.warn,
- 'Skipping connect: loaded as "$_loadedType" but connection needs "$_desiredManagerType"; awaiting rebuild',
- );
- return;
- }
-
- String? torProxyPort;
-
- if (_connectionUseTor) {
- final proxyInfo = await TorSettingsService.sharedInstance.getProxy();
-
- if (proxyInfo == null) {
- // Fail closed. Carrying on would leave proxyAddress empty and Wallet_init
- // would reach the server directly — handing it the primary address, the
- // private view key and the real IP, every refresh cycle, on a connection
- // the user marked Tor-only. Never fall back to clearnet.
- log(LogLevel.warn, 'Connection requires Tor but no proxy is available; not connecting.');
- _torRequirementBroken = true;
- _hasAttemptedConnection = true;
- _isConnected = false;
- notifyListeners();
- return;
- }
-
- torProxyPort = proxyInfo.port.toString();
- }
-
- _torRequirementBroken = false;
-
- final proxyPort = torProxyPort ?? _connectionProxyPort;
-
- if (Platform.isAndroid) {
- // This addresses SSL certificate verification issues on Android
- final cacertFile = await getCacertFile();
- _w2Wallet!.setCaFilePath(cacertFile.path);
- }
-
- await _connectToDaemon(
- address: _connectionAddress,
- proxyPort: proxyPort,
- useSsl: _connectionUseSsl,
- );
-
- _hasAttemptedConnection = true;
- // Read the outcome now rather than leaving it to the 1s poll — that's up to
- // a second of sync spinner after a connection is already up.
- _isConnected = await getIsConnected();
-
- notifyListeners();
- }
-
- Future<void> _connectToDaemon({
- required String address,
- String? proxyPort,
- bool useSsl = false,
- }) async {
- final walletFfiAddr = _w2Wallet!.ffiAddress();
- final daemonAddress = '${useSsl ? 'https://' : 'http://'}$address';
- final proxyAddress = proxyPort != '' ? '127.0.0.1:$proxyPort' : '';
- // A full node scans locally (lightWallet=false); LWS scans server-side.
- final lightWallet = !isNodeMode;
- final isNode = isNodeMode;
-
- log(LogLevel.info, 'Calling Wallet_init with parameters:');
- log(LogLevel.info, ' daemonAddress: $daemonAddress');
- log(LogLevel.info, ' proxyAddress: $proxyAddress');
- log(LogLevel.info, ' useSsl: $useSsl');
- log(LogLevel.info, ' lightWallet: $lightWallet');
-
- // Run init + connect (+ node refresh-thread kick) in a single isolate hop
- // and time each step so a slow daemon handshake is attributable.
- final r = await Isolate.run(() {
- final ptr = Pointer<Void>.fromAddress(walletFfiAddr);
- final sw = Stopwatch()..start();
- // ignore: deprecated_member_use
- final initResult = monero.Wallet_init(
- ptr,
- daemonAddress: daemonAddress,
- proxyAddress: proxyAddress,
- useSsl: useSsl,
- lightWallet: lightWallet,
- );
- final initMs = sw.elapsedMilliseconds;
- sw.reset();
- // ignore: deprecated_member_use
- final connectResult = monero.Wallet_connectToDaemon(ptr);
- final connectMs = sw.elapsedMilliseconds;
- sw.reset();
- var refreshMs = 0;
- if (isNode) {
- // ignore: deprecated_member_use
- monero.Wallet_setAutoRefreshInterval(ptr, millis: 10000);
- // ignore: deprecated_member_use
- monero.Wallet_startRefresh(ptr);
- refreshMs = sw.elapsedMilliseconds;
- }
- return (
- initResult: initResult,
- connectResult: connectResult,
- initMs: initMs,
- connectMs: connectMs,
- refreshMs: refreshMs,
- );
- });
-
- log(
- LogLevel.info,
- 'Wallet connect timings: init ${r.initMs}ms (result ${r.initResult}), '
- 'connectToDaemon ${r.connectMs}ms (result ${r.connectResult}), '
- 'startRefresh ${r.refreshMs}ms',
- );
-
- _daemonInitialized = true;
-
- final connectError = _w2Wallet!.errorString();
-
- if (connectError != '') {
- log(LogLevel.warn, 'Wallet_connectToDaemon error: $connectError');
- }
- }
-
- /// Probes a connection without opening the wallet. Throws on failure.
- Future<void> testConnection({
- required String address,
- String? proxyPort,
- required bool useSsl,
- required bool useTor,
- String connectionType = '',
- }) async {
- if (connectionType == 'node') {
- await _testNodeConnection(
- address: address,
- proxyPort: proxyPort,
- useSsl: useSsl,
- useTor: useTor,
- );
- return;
- }
-
- final url = '${useSsl ? 'https' : 'http'}://$address/get_address_info';
- log(LogLevel.info, 'Probing LWS server: $url (tor=$useTor, proxyPort=$proxyPort)');
-
- late int statusCode;
- if (useTor) {
- final torSettings = TorSettingsService.sharedInstance;
- if (torSettings.torMode == TorMode.disabled) {
- throw Exception('Tor is disabled. Please go back and enable it.');
- }
- final proxyInfo = await torSettings.getProxy();
- if (proxyInfo == null) {
- throw Exception('Could not resolve a Tor proxy.');
- }
- final response = await makeSocksHttpRequest(
- 'POST',
- url,
- proxyInfo,
- ).timeout(Duration(seconds: 20));
- statusCode = response.statusCode;
- } else {
- var httpClient = HttpClient();
- if (proxyPort != null && proxyPort.isNotEmpty) {
- httpClient.findProxy = (_) => 'SOCKS localhost:$proxyPort';
- }
- try {
- final request = await httpClient.postUrl(Uri.parse(url));
- final response = await request.close().timeout(Duration(seconds: 10));
- statusCode = response.statusCode;
- } finally {
- httpClient.close(force: true);
- }
- }
-
- // LWS responds with 500 to an unauthenticated POST to /get_address_info.
- // Anything else means we're not talking to a real LWS endpoint.
- if (statusCode != HttpStatus.internalServerError) {
- throw Exception('Unexpected status $statusCode from $url');
- }
- }
-
- /// Probes a full Monero node via `GET /get_height`. A real monerod replies
- /// 200 with a JSON body carrying a `height` (and `status`).
- Future<void> _testNodeConnection({
- required String address,
- String? proxyPort,
- required bool useSsl,
- required bool useTor,
- }) async {
- final url = '${useSsl ? 'https' : 'http'}://$address/get_height';
- log(LogLevel.info, 'Probing Monero node: $url (tor=$useTor, proxyPort=$proxyPort)');
-
- late int statusCode;
- dynamic jsonBody;
- if (useTor) {
- final torSettings = TorSettingsService.sharedInstance;
- if (torSettings.torMode == TorMode.disabled) {
- throw Exception('Tor is disabled. Please go back and enable it.');
- }
- final proxyInfo = await torSettings.getProxy();
- if (proxyInfo == null) {
- throw Exception('Could not resolve a Tor proxy.');
- }
- final response = await makeSocksHttpRequest(
- 'GET',
- url,
- proxyInfo,
- ).timeout(Duration(seconds: 20));
- statusCode = response.statusCode;
- jsonBody = response.jsonBody;
- } else {
- final httpClient = HttpClient();
- if (proxyPort != null && proxyPort.isNotEmpty) {
- httpClient.findProxy = (_) => 'SOCKS localhost:$proxyPort';
- }
- try {
- final request = await httpClient.getUrl(Uri.parse(url));
- final response = await request.close().timeout(Duration(seconds: 10));
- statusCode = response.statusCode;
- final body = await response.transform(utf8.decoder).join();
- try {
- jsonBody = json.decode(body);
- } catch (_) {
- jsonBody = null;
- }
- } finally {
- httpClient.close(force: true);
- }
- }
-
- if (statusCode != HttpStatus.ok || jsonBody is! Map || jsonBody['height'] == null) {
- throw Exception('Unexpected response ($statusCode) from $url');
- }
- }
-
- Future<void> loadPersistedSubaddressSupport() async {
- _serverSupportsSubaddresses = await SharedPreferencesService.get<bool>(
- SharedPreferencesKeys.serverSupportsSubaddresses,
- );
- }
-
- Future<void> loadSubaddressSupport() async {
- // A full node supports subaddresses natively; skip the LWS HTTP probe.
- if (isNodeMode) {
- _serverSupportsSubaddresses = true;
- await SharedPreferencesService.set<bool>(
- SharedPreferencesKeys.serverSupportsSubaddresses,
- true,
- );
- return;
- }
-
- try {
- final isSupported = await isSubaddressSupported(1);
- _serverSupportsSubaddresses = isSupported;
-
- await SharedPreferencesService.set<bool>(
- SharedPreferencesKeys.serverSupportsSubaddresses,
- _serverSupportsSubaddresses!,
- );
- } catch (e) {
- //
- }
- }
-
- Future<void> loadUnusedSubaddressIndex() async {
- final txHistory = _getTxHistory();
-
- Set<int> usedIndexes = {};
-
- for (final tx in txHistory) {
- if (tx.accountIndex == 0) {
- for (final subaddrIndex in tx.subaddrIndexList) {
- usedIndexes.add(subaddrIndex);
- }
- }
- }
-
- int nextSubaddrIndex = 1;
-
- while (usedIndexes.contains(nextSubaddrIndex)) {
- nextSubaddrIndex++;
- }
-
- if (_unusedSubaddressIndex != nextSubaddrIndex) {
- // Node supports subaddresses natively; skip the LWS HTTP probe.
- if (isNodeMode) {
- _unusedSubaddressIndex = nextSubaddrIndex;
- _unusedSubaddressIndexIsSupported = true;
- await SharedPreferencesService.set<int>(
- SharedPreferencesKeys.unusedSubaddressIndex,
- _unusedSubaddressIndex!,
- );
- await SharedPreferencesService.set<bool>(
- SharedPreferencesKeys.unusedSubaddressIndexIsSupported,
- true,
- );
- notifyListeners();
- return;
- }
-
- try {
- final isSupported = await isSubaddressSupported(nextSubaddrIndex);
- _unusedSubaddressIndex = nextSubaddrIndex;
- _unusedSubaddressIndexIsSupported = isSupported;
-
- await SharedPreferencesService.set<int>(
- SharedPreferencesKeys.unusedSubaddressIndex,
- _unusedSubaddressIndex!,
- );
- await SharedPreferencesService.set<bool>(
- SharedPreferencesKeys.unusedSubaddressIndexIsSupported,
- _unusedSubaddressIndexIsSupported!,
- );
-
- notifyListeners();
- } catch (e) {
- //
- }
- }
- }
-
- Future<void> loadPersistedUnusedSubaddressIndex() async {
- _unusedSubaddressIndex = await SharedPreferencesService.get<int>(
- SharedPreferencesKeys.unusedSubaddressIndex,
- );
- _unusedSubaddressIndexIsSupported = await SharedPreferencesService.get<bool>(
- SharedPreferencesKeys.unusedSubaddressIndexIsSupported,
- );
- }
-
- Future<bool> isSubaddressSupported(int subaddrIndex) async {
- final proto = _connectionUseSsl ? 'https' : 'http';
- final url = Uri.parse('$proto://$_connectionAddress/upsert_subaddrs');
- final primaryAddress = getPrimaryAddress();
- final viewKey = _w2Wallet!.secretViewKey();
- final subaddrs = [
- {
- "key": 0,
- "value": [
- [0, subaddrIndex],
- ],
- },
- ];
- final getAll = false;
-
- final body = json.encode({
- 'address': primaryAddress,
- 'view_key': viewKey,
- 'subaddrs': subaddrs,
- 'get_all': getAll,
- });
-
- log(LogLevel.info, 'Checking subaddress support:');
- log(LogLevel.info, ' url: $url');
- log(LogLevel.info, ' primaryAddress: $primaryAddress');
- log(LogLevel.info, ' viewKey: <hidden>');
- log(LogLevel.info, ' subaddrs: $subaddrs');
- log(LogLevel.info, ' getAll: $getAll');
-
- var httpStatus = 0;
-
- for (int i = 0; i < 3; i++) {
- try {
- if (_connectionUseTor) {
- // This POSTs the view key. Without Tor it does not go out at all.
- if (!await TorService.sharedInstance.waitUntilConnected()) {
- throw Exception('Tor is required for this connection but is unavailable.');
- }
-
- final proxyInfo = TorService.sharedInstance.getProxyInfo();
- final response = await makeSocksHttpRequest(
- 'POST',
- url.toString(),
- proxyInfo,
- body: body,
- ).timeout(Duration(seconds: 20));
-
- httpStatus = response.statusCode;
- } else {
- final response = await http
- .post(url, headers: {'Content-Type': 'application/json'}, body: body)
- .timeout(Duration(seconds: 5));
-
- httpStatus = response.statusCode;
- }
-
- break;
- } catch (e) {
- if (i == 2) {
- log(LogLevel.warn, 'Failed to check subaddress support after ${i + 1} attempts.');
- log(LogLevel.warn, 'Error: $e');
-
- rethrow;
- }
- }
- }
-
- final result = httpStatus == 200;
-
- log(
- LogLevel.info,
- 'Subaddress support check result for subaddress $subaddrIndex: $result (status: $httpStatus)',
- );
-
- return result;
- }
-
- Future<void> refresh() async {
- if (_w2Wallet == null || _w2TxHistory == null || !_daemonInitialized) return;
- final walletFfiAddr = _w2Wallet!.ffiAddress();
- final historyFfiAddr = _w2TxHistory!.ffiAddress();
-
- if (isNodeMode) {
- // The background refresh thread does the block scanning; just keep it
- // running and pull the latest tx-history view from the wallet's cache.
- log(LogLevel.info, 'Ensuring full-node refresh thread + history refresh');
- await Isolate.run(
- // ignore: deprecated_member_use
- () => monero.Wallet_startRefresh(Pointer.fromAddress(walletFfiAddr)),
- );
- await Isolate.run(
- // ignore: deprecated_member_use
- () => monero.TransactionHistory_refresh(Pointer.fromAddress(historyFfiAddr)),
- );
- return;
- }
-
- log(LogLevel.info, 'Calling Wallet_refresh and TransactionHistory_refresh');
-
- await Isolate.run(
- // ignore: deprecated_member_use
- () => monero.Wallet_refresh(Pointer.fromAddress(walletFfiAddr)),
- );
-
- await Isolate.run(
- // ignore: deprecated_member_use
- () => monero.TransactionHistory_refresh(Pointer.fromAddress(historyFfiAddr)),
- );
-
- log(LogLevel.info, 'Wallet refresh methods completed successfully');
- }
-
- Future<(String, int)> create() async {
- // ignore: deprecated_member_use
- final polyseed = await Isolate.run(() => monero.Wallet_createPolyseed());
- log(LogLevel.info, 'Wallet_createPolyseed completed');
- final restoreHeight = getHeightByDate(date: DateTime.now());
- log(LogLevel.info, 'Using blockchain height: $restoreHeight');
-
- await restoreFromMnemonic(polyseed, restoreHeight, isNewWallet: true);
- await SharedPreferencesService.set<int>(
- SharedPreferencesKeys.walletRestoreHeight,
- restoreHeight,
- );
- refresh().then((_) => connectToDaemon().then((_) => store()));
-
- return (polyseed, restoreHeight);
- }
-
- Future<int> getRestoreHeight() async {
- log(LogLevel.info, 'Calling Wallet_getRefreshFromBlockHeight');
-
- var w2RestoreHeight = _w2Wallet!.getRefreshFromBlockHeight();
-
- log(LogLevel.info, 'Wallet_getRefreshFromBlockHeight result: $w2RestoreHeight');
-
- if (w2RestoreHeight > 0) {
- return w2RestoreHeight;
- }
-
- return await SharedPreferencesService.get<int>(SharedPreferencesKeys.walletRestoreHeight) ?? 0;
- }
-
- Future<int> getCurrentHeight() async {
- final wmFfiAddr = (await _walletManager()).ffiAddress();
-
- log(LogLevel.info, 'Calling WalletManager_blockchainHeight');
-
- final height = await Isolate.run(() {
- // ignore: deprecated_member_use
- return monero.WalletManager_blockchainHeight(Pointer.fromAddress(wmFfiAddr));
- });
-
- log(LogLevel.info, 'WalletManager_blockchainHeight result: $height');
- return height;
- }
-
- Future<MoneroWallet> _getWalletFromLegacySeed({
- required String mnemonic,
- required int restoreHeight,
- required String password,
- bool isDummy = false,
- }) async {
- if (!isDummy && password == '') {
- throw Exception('Password should not be empty.');
- }
-
- final wmFfiAddr = (await _walletManager()).ffiAddress();
- final walletPath = await resolveWalletPath();
-
- log(LogLevel.info, 'Calling WalletManager_recoveryWallet with parameters:');
- log(LogLevel.info, ' mnemonic: <hidden>');
- log(LogLevel.info, ' restoreHeight: $restoreHeight');
- log(LogLevel.info, ' password: <hidden>');
- log(LogLevel.info, ' path: $walletPath');
- log(LogLevel.info, ' isDummy: $isDummy');
-
- final walletFfiAddr = await Isolate.run(() {
- // ignore: deprecated_member_use
- return monero.WalletManager_recoveryWallet(
- Pointer.fromAddress(wmFfiAddr),
- mnemonic: mnemonic,
- seedOffset: '',
- restoreHeight: restoreHeight,
- password: password,
- path: isDummy ? '' : walletPath,
- ).address;
- });
-
- log(LogLevel.info, 'WalletManager_recoveryWallet completed');
-
- return MoneroWallet(Pointer<Void>.fromAddress(walletFfiAddr));
- }
-
- Future<MoneroWallet> _getWalletFromPolyseed({
- required String mnemonic,
- required int restoreHeight,
- required String password,
- bool isDummy = false,
- bool newWallet = true,
- }) async {
- if (!isDummy && password == '') {
- throw Exception('Password should not be empty.');
- }
-
- final wmFfiAddr = (await _walletManager()).ffiAddress();
- final walletPath = await resolveWalletPath();
-
- // `newWallet` is what tells the backend this seed has history to scan. With
- // it set, both backends ignore the restore height: wallet2 starts the scan
- // at the current chain tip and LWSF never asks the server to rescan, so a
- // restored wallet comes up empty.
- final walletFfiAddr = await Isolate.run(() {
- // ignore: deprecated_member_use
- return monero.WalletManager_createWalletFromPolyseed(
- Pointer.fromAddress(wmFfiAddr),
- mnemonic: mnemonic,
- seedOffset: '',
- restoreHeight: restoreHeight,
- path: isDummy ? '' : walletPath,
- password: password,
- newWallet: newWallet,
- kdfRounds: 1,
- ).address;
- });
-
- log(LogLevel.info, 'WalletManager_createWalletFromPolyseed completed');
-
- return MoneroWallet(Pointer<Void>.fromAddress(walletFfiAddr));
- }
-
- /// Removes the wallet files for the current mode (cache + `.keys` +
- /// `.address.txt`). The other mode's files are left alone.
- Future<void> _deleteWalletFilesForCurrentMode() async {
- final path = await resolveWalletPath();
-
- for (final p in [path, '$path.keys', '$path.address.txt']) {
- final file = File(p);
- if (await file.exists()) {
- log(LogLevel.warn, 'Removing existing wallet file before restore: $p');
- await file.delete();
- }
- }
- }
-
- /// Builds the wallet for [mnemonic] with the factory its seed format needs.
- /// A BIP39 mnemonic is converted to the equivalent legacy word list first.
- Future<MoneroWallet> _buildWalletFromMnemonic({
- required String mnemonic,
- required int restoreHeight,
- required String password,
- required bool isPolyseed,
- required bool isNewWallet,
- }) async {
- if (isPolyseed) {
- return _getWalletFromPolyseed(
- mnemonic: mnemonic,
- restoreHeight: restoreHeight,
- password: password,
- newWallet: isNewWallet,
- );
- }
-
- return _getWalletFromLegacySeed(
- mnemonic: bip39.validateMnemonic(mnemonic) ? getLegacySeedFromBip39(mnemonic) : mnemonic,
- restoreHeight: restoreHeight,
- password: password,
- );
- }
-
- /// Restores (or, with [isNewWallet], creates) a wallet from [mnemonic].
- /// [isNewWallet] must only be set for a seed generated right now: it tells
- /// the backend there is no history behind the seed, which skips the rescan
- /// from [restoreHeight].
- Future<void> restoreFromMnemonic(
- String mnemonic,
- int restoreHeight, {
- String passphrase = '',
- bool isNewWallet = false,
- }) async {
- final walletPassword = _desktopWalletPassword ?? genWalletPassword();
- final isPolyseed = Polyseed.isValidSeed(mnemonic);
-
- var wallet = await _buildWalletFromMnemonic(
- mnemonic: mnemonic,
- restoreHeight: restoreHeight,
- password: walletPassword,
- isPolyseed: isPolyseed,
- isNewWallet: isNewWallet,
- );
-
- // wallet2 refuses to recover onto an existing wallet file, which surfaces
- // as an unexplained restore failure the user can't get out of. Reaching
- // this means the seed itself was accepted (it's decoded before the file is
- // touched), so the mode's derived files can be cleared and the restore
- // retried. Not done when creating a wallet: there'd be no seed in hand to
- // recover a clobbered file from.
- if (!isNewWallet && wallet.errorString().contains('file already exists')) {
- log(LogLevel.warn, 'Restore hit an existing wallet file: ${wallet.errorString()}');
- await _deleteWalletFilesForCurrentMode();
-
- wallet = await _buildWalletFromMnemonic(
- mnemonic: mnemonic,
- restoreHeight: restoreHeight,
- password: walletPassword,
- isPolyseed: isPolyseed,
- isNewWallet: isNewWallet,
- );
- }
-
- final errorString = wallet.errorString();
-
- if (errorString.contains('word list failed verification') ||
- errorString.contains('Failed polyseed decode')) {
- throw Exception('Invalid mnemonic.');
- }
-
- // Restore fires an LWS rescan before connect; ignore its pre-connect error.
- const connectionErrors = ['No response from HTTP server', 'Invalid argument'];
- final isConnectionError = connectionErrors.any(errorString.contains);
-
- if ((errorString != '' || wallet.status() != 0) && !isConnectionError) {
- log(LogLevel.error, 'Error restoring from mnemonic: $errorString');
- throw Exception('Error restoring from mnemonic: $errorString');
- }
-
- _w2Wallet = wallet;
- _w2TxHistory = _w2Wallet!.history();
- _loadedType = _desiredManagerType;
-
- if (!isNewWallet && restoreHeight > 0) {
- // wallet2's polyseed factory derives the scan start from the seed's
- // birthday and drops the height handed to it, so apply it here. The other
- // factories already took it: LWSF's polyseed path honours it once
- // newWallet is false, and both recoveryWallet implementations set it.
- if (isPolyseed && isNodeMode) {
- log(LogLevel.info, 'Setting refresh from block height: $restoreHeight');
- wallet.setRefreshFromBlockHeight(refresh_from_block_height: restoreHeight);
- }
-
- // Fallback for getRestoreHeight(), which needs a height to rebuild the
- // other mode's wallet file from the seed on an LWS↔node switch.
- await SharedPreferencesService.set<int>(
- SharedPreferencesKeys.walletRestoreHeight,
- restoreHeight,
- );
- }
-
- // Whatever this seed already has on chain is history, not news — a restore
- // would otherwise announce every incoming transaction it scans.
- await markExistingTxsAsNotified();
-
- if (Platform.isAndroid || Platform.isIOS) {
- await storeMobileWalletPassword(walletPassword);
- }
-
- await store();
- notifyListeners();
- }
-
- Future<void> openExisting({String? desktopWalletPassword}) async {
- // Opening the same file twice leaves two wallets (and two sync loops)
- // running against it; the first is never closed and both write the cache.
- if (_w2Wallet != null && _loadedType == _desiredManagerType) {
- log(LogLevel.warn, 'Wallet is already open for "$_loadedType"; skipping re-open.');
- return;
- }
-
- final wm = await _walletManager();
- final path = await resolveWalletPath();
-
- if (desktopWalletPassword != null) {
- _desktopWalletPassword = desktopWalletPassword;
- }
-
- final password = desktopWalletPassword ?? await getMobileWalletPassword();
-
- if (password == null) {
- final errorMsg = 'Failed to open existing wallet: could not get password.';
- log(LogLevel.error, errorMsg);
- throw Exception(errorMsg);
- }
-
- log(LogLevel.info, 'Calling WalletManager_openWallet with parameters:');
- log(LogLevel.info, ' path: $path');
- log(LogLevel.info, ' password: <hidden>');
-
- final w2Wallet = wm.openWallet(path: path, password: password);
-
- if (w2Wallet.errorString() != '') {
- final errorMsg = 'WalletManager_openWallet error: ${w2Wallet.errorString()}';
- log(LogLevel.error, errorMsg);
- throw Exception(errorMsg);
- }
-
- log(LogLevel.info, 'WalletManager_openWallet completed');
-
- _w2Wallet = w2Wallet;
- _w2TxHistory = _w2Wallet!.history();
- _loadedType = _desiredManagerType;
-
- notifyListeners();
-
- // Show last known balances + tx list (from the wallet cache) immediately
- // while the sync catches up.
- await loadCachedStats();
- }
-
- /// True when the open wallet was loaded for a different mode than the current
- /// connection needs (e.g. user switched LWS↔node) and must be re-opened.
- bool needsRebuildForCurrentConnection() =>
- _w2Wallet != null && _loadedType != null && _loadedType != _desiredManagerType;
-
- /// Applies a connection change made via the settings form: rebuilds the open
- /// wallet for the new mode if the server kind changed, then (re)syncs.
- Future<void> applyConnectionChange() async {
- if (needsRebuildForCurrentConnection()) {
- await _rebuildForConnectionType();
- }
- await load();
- }
-
- /// Re-opens the wallet for the current (newly-selected) mode. LWS and node
- /// keep separate cache files sharing the same keys/seed; if the target file
- /// doesn't exist yet it's recovered from the open wallet's seed.
- Future<void> _rebuildForConnectionType() async {
- // Both mode files share the same password; reuse the existing one so a
- // later switch back can still decrypt the other file.
- final password = _desktopWalletPassword ?? await getMobileWalletPassword();
- if (password == null) {
- throw Exception('Cannot rebuild wallet for new connection: no password.');
- }
-
- // Extract the seed + restore height while the old-mode wallet is still open.
- final polyseed = _w2Wallet!.getPolyseed(passphrase: '');
- final legacySeed = _w2Wallet!.seed(seedOffset: '');
- final mnemonic = polyseed.isNotEmpty ? polyseed : legacySeed;
- final restoreHeight = await getRestoreHeight();
-
- _daemonTargetHeight = null;
- _lastDaemonHeightFetch = null;
-
- final targetPath = await resolveWalletPath();
- final targetExists = await File(targetPath).exists();
-
- if (targetExists) {
- // _walletManager() (via openExisting) closes the old wallet + swaps factory.
- await openExisting(desktopWalletPassword: password);
- } else {
- // Recover the target-mode file from the shared seed. Force the existing
- // password so restoreFromMnemonic doesn't mint a new random one (which
- // would desync the two mode files). restoreFromMnemonic resolves the
- // target path and rebuilds the manager for the new mode.
- _desktopWalletPassword = password;
- await restoreFromMnemonic(mnemonic, restoreHeight);
- }
- }
-
- /// Stops the native scan thread and checkpoints what it managed to scan.
- ///
- /// Background isolates call this before they finish. Nothing closes the
- /// wallet when a background task returns, so a refresh left running keeps
- /// pulling blocks past the end of the task, and everything scanned since the
- /// last periodic checkpoint would go with the isolate.
- Future<void> pauseSyncAndStore() async {
- if (_w2Wallet == null || !_daemonInitialized) return;
-
- log(LogLevel.info, 'Pausing refresh and storing the wallet');
-
- // Sets the refresh-enabled flag; the scan thread stops at its next check.
- _w2Wallet!.pauseRefresh();
-
- await store();
- }
-
- Future<bool> store() async {
- final walletFfiAddr = _w2Wallet!.ffiAddress();
-
- log(LogLevel.info, 'Calling Wallet_store');
-
- final result = await Isolate.run(
- // ignore: deprecated_member_use
- () => monero.Wallet_store(Pointer<Void>.fromAddress(walletFfiAddr)),
- );
-
- log(LogLevel.info, 'Wallet_store result: $result');
- return result;
- }
-
- Future delete() async {
- await _closeOpenWallet();
-
- _hasAttemptedConnection = false;
- _isConnected = false;
- _isSynced = false;
- _syncedHeight = null;
- _unlockedBalance = null;
- _totalBalance = null;
- _txHistory = [];
-
- // Remove both mode files (LWS `mywallet*` and node `mywallet_node*`) plus
- // the companion `.keys` / `.address.txt` Monero writes alongside each.
- final base = await getWalletPath();
- for (final b in {base, '${base}_node'}) {
- for (final p in [b, '$b.keys', '$b.address.txt']) {
- final file = File(p);
- if (await file.exists()) await file.delete();
- }
- }
-
- await SharedPreferencesService.remove(SharedPreferencesKeys.connectionType);
- await clearTxNotificationState();
- await SharedPreferencesService.remove(SharedPreferencesKeys.walletRestoreHeight);
- await SharedPreferencesService.remove(SharedPreferencesKeys.appLockEnabled);
- await SharedPreferencesService.remove(SharedPreferencesKeys.serverSupportsSubaddresses);
- await clearContacts();
- await SharedPreferencesService.remove(SharedPreferencesKeys.unusedSubaddressIndex);
- await SharedPreferencesService.remove(SharedPreferencesKeys.unusedSubaddressIndexIsSupported);
- }
-
- Future<bool> hasExistingWallet() async {
- // Each mode keeps its own file in a format only its own manager recognizes
- // (LWSF's `mywallet` vs wallet2's `mywallet_node`), so the persisted
- // connection has to be known before looking one up — checking the default
- // LWS path makes a node wallet look like a fresh install and drops the user
- // back into onboarding.
- await _ensureConnectionLoaded();
-
- final path = await resolveWalletPath();
-
- log(LogLevel.info, 'Calling WalletManager_walletExists with parameters:');
- log(LogLevel.info, ' path: $path');
-
- final wm = await _walletManager();
- final exists = wm.walletExists(path);
-
- log(LogLevel.info, 'WalletManager_walletExists result: $exists');
-
- final errorString = wm.errorString();
-
- if (errorString != '') {
- log(LogLevel.error, 'WalletManager_walletExists error: $errorString');
- }
-
- if (exists) {
- return true;
- }
-
- return _adoptModeWithExistingWallet();
- }
-
- /// Recovers from a connection/wallet-file mismatch: when the persisted mode
- /// has no wallet file but the other mode does (e.g. an LWS↔node switch that
- /// was interrupted before the new file was written), switch to the mode we
- /// actually have a wallet for instead of reporting "no wallet" and sending
- /// the user through onboarding on top of an existing wallet.
- Future<bool> _adoptModeWithExistingWallet() async {
- final otherType = isNodeMode ? 'lws' : 'node';
- final otherPath = await walletPathForType(otherType);
-
- // wallet2 (node) writes `<path>` plus `<path>.keys`; LWSF writes a single
- // `<path>` file.
- final otherExists = await File(otherPath).exists() || await File('$otherPath.keys').exists();
-
- if (!otherExists) {
- return false;
- }
-
- log(
- LogLevel.warn,
- 'No "$_connectionType" wallet file found but a "$otherType" one exists; '
- 'switching the connection type to match.',
- );
-
- _connectionType = otherType;
- // Persisted, not just in-memory: callers reload the connection from prefs
- // right after this check.
- await SharedPreferencesService.set<String>(SharedPreferencesKeys.connectionType, otherType);
- notifyListeners();
-
- return true;
- }
-
- Future<bool> getIsConnected() async {
- if (_w2Wallet == null || !_daemonInitialized) return false;
- final w2WalletFfiAddr = _w2Wallet!.ffiAddress();
-
- final connected = await Isolate.run(
- // ignore: deprecated_member_use
- () => monero.Wallet_connected(Pointer<Void>.fromAddress(w2WalletFfiAddr)),
- );
-
- return connected != 0;
- }
-
- Future<void> loadIsSynced() async {
- if (_w2Wallet == null || !_daemonInitialized) return;
- final walletFfiAddr = _w2Wallet!.ffiAddress();
-
- log(LogLevel.info, 'Calling Wallet_synchronized:');
-
- final synced = await Isolate.run(
- () =>
- // ignore: deprecated_member_use
- monero.Wallet_synchronized(Pointer<Void>.fromAddress(walletFfiAddr)),
- );
-
- log(LogLevel.info, 'Wallet_synchronized result: $synced');
-
- if (isNodeMode && !synced) {
- // Wallet height is local/cheap — read every poll. The daemon tip is a
- // network RPC, so only refresh it every 30s to avoid stealing the
- // circuit/lock from the scan.
- final now = DateTime.now();
- if (_lastDaemonHeightFetch == null ||
- now.difference(_lastDaemonHeightFetch!) >= const Duration(seconds: 30)) {
- _lastDaemonHeightFetch = now;
- _daemonTargetHeight = await Isolate.run(
- // ignore: deprecated_member_use
- () => monero.Wallet_daemonBlockChainHeight(Pointer<Void>.fromAddress(walletFfiAddr)),
- );
- log(LogLevel.info, 'Daemon target height: $_daemonTargetHeight');
- }
- }
-
- _isSynced = synced;
- }
-
- Future<void> loadSyncedHeight() async {
- if (_w2Wallet == null || !_daemonInitialized) return;
- final walletFfiAddr = _w2Wallet!.ffiAddress();
-
- log(LogLevel.info, 'Calling Wallet_blockChainHeight:');
-
- _syncedHeight = await Isolate.run(
- // ignore: deprecated_member_use
- () => monero.Wallet_blockChainHeight(Pointer<Void>.fromAddress(walletFfiAddr)),
- );
-
- log(LogLevel.info, 'Wallet_blockChainHeight result: $_syncedHeight');
- }
-
- Future<void> loadTotalBalance() async {
- final walletFfiAddr = _w2Wallet!.ffiAddress();
-
- final accountIndex = 0;
-
- log(LogLevel.info, 'Calling Wallet_balance with parameters:');
- log(LogLevel.info, ' accountIndex: $accountIndex');
-
- final amount = await Isolate.run(
- // ignore: deprecated_member_use
- () => monero.Wallet_balance(
- Pointer<Void>.fromAddress(walletFfiAddr),
- accountIndex: accountIndex,
- ),
- );
-
- log(LogLevel.info, 'Wallet_balance result: $amount');
-
- _totalBalance = doubleAmountFromInt(amount);
- }
-
- Future<void> loadUnlockedBalance() async {
- final walletFfiAddr = _w2Wallet!.ffiAddress();
-
- final accountIndex = 0;
-
- log(LogLevel.info, 'Calling Wallet_unlockedBalance with parameters:');
- log(LogLevel.info, ' accountIndex: $accountIndex');
-
- final amount = await Isolate.run(
- // ignore: deprecated_member_use
- () => monero.Wallet_unlockedBalance(
- Pointer<Void>.fromAddress(walletFfiAddr),
- accountIndex: accountIndex,
- ),
- );
-
- log(LogLevel.info, 'Wallet_unlockedBalance result: $amount');
-
- _unlockedBalance = doubleAmountFromInt(amount);
- }
-
- String getPrimaryAddress() {
- final accountIndex = 0;
-
- log(LogLevel.info, 'Calling Wallet_address with parameters:');
- log(LogLevel.info, ' accountIndex: $accountIndex');
-
- final address = _w2Wallet!.address(accountIndex: accountIndex);
-
- log(LogLevel.info, 'Wallet_address result: $address');
-
- return address;
- }
-
- String? getUnusedSubaddress() {
- if (_unusedSubaddressIndex == null) {
- return null;
- }
-
- var subaddrIndex = _unusedSubaddressIndex!;
-
- if (_unusedSubaddressIndexIsSupported == false) {
- subaddrIndex -= 1;
- }
-
- log(LogLevel.info, 'Calling Wallet_address with parameters:');
- log(LogLevel.info, ' accountIndex: 0');
- log(LogLevel.info, ' addressIndex: $subaddrIndex');
-
- final subaddress = _w2Wallet!.address(accountIndex: 0, addressIndex: subaddrIndex);
-
- log(LogLevel.info, 'Wallet_address result: $subaddress');
-
- return subaddress;
- }
-
- @override
- String? getReceiveAddress() => getUnusedSubaddress() ?? getPrimaryAddress();
-
- @override
- Future<String> readSecretViewKey() async => _w2Wallet?.secretViewKey() ?? '';
- @override
- Future<String> readSecretSpendKey() async => _w2Wallet?.secretSpendKey() ?? '';
- @override
- Future<String> readPublicViewKey() async => _w2Wallet?.publicViewKey() ?? '';
- @override
- Future<String> readPublicSpendKey() async => _w2Wallet?.publicSpendKey() ?? '';
- @override
- Future<String> readLegacySeed() async => _w2Wallet?.seed(seedOffset: '') ?? '';
- @override
- Future<String> readPolyseed() async => _w2Wallet?.getPolyseed(passphrase: '') ?? '';
- @override
- Future<StoredSeed?> readStoredSeed() async => null;
-
- /// Estimates the network fee (in piconero) for a send at [priority] via the
- /// native estimator. Returns null on failure or when fee info isn't cached yet
- bool isAddressValid(String address) => _w2Wallet?.addressValid(address, 0) ?? false;
-
- Future<int?> estimateFee(
- String destinationAddress,
- double amount, {
- int priority = 0,
- String? amountText,
- }) async {
- if (_w2Wallet == null) return null;
-
- final amountInt = amountText != null
- ? decimalToBaseUnits(amountText, consts.moneroDecimals).toInt()
- : _w2Wallet!.amountFromDouble(amount);
- final walletFfiAddr = _w2Wallet!.ffiAddress();
-
- try {
- final fee = await Isolate.run(() {
- // ignore: deprecated_member_use
- return monero.Wallet_estimateTransactionFee(
- Pointer.fromAddress(walletFfiAddr),
- dstAddr: [destinationAddress],
- amounts: [amountInt],
- pendingTransactionPriority: priority,
- );
- });
- // 0 = backend couldn't estimate (never a real fee).
- return fee > 0 ? fee : null;
- } catch (e) {
- log(LogLevel.warn, 'estimateFee failed: $e');
- return null;
- }
- }
-
- Future<AppPendingTx> createTx(
- String destinationAddress,
- double amount,
- bool isSweepAll, {
- int priority = 0,
- String? amountText,
- }) async {
- // Convert the exact decimal string when available; going through double
- // loses precision. Falls back to amountFromDouble when no text is given.
- final amountInt = amountText != null
- ? decimalToBaseUnits(amountText, consts.moneroDecimals).toInt()
- : _w2Wallet!.amountFromDouble(amount);
- final w2WalletFfiAddr = _w2Wallet!.ffiAddress();
-
- final dstAddr = [destinationAddress];
- final amounts = [amountInt];
- final mixinCount = 15;
- final subaddrAccount = 0;
-
- log(LogLevel.info, 'Calling Wallet_createTransactionMultDest with parameters:');
- log(LogLevel.info, ' w2WalletFfiAddr: $w2WalletFfiAddr');
- log(LogLevel.info, ' isSweepAll: $isSweepAll');
- log(LogLevel.info, ' dstAddr: $dstAddr');
- log(LogLevel.info, ' amounts: $amounts');
- log(LogLevel.info, ' mixinCount: $mixinCount');
- log(LogLevel.info, ' pendingTransactionPriority: $priority');
- log(LogLevel.info, ' subaddr_account: $subaddrAccount');
-
- final txPointer = Pointer<Void>.fromAddress(
- await Isolate.run(() {
- // ignore: deprecated_member_use
- return monero.Wallet_createTransactionMultDest(
- Pointer.fromAddress(w2WalletFfiAddr),
- isSweepAll: isSweepAll,
- dstAddr: dstAddr,
- amounts: amounts,
- mixinCount: mixinCount,
- pendingTransactionPriority: priority,
- subaddr_account: subaddrAccount,
- ).address;
- }),
- );
-
- log(LogLevel.info, 'Wallet_createTransactionMultDest completed');
-
- final pendingTx = MoneroPendingTransaction(txPointer);
-
- if (pendingTx.errorString() != '') {
- log(LogLevel.error, 'Failed to create transaction: ${pendingTx.errorString()}');
- throw Exception(pendingTx.errorString());
- }
-
- return _MoneroPendingTx(pendingTx);
- }
-
- Future<void> commitTx(AppPendingTx tx, String destinationAddress) async {
- final raw = (tx as _MoneroPendingTx).raw;
- final txFfiAddr = raw.ffiAddress();
-
- final filename = '';
- final overwrite = false;
-
- log(LogLevel.info, 'Calling PendingTransaction_commit with parameters:');
- log(LogLevel.info, ' filename: $filename');
- log(LogLevel.info, ' overwrite: $overwrite');
-
- final commitResult = await Isolate.run(() {
- // ignore: deprecated_member_use
- return monero.PendingTransaction_commit(
- Pointer.fromAddress(txFfiAddr),
- filename: '',
- overwrite: false,
- );
- });
-
- final status = raw.status();
- log(LogLevel.info, 'PendingTransaction_commit result: $commitResult, status: $status');
-
- final errorMsg = raw.errorString();
-
- if (errorMsg != '' && errorMsg != 'Schema expected string') {
- log(LogLevel.error, 'PendingTransaction_commit error: $errorMsg');
- throw FormatException(errorMsg);
- }
-
- // The broadcast can fail without setting errorString; gate success on the
- // commit result and status too so we don't report a send that didn't happen.
- if (!commitResult || status != 0) {
- log(LogLevel.error, 'PendingTransaction_commit failed: result=$commitResult status=$status');
- throw FormatException('Failed to broadcast transaction.');
- }
-
- await refresh();
- // Persist so the just-sent unconfirmed tx (held in the wallet's cache, not
- // on-chain yet) survives an app restart before it's mined.
- await store();
- // Reload balances so the spent/locked amount is reflected right away.
- await Future.wait([loadTotalBalance(), loadUnlockedBalance()]);
- await loadTxHistory();
- notifyListeners();
- }
-
- /// Resolves an OpenAlias (an FQDN or an email-style `name@domain`) to a
- /// Monero address with end-to-end DNSSEC validation, over Tor (via the
- /// wallet_openalias Rust/hickory resolver).
- ///
- /// OpenAlias v2 records are preferred and v1 is the fallback, per the spec's
- /// compatibility rule. Returns null on any failure (incl. Tor unavailable) so
- /// the send flow surfaces a resolve error. OpenAlias never leaves Tor.
- Future<ResolvedOpenAlias?> resolveOpenAlias(String alias) async {
- log(LogLevel.info, 'Resolving OpenAlias over Tor: $alias');
-
- try {
- // No proxy, no lookup. With Tor disabled, still bootstrapping, or
- // misconfigured, resolution fails here — there is no clearnet fallback.
- final proxy = await TorSettingsService.sharedInstance.getProxy();
- if (proxy == null) {
- log(LogLevel.warn, 'OpenAlias: Tor proxy unavailable; cannot resolve.');
- return null;
- }
-
- final result = await OpenAliasFfi.resolve(
- alias: alias,
- // Monero mainnet is `network=xmr`, whose native asset is `xmr` per the
- // OA2 network list — so a v2 record that omits `asset` is XMR too.
- network: 'xmr',
- asset: 'xmr',
- nativeAsset: 'xmr',
- socksPort: proxy.port,
- );
-
- final resolved = result.payment.address;
-
- // A record can publish any string at all, so nothing downstream — the tx
- // builder, the confirm screen — ever sees one that isn't a valid Monero
- // address for this network. No wallet to check against means no answer.
- final wallet = _w2Wallet;
- if (wallet == null || !wallet.addressValid(resolved, 0)) {
- log(LogLevel.warn, 'OpenAlias: resolved address failed validation.');
- return null;
- }
-
- log(LogLevel.info, 'OpenAlias resolved successfully (v${result.version}).');
- return ResolvedOpenAlias(
- address: resolved,
- version: result.version,
- recipientName: _openAliasDisplayName(result.recipientName),
- );
- } catch (e) {
- log(LogLevel.warn, 'OpenAlias resolution failed: $e');
- return null;
- }
- }
-
- /// A recipient-published name is only ever shown, never trusted. Strip
- /// control characters and the Unicode formatting characters that can reorder
- /// or hide what is drawn (bidi overrides/isolates, zero-width marks),
- /// collapse whitespace runs, and cap the length, so a crafted name can't
- /// misrepresent or disrupt the confirm screen.
- static String? _openAliasDisplayName(String? name) {
- if (name == null) return null;
-
- final cleaned = name
- .replaceAll(_unsafeDisplayChars, ' ')
- .replaceAll(RegExp(r'\s+'), ' ')
- .trim();
- if (cleaned.isEmpty) return null;
- if (cleaned.length <= 64) return cleaned;
-
- var clipped = cleaned.substring(0, 63);
- final last = clipped.codeUnitAt(clipped.length - 1);
- // Don't leave half a surrogate pair behind when cutting.
- if (last >= 0xd800 && last <= 0xdbff) {
- clipped = clipped.substring(0, clipped.length - 1);
- }
- return '$clipped…';
- }
-
- static final RegExp _unsafeDisplayChars = RegExp(
- r'[\x00-\x1f\x7f-\x9f\u200b-\u200f\u202a-\u202e\u2066-\u2069\ufeff]',
- );
-
- List<TxDetails> _getTxHistory() {
- final txCount = _w2TxHistory!.count();
- final List<TxDetails> txs = [];
-
- for (int i = 0; i < txCount; i++) {
- final tx = getTxDetails(i);
- txs.add(tx);
- }
-
- txs.sort((a, b) {
- return a.timestamp < b.timestamp ? 1 : -1;
- });
-
- return txs;
- }
-
- TxDetails getTxDetails(int txIndex) {
- final tx = _w2TxHistory!.transaction(txIndex);
- final direction = tx.direction();
- final hash = tx.hash();
- final amountSent = doubleAmountFromInt(tx.amount());
- final fee = doubleAmountFromInt(tx.fee());
- final timestamp = tx.timestamp();
- final height = tx.blockHeight();
- final confirmations = height > -1 ? _w2Wallet!.blockChainHeight() - height + 1 : 0;
- final key = _w2Wallet!.getTxKey(txid: hash);
-
- List<TxRecipient> recipients = [];
- final recipientsCount = tx.transfers_count();
- final accountIndex = tx.subaddrAccount();
- final subaddrIndexList = tx
- .subaddrIndex()
- .split(", ")
- .map((e) => int.tryParse(e) ?? 0)
- .toList();
-
- for (int i = 0; i < recipientsCount; i++) {
- final address = tx.transfers_address(i);
- final amountInt = tx.transfers_amount(i);
- final amount = doubleAmountFromInt(amountInt);
- recipients.add(TxRecipient(address, amount));
- }
-
- return TxDetails(
- index: txIndex,
- direction: direction,
- hash: hash,
- amount: amountSent,
- fee: fee,
- recipients: recipients,
- accountIndex: accountIndex,
- subaddrIndexList: subaddrIndexList,
- timestamp: timestamp,
- height: height,
- confirmations: confirmations,
- key: key,
- );
- }
-}
diff --git a/lib/models/wallet_types.dart b/lib/models/wallet_types.dart
new file mode 100644
index 0000000..f78791a
--- /dev/null
+++ b/lib/models/wallet_types.dart
@@ -0,0 +1,108 @@
+// Neutral wallet display types, shared across screens and the wallet engine.
+// These used to live in wallet_model.dart; they moved here when the legacy
+// WalletModel engine was removed and the app went wallet-core-only.
+
+/// A validated OpenAlias resolution: the Monero address to pay, plus what the
+/// recipient published about themselves, for the confirm screen.
+class ResolvedOpenAlias {
+ ResolvedOpenAlias({required this.address, required this.version, this.recipientName});
+
+ final String address;
+
+ /// 1 if this came from an `oa1:xmr` record, 2 from `_openalias-payment`.
+ final int version;
+
+ /// The recipient's display name, if they published one. Display only — it has
+ /// no bearing on [address].
+ final String? recipientName;
+}
+
+class TxDetails {
+ final int? index;
+ final int direction;
+ final String hash;
+ final double amount;
+ final double fee;
+ final List<TxRecipient> recipients;
+ final int? accountIndex;
+ final List<int> subaddrIndexList;
+ final int timestamp;
+ final int height;
+ final int confirmations;
+ final String key;
+
+ TxDetails({
+ required this.index,
+ required this.direction,
+ required this.hash,
+ required this.amount,
+ required this.fee,
+ required this.recipients,
+ required this.accountIndex,
+ required this.subaddrIndexList,
+ required this.timestamp,
+ required this.height,
+ required this.confirmations,
+ required this.key,
+ });
+
+ Map<String, dynamic> toJson() => {
+ 'index': index,
+ 'direction': direction,
+ 'hash': hash,
+ 'amount': amount,
+ 'fee': fee,
+ 'recipients': recipients.map((r) => r.toJson()).toList(),
+ 'accountIndex': accountIndex,
+ 'subaddrIndexList': subaddrIndexList,
+ 'timestamp': timestamp,
+ 'height': height,
+ 'confirmations': confirmations,
+ 'key': key,
+ };
+
+ factory TxDetails.fromJson(Map<String, dynamic> json) => TxDetails(
+ index: json['index'] as int?,
+ direction: json['direction'] as int,
+ hash: json['hash'] as String,
+ amount: (json['amount'] as num).toDouble(),
+ fee: (json['fee'] as num).toDouble(),
+ recipients: (json['recipients'] as List<dynamic>)
+ .map((r) => TxRecipient.fromJson(r as Map<String, dynamic>))
+ .toList(),
+ accountIndex: json['accountIndex'] as int?,
+ subaddrIndexList: (json['subaddrIndexList'] as List<dynamic>).cast<int>(),
+ timestamp: json['timestamp'] as int,
+ height: json['height'] as int,
+ confirmations: json['confirmations'] as int,
+ key: json['key'] as String,
+ );
+}
+
+class TxRecipient {
+ final String address;
+ final double amount;
+
+ TxRecipient(this.address, this.amount);
+
+ Map<String, dynamic> toJson() => {'address': address, 'amount': amount};
+
+ factory TxRecipient.fromJson(Map<String, dynamic> json) =>
+ TxRecipient(json['address'] as String, (json['amount'] as num).toDouble());
+}
+
+class LWSConnectionDetails {
+ final String address;
+ final String proxyPort;
+ final bool useTor;
+ final bool useSsl;
+ final String connectionType;
+
+ LWSConnectionDetails({
+ required this.address,
+ required this.proxyPort,
+ required this.useTor,
+ required this.useSsl,
+ this.connectionType = 'lws',
+ });
+}
diff --git a/lib/screens/address_book.dart b/lib/screens/address_book.dart
index 0d31cf8..d5f7d49 100644
--- a/lib/screens/address_book.dart
+++ b/lib/screens/address_book.dart
@@ -1,9 +1,9 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
-import 'package:monero/monero.dart' as monero;
import 'package:skylight_wallet/l10n/app_localizations.dart';
import 'package:skylight_wallet/models/contact_model.dart';
+import 'package:skylight_wallet/wallet_core_glue.dart';
import 'package:skylight_wallet/widgets/wallet_navigation_bar.dart';
class AddressBookScreen extends StatefulWidget {
@@ -287,8 +287,7 @@ class _ContactDialogState extends State<_ContactDialog> {
return i18n.fieldEmptyError;
}
- // ignore: deprecated_member_use
- if (!monero.Wallet_addressValid(value.trim(), 0)) {
+ if (!appWalletOf(context).isAddressValid(value.trim())) {
return i18n.sendInvalidAddressError;
}
return null;
diff --git a/lib/screens/send.dart b/lib/screens/send.dart
index 505dbe1..b0c0279 100644
--- a/lib/screens/send.dart
+++ b/lib/screens/send.dart
@@ -12,7 +12,7 @@ import 'package:skylight_wallet/util/formatting.dart';
import 'package:skylight_wallet/widgets/fiat_amount.dart';
import 'package:skylight_wallet/widgets/loading_button.dart';
import 'package:skylight_wallet/widgets/monero_amount.dart';
-import 'package:skylight_wallet/models/wallet_model.dart';
+import 'package:skylight_wallet/models/wallet_types.dart';
import 'package:skylight_wallet/wallet_core_glue.dart';
import 'package:skylight_wallet/models/contact_model.dart';
diff --git a/lib/screens/wallet_home.dart b/lib/screens/wallet_home.dart
index 3473224..24285ec 100644
--- a/lib/screens/wallet_home.dart
+++ b/lib/screens/wallet_home.dart
@@ -11,7 +11,7 @@ import 'package:provider/provider.dart';
import 'package:skeletonizer/skeletonizer.dart';
import 'package:timeago/timeago.dart' as timeago;
import 'package:skylight_wallet/l10n/app_localizations.dart';
-import 'package:skylight_wallet/models/wallet_model.dart' show TxDetails;
+import 'package:skylight_wallet/models/wallet_types.dart' show TxDetails;
import 'package:skylight_wallet/models/app_wallet.dart';
import 'package:skylight_wallet/wallet_core_glue.dart';
import 'package:skylight_wallet/consts.dart' as consts;
diff --git a/lib/util/tx_notifications.dart b/lib/util/tx_notifications.dart
index 0e47056..06aed28 100644
--- a/lib/util/tx_notifications.dart
+++ b/lib/util/tx_notifications.dart
@@ -1,4 +1,4 @@
-import 'package:skylight_wallet/models/wallet_model.dart';
+import 'package:skylight_wallet/models/wallet_types.dart';
import 'package:skylight_wallet/consts.dart' as consts;
/// How many announced transaction hashes are remembered. The cutoff covers
diff --git a/lib/wallet_core_glue.dart b/lib/wallet_core_glue.dart
index b35d076..b1e8692 100644
--- a/lib/wallet_core_glue.dart
+++ b/lib/wallet_core_glue.dart
@@ -6,7 +6,6 @@ import 'package:provider/provider.dart';
import 'package:skylight_wallet/models/app_wallet.dart';
import 'package:skylight_wallet/models/monero_wallet_adapter.dart';
-import 'package:skylight_wallet/models/wallet_model.dart';
import 'package:skylight_wallet/services/notifications_service.dart';
import 'package:skylight_wallet/services/shared_preferences_service.dart';
import 'package:skylight_wallet/util/logging.dart';
@@ -23,9 +22,6 @@ import 'package:wallet_domain/wallet_domain.dart'
import 'package:wallet_monero/wallet_monero.dart' show MoneroWallet;
import 'package:wallet_openalias/wallet_openalias.dart' show resolveOpenAlias;
-/// Phase 6 flag: route the wallet layer through wallet-core.
-const useSharedWalletCore = bool.fromEnvironment('useSharedWalletCore');
-
const _moneroDecimals = 12;
bool get _isMobile => Platform.isAndroid || Platform.isIOS;
@@ -51,8 +47,8 @@ void installWalletCore() {
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.
+ // Desktop has no notifications toggle (it's Android/iOS-only), so it always
+ // shows an incoming-tx notification.
NotificationService().showIncomingTxNotification(amount);
return;
}
@@ -73,39 +69,20 @@ void installWalletCore() {
/// [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.
+/// sets it, the foreground service does not). 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);
- }
+ installWalletCore();
+ final manager = WalletManager(coins: () => [MoneroWallet()]);
+ if (!await manager.hasAnyExistingWallet()) return null;
- final wallet = WalletModel();
- if (!await wallet.hasExistingWallet()) return null;
- await wallet.loadPersistedConnection();
+ // 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,
@@ -115,8 +92,9 @@ Future<AppWallet?> openBackgroundWallet({
)) {
return null;
}
- await wallet.openExisting();
- return wallet;
+
+ await manager.openAll();
+ return MoneroWalletAdapter(wallet);
}
Future<bool> _shouldBackgroundSync({
@@ -137,7 +115,7 @@ Future<bool> _shouldBackgroundSync({
return true;
}
-/// WalletManager provider, gated. Coexists with WalletModel during migration.
+/// The wallet-core [WalletManager] provider.
ChangeNotifierProvider<WalletManager> walletManagerProvider() =>
ChangeNotifierProvider(create: (_) => WalletManager(coins: () => [MoneroWallet()]));
@@ -159,13 +137,9 @@ Future<bool> _loadExistingWalletManager(WalletManager manager) async {
final _adapters = Expando<MoneroWalletAdapter>('appWalletAdapter');
-/// The neutral [AppWallet] for the active stack: the shared-core adapter under
-/// the flag, else the legacy [WalletModel]. The adapter is cached per wallet so
-/// repeated lookups don't stack duplicate listeners.
+/// The neutral [AppWallet] for the XMR wallet. The adapter is cached per wallet
+/// so repeated lookups don't stack duplicate listeners.
AppWallet appWalletOf(BuildContext context, {bool listen = false}) {
- if (!useSharedWalletCore) {
- return Provider.of<WalletModel>(context, listen: listen);
- }
final manager = Provider.of<WalletManager>(context, listen: listen);
final wallet = manager.getWallet('XMR') as MoneroWallet;
return _adapters[wallet] ??= MoneroWalletAdapter(
@@ -181,11 +155,7 @@ AppWallet appWalletOf(BuildContext context, {bool listen = false}) {
/// Sets the wallet-encryption password (desktop-entered). Mobile mints a random
/// one at restore/create time instead.
void setWalletPassword(BuildContext context, String password) {
- if (useSharedWalletCore) {
- Provider.of<WalletManager>(context, listen: false).setWalletPassword(password);
- } else {
- Provider.of<WalletModel>(context, listen: false).setWalletPassword(password);
- }
+ Provider.of<WalletManager>(context, listen: false).setWalletPassword(password);
}
/// Restores the wallet from a mnemonic at [restoreHeight], then opens + syncs.
@@ -195,88 +165,53 @@ Future<void> restoreWallet(
required String mnemonic,
required int restoreHeight,
}) async {
- if (useSharedWalletCore) {
- final manager = Provider.of<WalletManager>(context, listen: false);
- final seed = SeedSource.detect(mnemonic);
- if (seed == null) throw Exception('Invalid mnemonic.');
- if (!manager.hasPassword) manager.useGeneratedPassword();
- await manager.restoreAll(seed: seed, from: RestorePoint.height(restoreHeight));
- manager.syncInBackground();
- } else {
- final wallet = Provider.of<WalletModel>(context, listen: false);
- await wallet.restoreFromMnemonic(mnemonic, restoreHeight);
- wallet.load();
- }
+ final manager = Provider.of<WalletManager>(context, listen: false);
+ final seed = SeedSource.detect(mnemonic);
+ if (seed == null) throw Exception('Invalid mnemonic.');
+ if (!manager.hasPassword) manager.useGeneratedPassword();
+ await manager.restoreAll(seed: seed, from: RestorePoint.height(restoreHeight));
+ manager.syncInBackground();
}
/// Creates a brand-new wallet, then opens + syncs. Returns its seed words and
/// restore height (for the seed-backup screen and the connection step).
Future<(String seed, int restoreHeight)> createWallet(BuildContext context) async {
- if (useSharedWalletCore) {
- final manager = Provider.of<WalletManager>(context, listen: false);
- if (!manager.hasPassword) manager.useGeneratedPassword();
- final generated = manager.generateSeed();
- await manager.restoreAll(seed: generated.seed, from: RestorePoint.date(generated.restoreDate));
- manager.syncInBackground();
- final height = await manager.getWallet('XMR')!.getRestoreHeight();
- return (generated.seed.mnemonic, height);
- } else {
- final wallet = Provider.of<WalletModel>(context, listen: false);
- final result = await wallet.create();
- wallet.load();
- return result;
- }
+ final manager = Provider.of<WalletManager>(context, listen: false);
+ if (!manager.hasPassword) manager.useGeneratedPassword();
+ final generated = manager.generateSeed();
+ await manager.restoreAll(seed: generated.seed, from: RestorePoint.date(generated.restoreDate));
+ manager.syncInBackground();
+ final height = await manager.getWallet('XMR')!.getRestoreHeight();
+ return (generated.seed.mnemonic, height);
}
/// Opens an already-existing wallet (used by the welcome safety-net). Returns
/// false when there is none. Mobile only — desktop unlocks with a password.
Future<bool> openExistingWallet(BuildContext context) async {
- if (useSharedWalletCore) {
- final manager = Provider.of<WalletManager>(context, listen: false);
- if (!await manager.hasAnyExistingWallet()) return false;
- manager.openWalletFilesAndSync();
- return true;
- }
- final wallet = Provider.of<WalletModel>(context, listen: false);
- if (!await wallet.hasExistingWallet()) return false;
- await wallet.loadPersistedConnection();
- await wallet.openExisting();
+ final manager = Provider.of<WalletManager>(context, listen: false);
+ if (!await manager.hasAnyExistingWallet()) return false;
+ manager.openWalletFilesAndSync();
return true;
}
/// Opens the wallet with a desktop-entered password, then syncs. Throws on a
/// wrong password (the unlock screen shows the error).
Future<void> unlockWithPassword(BuildContext context, String password) async {
- if (useSharedWalletCore) {
- final manager = Provider.of<WalletManager>(context, listen: false);
- await manager.openAll(password: password);
- manager.syncInBackground();
- } else {
- final wallet = Provider.of<WalletModel>(context, listen: false);
- await wallet.loadPersistedConnection();
- await wallet.openExisting(desktopWalletPassword: password);
- wallet.load();
- }
+ final manager = Provider.of<WalletManager>(context, listen: false);
+ await manager.openAll(password: password);
+ manager.syncInBackground();
}
/// Deletes the wallet and everything derived from it.
Future<void> deleteWallet(BuildContext context) async {
- if (useSharedWalletCore) {
- // TODO(wallet-core): pass skylight's own pref keys (contacts, pending tx,
- // notification state) once the flag-on delete path is validated on device.
- await Provider.of<WalletManager>(context, listen: false).deleteAll();
- } else {
- await Provider.of<WalletModel>(context, listen: false).delete();
- }
+ // TODO(wallet-core): pass skylight's own pref keys (contacts, pending tx,
+ // notification state) once the delete path is validated on device.
+ await Provider.of<WalletManager>(context, listen: false).deleteAll();
}
/// Rebuilds the wallet if the server kind (LWS↔node) changed, then resyncs.
void applyConnectionChange(BuildContext context) {
- if (useSharedWalletCore) {
- unawaited(Provider.of<WalletManager>(context, listen: false).applyConnectionChange('XMR'));
- } else {
- Provider.of<WalletModel>(context, listen: false).applyConnectionChange();
- }
+ unawaited(Provider.of<WalletManager>(context, listen: false).applyConnectionChange('XMR'));
}
/// Routes wallet-core log lines into skylight's logger.
diff --git a/lib/widgets/tx_details.dart b/lib/widgets/tx_details.dart
index cd92940..6617ac6 100644
--- a/lib/widgets/tx_details.dart
+++ b/lib/widgets/tx_details.dart
@@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:skylight_wallet/l10n/app_localizations.dart';
-import 'package:skylight_wallet/models/wallet_model.dart';
+import 'package:skylight_wallet/models/wallet_types.dart';
import 'package:skylight_wallet/util/secure_clipboard.dart';
class TxDetailsDialog {
Why this scored 20/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.