From faece2b04781e70dd28d282be7f4b77e82aa994d Mon Sep 17 00:00:00 2001 From: bracr10 Date: Tue, 4 Nov 2025 02:42:24 -0600 Subject: [PATCH 01/25] feat: Add 'restore-session' action and EmptyPayload Protocol Compliance - Update MostroMessage serialization to use correct wrapper key: * "restore" key for Action.restore messages * "order" key for all other actions (backward compatible) - Implement EmptyPayload class for null payload serialization - Support full privacy mode in restore requests: * Reputation mode: includes master key signature * Full privacy mode: omits master key, uses only trade key --- lib/data/models/enums/action.dart | 3 +- lib/data/models/mostro_message.dart | 14 +++-- lib/data/models/payload.dart | 11 ++++ lib/data/models/restore_request.dart | 21 +++++++ lib/data/models/restore_response.dart | 91 +++++++++++++++++++++++++++ 5 files changed, 135 insertions(+), 5 deletions(-) create mode 100644 lib/data/models/restore_request.dart create mode 100644 lib/data/models/restore_response.dart diff --git a/lib/data/models/enums/action.dart b/lib/data/models/enums/action.dart index 64cf32843..0b58bc4ec 100644 --- a/lib/data/models/enums/action.dart +++ b/lib/data/models/enums/action.dart @@ -38,7 +38,8 @@ enum Action { paymentFailed('payment-failed'), invoiceUpdated('invoice-updated'), sendDm('send-dm'), - tradePubkey('trade-pubkey'); + tradePubkey('trade-pubkey'), + restore('restore-session'); final String value; diff --git a/lib/data/models/mostro_message.dart b/lib/data/models/mostro_message.dart index 5b34998c2..6768332b8 100644 --- a/lib/data/models/mostro_message.dart +++ b/lib/data/models/mostro_message.dart @@ -36,13 +36,15 @@ class MostroMessage { json['id'] = id; } json['action'] = action.value; - json['payload'] = _payload?.toJson(); + // Serialize EmptyPayload as null to match protocol specification + json['payload'] = (_payload is EmptyPayload) ? null :_payload?.toJson(); return json; } factory MostroMessage.fromJson(Map json) { final timestamp = json['timestamp']; - json = json['order'] ?? json['cant-do'] ?? json; + // Support both 'order', 'restore', and 'cant-do' wrapper keys + json = json['order'] ?? json['restore'] ?? json['cant-do'] ?? json; final num requestId = json['request_id'] ?? 0; return MostroMessage( @@ -97,7 +99,9 @@ class MostroMessage { } String sign(NostrKeyPairs keyPair) { - final message = {'order': toJson()}; + // Use 'restore' key for restore action, 'order' for everything else + final wrapperKey = action == Action.restore ? 'restore' : 'order'; + final message = {wrapperKey: toJson()}; final serializedEvent = jsonEncode(message); final bytes = utf8.encode(serializedEvent); final digest = sha256.convert(bytes); @@ -107,7 +111,9 @@ class MostroMessage { } String serialize({NostrKeyPairs? keyPair}) { - final message = {'order': toJson()}; + // Use 'restore' key for restore action, 'order' for everything else + final wrapperKey = action == Action.restore ? 'restore' : 'order'; + final message = {wrapperKey: toJson()}; final serializedEvent = jsonEncode(message); final signature = (keyPair != null) ? '"${sign(keyPair)}"' : null; final content = '[$serializedEvent, $signature]'; diff --git a/lib/data/models/payload.dart b/lib/data/models/payload.dart index 9c8c550e1..53c4faf7f 100644 --- a/lib/data/models/payload.dart +++ b/lib/data/models/payload.dart @@ -36,3 +36,14 @@ abstract class Payload { } } } + +/// Empty payload for actions that don't require payload data +class EmptyPayload implements Payload { + const EmptyPayload(); + + @override + String get type => 'empty'; + + @override + Map toJson() => {}; +} diff --git a/lib/data/models/restore_request.dart b/lib/data/models/restore_request.dart new file mode 100644 index 000000000..9d351f98f --- /dev/null +++ b/lib/data/models/restore_request.dart @@ -0,0 +1,21 @@ +import 'dart:convert'; + +class RestoreMessage { + final int version; + final String action; + + RestoreMessage({ + this.version = 1, + this.action = 'restore-session', + }); + + Map toJson() => { + 'restore': { + 'version': version, + 'action': action, + 'payload': null, + }, + }; + + String toJsonString() => jsonEncode([toJson(), null]); +} \ No newline at end of file diff --git a/lib/data/models/restore_response.dart b/lib/data/models/restore_response.dart new file mode 100644 index 000000000..85dc20f41 --- /dev/null +++ b/lib/data/models/restore_response.dart @@ -0,0 +1,91 @@ +import 'package:mostro_mobile/data/models/payload.dart'; + +class RestoreData implements Payload { + final List orders; + final List disputes; + + RestoreData({ + required this.orders, + required this.disputes, + }); + + @override + String get type => 'restore_data'; + + factory RestoreData.fromJson(Map json) { + final restoreData = json['restore_data'] as Map; + + return RestoreData( + orders: (restoreData['orders'] as List?) + ?.map((o) => RestoredOrder.fromJson(o as Map)) + .toList() ?? [], + disputes: (restoreData['disputes'] as List?) + ?.map((d) => RestoredDispute.fromJson(d as Map)) + .toList() ?? [], + ); + } + + @override + Map toJson() => { + 'restore_data': { + 'orders': orders.map((o) => o.toJson()).toList(), + 'disputes': disputes.map((d) => d.toJson()).toList(), + } + }; +} + +class RestoredOrder { + final String id; + final int tradeIndex; + final String status; + + RestoredOrder({ + required this.id, + required this.tradeIndex, + required this.status, + }); + + factory RestoredOrder.fromJson(Map json) { + return RestoredOrder( + id: json['order_id'] as String, + tradeIndex: json['trade_index'] as int, + status: json['status'] as String, + ); + } + + Map toJson() => { + 'id': id, + 'trade_index': tradeIndex, + 'status': status, + }; +} + +class RestoredDispute { + final String disputeId; + final String orderId; + final int tradeIndex; + final String status; + + RestoredDispute({ + required this.disputeId, + required this.orderId, + required this.tradeIndex, + required this.status, + }); + + factory RestoredDispute.fromJson(Map json) { + return RestoredDispute( + disputeId: json['dispute_id'] as String, + orderId: json['order_id'] as String, + tradeIndex: json['trade_index'] as int, + status: json['status'] as String, + ); + } + + Map toJson() => { + 'dispute_id': disputeId, + 'order_id': orderId, + 'trade_index': tradeIndex, + 'status': status, + }; +} \ No newline at end of file From 61f1e5063e4008ac31124bca57caf5d452914343 Mon Sep 17 00:00:00 2001 From: bracr10 Date: Tue, 4 Nov 2025 02:42:25 -0600 Subject: [PATCH 02/25] feat: Partially Implement restore session manager and progress state ## Restore Session Flow - Implement RestoreService with initial session restore workflow: 1. Clear existing data (sessions, storage, notifications) 2. Create temporary subscription on trade key index 1 3. Send restore-session request to Mostro 4. Handle restore response with orders and disputes data --- lib/features/restore/restore_manager.dart | 154 ++++++++++++++++++ .../restore/restore_progress_notifier.dart | 123 ++++++++++++++ .../restore/restore_progress_state.dart | 53 ++++++ 3 files changed, 330 insertions(+) create mode 100644 lib/features/restore/restore_manager.dart create mode 100644 lib/features/restore/restore_progress_notifier.dart create mode 100644 lib/features/restore/restore_progress_state.dart diff --git a/lib/features/restore/restore_manager.dart b/lib/features/restore/restore_manager.dart new file mode 100644 index 000000000..e9b35c6bc --- /dev/null +++ b/lib/features/restore/restore_manager.dart @@ -0,0 +1,154 @@ +import 'dart:async'; +import 'package:dart_nostr/nostr/model/event/event.dart'; +import 'package:dart_nostr/nostr/model/request/filter.dart'; +import 'package:dart_nostr/nostr/model/request/request.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:logger/logger.dart'; +import 'package:mostro_mobile/data/models/enums/action.dart'; +import 'package:mostro_mobile/data/models/payload.dart'; +import 'package:mostro_mobile/features/key_manager/key_manager_provider.dart'; +import 'package:mostro_mobile/features/mostro/mostro_instance.dart'; +import 'package:mostro_mobile/features/restore/restore_progress_notifier.dart'; +import 'package:mostro_mobile/features/settings/settings_provider.dart'; +import 'package:mostro_mobile/shared/providers/local_notifications_providers.dart'; +import 'package:mostro_mobile/shared/providers/mostro_storage_provider.dart'; +import 'package:mostro_mobile/shared/providers/nostr_service_provider.dart'; +import 'package:mostro_mobile/shared/providers/notifications_history_repository_provider.dart'; +import 'package:mostro_mobile/shared/providers/session_notifier_provider.dart'; +import 'package:mostro_mobile/data/models/mostro_message.dart'; + +class RestoreService { + + final Ref ref; + final Logger _logger = Logger(); + StreamSubscription? _tempSubscription; + + RestoreService(this.ref); + + Future importMnemonicAndRestore(String mnemonic) async { + _logger.i('Restore: importing mnemonic'); + final keyManager = ref.read(keyManagerProvider); + await keyManager.importMnemonic(mnemonic); + await restore(); + } + + Future _clearAll() async { + try { + await ref.read(sessionNotifierProvider.notifier).reset(); + await ref.read(mostroStorageProvider).deleteAll(); + await ref.read(notificationsRepositoryProvider).clearAll(); + await ref.read(localNotificationsProvider).cancelAll(); + } catch (e) { + _logger.w('Restore: cleanup error', error: e); + } + } + + void _handleTempSubscriptionsResponse(NostrEvent event) { + _logger.i('Restore: received restore notification event ${event.id}'); + + } + + Future> _createTempSubscription() async { + //use temporary trade key 1 to subscribe to restore notifications + final keyManager = ref.read(keyManagerProvider); + final tempTradeKey = await keyManager.deriveTradeKeyFromIndex(1); + + final filter = NostrFilter( + kinds: [1059], + p: [tempTradeKey.public], + ); + + final request = NostrRequest(filters: [filter]); + final stream = ref.read(nostrServiceProvider).subscribeToEvents(request); + + final subscription = stream.listen( + _handleTempSubscriptionsResponse, + onError: (error, stackTrace) { + _logger.e('Restore: subscription error', error: error, stackTrace: stackTrace); + }, + cancelOnError: false, + ); + + return subscription; + } + + Future _sendRestoreRequest() async { + _logger.i('Restore: sending restore request'); + + final keyManager = ref.read(keyManagerProvider); + final settings = ref.read(settingsProvider); + + // Use temporary trade key 1 for restore communication + final tempTradeKey = await keyManager.deriveTradeKeyFromIndex(1); + + // Create restore message with EmptyPayload (serializes as null per protocol spec) + final mostroMessage = MostroMessage( + action: Action.restore, + payload: EmptyPayload(), + ); + + // Respect full privacy mode: if enabled, don't pass master key + final wrappedEvent = await mostroMessage.wrap( + tradeKey: tempTradeKey, + recipientPubKey: settings.mostroPublicKey, + masterKey: settings.fullPrivacyMode ? null : keyManager.masterKeyPair + ); + + await ref.read(nostrServiceProvider).publishEvent(wrappedEvent); + _logger.i('Restore: request sent successfully'); + } + + //Workflow: + // 1. Clear existing data + // 2. Create temporary subscription to key index 1 for restore notifications + // 3. Send restore request + Future restore() async { + //Clear existing data + await _clearAll(); + + //Show restore overlay + final progress = ref.read(restoreProgressProvider.notifier); + progress.startRestore(); + + //Validate master key + final keyManager = ref.read(keyManagerProvider); + if (keyManager.masterKeyPair == null) { + _logger.e('Restore: master key not found after import'); + throw Exception('Master key not found'); + } + + //Validate Mostro public key + final settings = ref.read(settingsProvider); + if (settings.mostroPublicKey.isEmpty) { + _logger.e('Restore: Mostro not configured'); + throw Exception('Mostro not configured'); + } + + //Subscribe to temporary notifications + _tempSubscription = await _createTempSubscription(); + + //Send restore request + await _sendRestoreRequest(); + + await Future.delayed(const Duration(seconds: 3)); + _tempSubscription?.cancel(); + + } + + +// Future _buildRestoreRequest() async { + + // } + +//Future _buildOrderDetailsRequest(List orderIds) async { + // } + + + + + +} + +final restoreServiceProvider = Provider((ref) { + return RestoreService(ref); +}); \ No newline at end of file diff --git a/lib/features/restore/restore_progress_notifier.dart b/lib/features/restore/restore_progress_notifier.dart new file mode 100644 index 000000000..e59bd66ee --- /dev/null +++ b/lib/features/restore/restore_progress_notifier.dart @@ -0,0 +1,123 @@ +import 'dart:async'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:logger/logger.dart'; +import 'package:mostro_mobile/features/restore/restore_progress_state.dart'; + +class RestoreProgressNotifier extends StateNotifier { + final _logger = Logger(); + Timer? _timeoutTimer; + static const _maxTimeout = Duration(seconds: 30); + + RestoreProgressNotifier() : super(RestoreProgressState.initial()); + + void startRestore() { + _logger.i('Starting restore overlay'); + state = RestoreProgressState.initial().copyWith( + isVisible: true, + step: RestoreStep.requesting, + ); + + _startTimeoutTimer(); + } + + void updateStep(RestoreStep step, {int? current, int? total}) { + _logger.i('Restore step: $step (${current ?? 0}/${total ?? 0})'); + state = state.copyWith( + step: step, + currentProgress: current ?? state.currentProgress, + totalProgress: total ?? state.totalProgress, + ); + + _resetTimeoutTimer(); + } + + void setOrdersReceived(int count) { + _logger.i('Received $count orders'); + state = state.copyWith( + step: RestoreStep.receivingOrders, + totalProgress: count, + currentProgress: 0, + ); + + _resetTimeoutTimer(); + } + + void incrementProgress() { + state = state.copyWith( + currentProgress: state.currentProgress + 1, + ); + + _resetTimeoutTimer(); + } + + void completeRestore() { + _logger.i('Restore completed successfully'); + _cancelTimeoutTimer(); + + state = state.copyWith( + step: RestoreStep.completed, + ); + + // Auto-hide after 1 second + Future.delayed(const Duration(seconds: 1), () { + if (mounted) { + hide(); + } + }); + } + + void showError(String message) { + _logger.w('Restore error: $message'); + _cancelTimeoutTimer(); + + state = state.copyWith( + step: RestoreStep.error, + errorMessage: message, + ); + + // Auto-hide after 3 seconds + Future.delayed(const Duration(seconds: 3), () { + if (mounted) { + hide(); + } + }); + } + + void hide() { + _logger.i('Hiding restore overlay'); + _cancelTimeoutTimer(); + state = RestoreProgressState.initial(); + } + + void _startTimeoutTimer() { + _cancelTimeoutTimer(); + _timeoutTimer = Timer(_maxTimeout, () { + if (mounted && state.isVisible) { + _logger.w('Restore timeout - auto-hiding overlay'); + showError('Request timeout'); + } + }); + } + + void _resetTimeoutTimer() { + if (state.isVisible) { + _startTimeoutTimer(); + } + } + + void _cancelTimeoutTimer() { + _timeoutTimer?.cancel(); + _timeoutTimer = null; + } + + @override + void dispose() { + _cancelTimeoutTimer(); + super.dispose(); + } +} + +final restoreProgressProvider = + StateNotifierProvider((ref) { + return RestoreProgressNotifier(); +}); \ No newline at end of file diff --git a/lib/features/restore/restore_progress_state.dart b/lib/features/restore/restore_progress_state.dart new file mode 100644 index 000000000..fe9127d60 --- /dev/null +++ b/lib/features/restore/restore_progress_state.dart @@ -0,0 +1,53 @@ +enum RestoreStep { + requesting, + receivingOrders, + loadingDetails, + processingRoles, + finalizing, + completed, + error, +} + +class RestoreProgressState { + final RestoreStep step; + final int currentProgress; + final int totalProgress; + final String? errorMessage; + final bool isVisible; + + const RestoreProgressState({ + required this.step, + this.currentProgress = 0, + this.totalProgress = 0, + this.errorMessage, + this.isVisible = false, + }); + + RestoreProgressState copyWith({ + RestoreStep? step, + int? currentProgress, + int? totalProgress, + String? errorMessage, + bool? isVisible, + }) { + return RestoreProgressState( + step: step ?? this.step, + currentProgress: currentProgress ?? this.currentProgress, + totalProgress: totalProgress ?? this.totalProgress, + errorMessage: errorMessage ?? this.errorMessage, + isVisible: isVisible ?? this.isVisible, + ); + } + + double get progressPercentage { + if (totalProgress == 0) return 0.0; + return currentProgress / totalProgress; + } + + static RestoreProgressState initial() { + return const RestoreProgressState( + step: RestoreStep.requesting, + isVisible: false, + ); + } +} \ No newline at end of file From 8587759dbda80f85ee092d237ba6dca5e4bf8005 Mon Sep 17 00:00:00 2001 From: bracr10 Date: Tue, 4 Nov 2025 02:42:25 -0600 Subject: [PATCH 03/25] feat: Create restore progress overlay UI and dialog - Add RestoreOverlay widget with multi-stage progress indicator - Add ImportMnemonicDialog for mnemonic phrase input and validation - Implement RestoreProgressNotifier for state management --- .../key_manager/import_mnemonic_dialog.dart | 247 ++++++++++++++++++ .../key_manager/key_management_screen.dart | 207 +++++++++++++-- lib/features/restore/restore_overlay.dart | 172 ++++++++++++ 3 files changed, 599 insertions(+), 27 deletions(-) create mode 100644 lib/features/key_manager/import_mnemonic_dialog.dart create mode 100644 lib/features/restore/restore_overlay.dart diff --git a/lib/features/key_manager/import_mnemonic_dialog.dart b/lib/features/key_manager/import_mnemonic_dialog.dart new file mode 100644 index 000000000..81c19adc2 --- /dev/null +++ b/lib/features/key_manager/import_mnemonic_dialog.dart @@ -0,0 +1,247 @@ +import 'package:flutter/material.dart'; +import 'package:mostro_mobile/core/app_theme.dart'; +import 'package:mostro_mobile/generated/l10n.dart'; + +class ImportMnemonicDialog extends StatefulWidget { + const ImportMnemonicDialog({super.key}); + + @override + State createState() => _ImportMnemonicDialogState(); +} + +class _ImportMnemonicDialogState extends State { + final TextEditingController _mnemonicController = TextEditingController(); + String? _errorMessage; + + @override + void dispose() { + _mnemonicController.dispose(); + super.dispose(); + } + + bool _validateMnemonic(String mnemonic) { + final trimmed = mnemonic.trim(); + if (trimmed.isEmpty) { + setState(() { + _errorMessage = null; + }); + return false; + } + + final words = trimmed.split(RegExp(r'\s+')); + + if (words.length != 12) { + setState(() { + _errorMessage = S.of(context)!.errorNotTwelveWords; + }); + return false; + } + + for (final word in words) { + if (word.length < 3) { + setState(() { + _errorMessage = S.of(context)!.errorWordTooShort; + }); + return false; + } + + if (!RegExp(r'^[a-zA-Z]+$').hasMatch(word)) { + setState(() { + _errorMessage = S.of(context)!.errorInvalidCharacters; + }); + return false; + } + } + + setState(() { + _errorMessage = null; + }); + return true; + } + + void _handleImport() { + final mnemonic = _mnemonicController.text.trim(); + + if (_validateMnemonic(mnemonic)) { + if (mounted) { + Navigator.of(context).pop(mnemonic); + } + } + } + + @override + Widget build(BuildContext context) { + return Dialog( + backgroundColor: AppTheme.backgroundCard, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: BorderSide(color: Colors.white.withValues(alpha: 0.1)), + ), + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + S.of(context)!.importMostroUserDialogTitle, + style: const TextStyle( + color: AppTheme.textPrimary, + fontSize: 20, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 20), + _buildInfoPoint(S.of(context)!.importMostroUserInfo1), + const SizedBox(height: 12), + _buildInfoPoint(S.of(context)!.importMostroUserInfo2), + const SizedBox(height: 12), + _buildInfoPoint(S.of(context)!.importMostroUserInfo3), + const SizedBox(height: 24), + Text( + S.of(context)!.secretWordsLabel, + style: const TextStyle( + color: AppTheme.textPrimary, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 8), + TextField( + controller: _mnemonicController, + minLines: 4, + maxLines: 6, + style: const TextStyle( + color: AppTheme.textPrimary, + fontSize: 14, + ), + decoration: InputDecoration( + hintText: S.of(context)!.secretWordsPlaceholder, + hintStyle: TextStyle( + color: AppTheme.textSecondary.withValues(alpha: 0.5), + fontSize: 14, + ), + filled: true, + fillColor: AppTheme.backgroundInput, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide( + color: Colors.white.withValues(alpha: 0.1), + ), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide( + color: Colors.white.withValues(alpha: 0.1), + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide( + color: AppTheme.activeColor, + width: 2, + ), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide( + color: Colors.red, + width: 2, + ), + ), + focusedErrorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide( + color: Colors.red, + width: 2, + ), + ), + errorText: _errorMessage, + errorStyle: const TextStyle( + color: Colors.red, + fontSize: 12, + ), + ), + onChanged: (_) { + if (_errorMessage != null) { + setState(() { + _errorMessage = null; + }); + } + }, + ), + const SizedBox(height: 24), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text( + S.of(context)!.cancel, + style: const TextStyle( + color: AppTheme.textSecondary, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + ), + const SizedBox(width: 12), + ElevatedButton( + onPressed: _handleImport, + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.activeColor, + foregroundColor: Colors.black, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 12, + ), + ), + child: Text( + S.of(context)!.importMostroUser, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ], + ), + ), + ), + ); + } + + Widget _buildInfoPoint(String text) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Padding( + padding: EdgeInsets.only(top: 6), + child: Text( + '• ', + style: TextStyle( + color: AppTheme.textSecondary, + fontSize: 14, + ), + ), + ), + Expanded( + child: Text( + text, + style: const TextStyle( + color: AppTheme.textSecondary, + fontSize: 14, + height: 1.5, + ), + ), + ), + ], + ); + } +} \ No newline at end of file diff --git a/lib/features/key_manager/key_management_screen.dart b/lib/features/key_manager/key_management_screen.dart index ff22536a5..9ad633132 100644 --- a/lib/features/key_manager/key_management_screen.dart +++ b/lib/features/key_manager/key_management_screen.dart @@ -3,10 +3,13 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:heroicons/heroicons.dart'; +import 'package:logger/logger.dart'; import 'package:lucide_icons/lucide_icons.dart'; import 'package:mostro_mobile/core/app_theme.dart'; import 'package:mostro_mobile/features/key_manager/key_manager_provider.dart'; +import 'package:mostro_mobile/features/key_manager/import_mnemonic_dialog.dart'; import 'package:mostro_mobile/features/settings/settings_provider.dart'; +import 'package:mostro_mobile/features/restore/restore_manager.dart'; import 'package:mostro_mobile/shared/providers.dart'; import 'package:mostro_mobile/generated/l10n.dart'; @@ -19,6 +22,7 @@ class KeyManagementScreen extends ConsumerStatefulWidget { } class _KeyManagementScreenState extends ConsumerState { + final Logger _logger = Logger(); String? _mnemonic; int? _tradeKeyIndex; bool _loading = false; @@ -167,8 +171,20 @@ class _KeyManagementScreenState extends ConsumerState { _buildGenerateNewUserButton(context), const SizedBox(height: 16), - // Import Mostro User Button - _buildImportUserButton(context), + // Import and Refresh User Buttons + Row( + children: [ + Expanded( + flex: 7, + child: _buildImportUserButton(context), + ), + const SizedBox(width: 12), + Expanded( + flex: 3, + child: _buildRefreshUserButton(context), + ), + ], + ), const SizedBox(height: 16), ], ), @@ -555,37 +571,54 @@ class _KeyManagementScreenState extends ConsumerState { } Widget _buildImportUserButton(BuildContext context) { - return SizedBox( - width: double.infinity, - child: OutlinedButton( - onPressed: null, // Keep disabled as requested - style: OutlinedButton.styleFrom( - side: - BorderSide(color: AppTheme.textSecondary.withValues(alpha: 0.3)), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - padding: const EdgeInsets.symmetric(vertical: 16), + return OutlinedButton( + onPressed: () => _showImportMnemonicDialog(context), + style: OutlinedButton.styleFrom( + side: const BorderSide(color: AppTheme.activeColor), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - LucideIcons.download, - size: 20, - color: AppTheme.textSecondary.withValues(alpha: 0.5), - ), - const SizedBox(width: 8), - Text( + padding: const EdgeInsets.symmetric(vertical: 16), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + LucideIcons.download, + size: 20, + color: AppTheme.activeColor, + ), + const SizedBox(width: 8), + Flexible( + child: Text( S.of(context)!.importMostroUser, - style: TextStyle( + style: const TextStyle( fontSize: 16, fontWeight: FontWeight.w500, - color: AppTheme.textSecondary.withValues(alpha: 0.5), + color: AppTheme.activeColor, ), + overflow: TextOverflow.ellipsis, ), - ], + ), + ], + ), + ); + } + + Widget _buildRefreshUserButton(BuildContext context) { + return OutlinedButton( + onPressed: () => _showRefreshUserDialog(context), + style: OutlinedButton.styleFrom( + side: const BorderSide(color: AppTheme.activeColor), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), ), + padding: const EdgeInsets.symmetric(vertical: 16), + ), + child: const Icon( + LucideIcons.refreshCw, + size: 20, + color: AppTheme.activeColor, ), ); } @@ -702,4 +735,124 @@ class _KeyManagementScreenState extends ConsumerState { }, ); } -} + + void _showRefreshUserDialog(BuildContext context) { + showDialog( + context: context, + builder: (BuildContext dialogContext) { + return AlertDialog( + backgroundColor: AppTheme.backgroundCard, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: BorderSide(color: Colors.white.withValues(alpha: 0.1)), + ), + title: Text( + S.of(context)!.refreshUserDialogTitle, + style: const TextStyle( + color: AppTheme.textPrimary, + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + content: Text( + S.of(context)!.refreshUserDialogContent, + style: const TextStyle( + color: AppTheme.textSecondary, + fontSize: 14, + height: 1.5, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: Text( + S.of(context)!.cancel, + style: const TextStyle( + color: AppTheme.textSecondary, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + textAlign: TextAlign.center, + ), + ), + const SizedBox(width: 12), + ElevatedButton( + onPressed: () async { + Navigator.of(dialogContext).pop(); + + // Capture context-dependent values before async gap + final successMessage = S.of(context)!.refreshSuccessful; + final scaffoldMessenger = ScaffoldMessenger.of(context); + final localizations = S.of(context)!; + + try { + final restoreService = ref.read(restoreServiceProvider); + await restoreService.restore(); + + if (!mounted) return; + scaffoldMessenger.showSnackBar( + SnackBar(content: Text(successMessage)), + ); + } catch (e) { + _logger.e('Refresh failed: $e'); + if (!mounted) return; + scaffoldMessenger.showSnackBar( + SnackBar(content: Text(localizations.refreshFailed(e.toString()))), + ); + } + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.activeColor, + foregroundColor: Colors.black, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 24), + ), + child: Text( + S.of(context)!.refresh, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ); + }, + ); + } + + Future _showImportMnemonicDialog(BuildContext context) async { + // Capture context-dependent values before async gap + final scaffoldMessenger = ScaffoldMessenger.of(context); + final localizations = S.of(context)!; + + final mnemonic = await showDialog( + context: context, + builder: (BuildContext dialogContext) { + return const ImportMnemonicDialog(); + }, + ); + + if (mnemonic != null && mnemonic.isNotEmpty) { + + try { + final restoreService = ref.read(restoreServiceProvider); + await restoreService.importMnemonicAndRestore(mnemonic); + await _loadKeys(); + + if (!mounted) return; + scaffoldMessenger.showSnackBar( + SnackBar(content: Text(localizations.keyImportedSuccessfully)), + ); + } catch (e) { + _logger.e('Import failed: $e'); + if (!mounted) return; + scaffoldMessenger.showSnackBar( + SnackBar(content: Text(localizations.importFailed(e.toString()))), + ); + } + } + } +} \ No newline at end of file diff --git a/lib/features/restore/restore_overlay.dart b/lib/features/restore/restore_overlay.dart new file mode 100644 index 000000000..1da4450fc --- /dev/null +++ b/lib/features/restore/restore_overlay.dart @@ -0,0 +1,172 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:mostro_mobile/core/app_theme.dart'; +import 'package:mostro_mobile/features/restore/restore_progress_notifier.dart'; +import 'package:mostro_mobile/features/restore/restore_progress_state.dart'; +import 'package:mostro_mobile/generated/l10n.dart'; + +class RestoreOverlay extends ConsumerWidget { + const RestoreOverlay({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final state = ref.watch(restoreProgressProvider); + + if (!state.isVisible) { + return const SizedBox.shrink(); + } + + final screenWidth = MediaQuery.of(context).size.width; + final isSmallScreen = screenWidth < 360; + final isMediumScreen = screenWidth >= 360 && screenWidth < 600; + + // Responsive values + final horizontalMargin = isSmallScreen ? 24.0 : (isMediumScreen ? 32.0 : 40.0); + final containerPadding = isSmallScreen ? 24.0 : 32.0; + final iconSize = isSmallScreen ? 48.0 : 64.0; + final titleFontSize = isSmallScreen ? 18.0 : 20.0; + final messageFontSize = isSmallScreen ? 13.0 : 14.0; + + return Material( + color: Colors.black.withValues(alpha: 0.85), + child: Center( + child: Container( + margin: EdgeInsets.symmetric(horizontal: horizontalMargin), + padding: EdgeInsets.all(containerPadding), + decoration: BoxDecoration( + color: AppTheme.backgroundCard, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: Colors.white.withValues(alpha: 0.1), + ), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildStatusIcon(state.step, iconSize), + SizedBox(height: isSmallScreen ? 16 : 24), + Text( + S.of(context)!.restoringOrders, + style: TextStyle( + color: AppTheme.textPrimary, + fontSize: titleFontSize, + fontWeight: FontWeight.w600, + ), + textAlign: TextAlign.center, + ), + SizedBox(height: isSmallScreen ? 12 : 16), + Text( + _getMessage(context, state), + style: TextStyle( + color: AppTheme.textSecondary, + fontSize: messageFontSize, + ), + textAlign: TextAlign.center, + ), + SizedBox(height: isSmallScreen ? 16 : 24), + _buildProgressIndicator(state, iconSize), + ], + ), + ), + ), + ); + } + + Widget _buildStatusIcon(RestoreStep step, double size) { + IconData iconData; + Color iconColor; + + switch (step) { + case RestoreStep.error: + iconData = Icons.error; + iconColor = Colors.red; + break; + default: + iconData = Icons.sync; + iconColor = AppTheme.activeColor; + } + + return Icon( + iconData, + size: size, + color: iconColor, + ); + } + + Widget _buildProgressIndicator(RestoreProgressState state, double iconSize) { + if (state.step == RestoreStep.completed) { + return const Icon( + Icons.check_circle, + color: Colors.green, + size: 48, + ); + } + + if (state.step == RestoreStep.error) { + return const Icon( + Icons.error, + color: Colors.red, + size: 48, + ); + } + + // Show progress counter if we have total progress + if (state.totalProgress > 0) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox( + width: 40, + height: 40, + child: CircularProgressIndicator( + color: AppTheme.activeColor, + strokeWidth: 3, + ), + ), + const SizedBox(height: 12), + Text( + '${state.currentProgress}/${state.totalProgress}', + style: const TextStyle( + color: AppTheme.textSecondary, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + ], + ); + } + + // Default spinning indicator + return const SizedBox( + width: 40, + height: 40, + child: CircularProgressIndicator( + color: AppTheme.activeColor, + strokeWidth: 3, + ), + ); + } + + String _getMessage(BuildContext context, RestoreProgressState state) { + if (state.step == RestoreStep.error) { + return state.errorMessage ?? S.of(context)!.restoreError; + } + + switch (state.step) { + case RestoreStep.requesting: + return S.of(context)!.restoreRequestingData; + case RestoreStep.receivingOrders: + return S.of(context)!.restoreReceivingOrders; + case RestoreStep.loadingDetails: + return S.of(context)!.restoreLoadingDetails; + case RestoreStep.processingRoles: + return S.of(context)!.restoreProcessingRoles; + case RestoreStep.finalizing: + return S.of(context)!.restoreFinalizing; + case RestoreStep.completed: + return S.of(context)!.restoreCompleted; + default: + return S.of(context)!.restoreRequestingData; + } + } +} \ No newline at end of file From 5828a057256973eb7c86ae9c12245b51e54b6bde Mon Sep 17 00:00:00 2001 From: bracr10 Date: Tue, 4 Nov 2025 02:42:27 -0600 Subject: [PATCH 04/25] fix: Update notification handling for restore action --- .../notifications/utils/notification_message_mapper.dart | 4 ++++ lib/features/notifications/widgets/notification_item.dart | 1 + 2 files changed, 5 insertions(+) diff --git a/lib/features/notifications/utils/notification_message_mapper.dart b/lib/features/notifications/utils/notification_message_mapper.dart index 677cbe0f4..4c2e98f05 100644 --- a/lib/features/notifications/utils/notification_message_mapper.dart +++ b/lib/features/notifications/utils/notification_message_mapper.dart @@ -84,6 +84,8 @@ class NotificationMessageMapper { return 'notification_dispute_started_title'; case mostro.Action.tradePubkey: return 'notification_order_update_title'; + case mostro.Action.restore: + return 'TODO: implement restore title key'; } } @@ -176,6 +178,8 @@ class NotificationMessageMapper { return 'notification_dispute_started_message'; case mostro.Action.tradePubkey: return 'notification_order_update_message'; + case mostro.Action.restore: + return 'TODO: implement restore message key'; } } diff --git a/lib/features/notifications/widgets/notification_item.dart b/lib/features/notifications/widgets/notification_item.dart index 5b7337f76..ae7a51d33 100644 --- a/lib/features/notifications/widgets/notification_item.dart +++ b/lib/features/notifications/widgets/notification_item.dart @@ -121,6 +121,7 @@ class NotificationItem extends ConsumerWidget { case mostro_action.Action.adminTookDispute: case mostro_action.Action.invoiceUpdated: case mostro_action.Action.tradePubkey: + case mostro_action.Action.restore: break; } } From 21802bb3bcc5373f9c00ffa30cdd3b8ff7abb6a6 Mon Sep 17 00:00:00 2001 From: bracr10 Date: Tue, 4 Nov 2025 02:42:27 -0600 Subject: [PATCH 05/25] feat: Add localization for restore and import features --- lib/l10n/intl_en.arb | 52 ++++++++++++++++++++++++++++++++++++++++++-- lib/l10n/intl_es.arb | 50 +++++++++++++++++++++++++++++++++++++++++- lib/l10n/intl_it.arb | 52 ++++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 149 insertions(+), 5 deletions(-) diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index ab95ab4a7..5ea2ed1e8 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -460,10 +460,34 @@ "incrementsWithEachTrade": "Increments with each trade", "generateNewUser": "Generate New User", "importMostroUser": "Import Mostro User", + "refreshUser": "Refresh User", "keyImportedSuccessfully": "Key imported successfully", "importFailed": "Import failed: {error}", + "@importFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "refreshSuccessful": "User data refreshed successfully", + "refreshFailed": "Refresh failed: {error}", + "@refreshFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "noMnemonicFound": "No mnemonic found", "errorLoadingMnemonic": "Error: {error}", + "@errorLoadingMnemonic": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "noMessagesAvailable": "No messages available", "back": "BACK", @@ -885,6 +909,9 @@ "currentTradeIndexInfoText": "With each new trade, a unique Trade Key is generated to ensure your transactions remain private. The trade index indicates how many keys you've used. If you're in 'Reputation Mode,' you allow Mostro to link all your Trade Keys to calculate and maintain your reputation.", "generateNewUserDialogTitle": "Generate a new user?", "generateNewUserDialogContent": "If you continue, a new set of 12 words will be generated as your new user. This new user will have no reputation, and you will have to start from scratch.", + "refreshUserDialogTitle": "Refresh user data?", + "refreshUserDialogContent": "This will re-fetch your trades and orders from the Mostro instance. Use this if you think your data is out of sync or orders are missing.", + "refresh": "Refresh", "continueButton": "Continue", "show": "Show", "hide": "Hide", @@ -1144,5 +1171,26 @@ "deleteUserRelayCancel": "No", "@_comment_session_timeout": "Session timeout message", - "sessionTimeoutMessage": "No response received, check your connection and try again later" -} + "sessionTimeoutMessage": "No response received, check your connection and try again later", + + "@_comment_import_mnemonic_dialog": "Import Mnemonic Dialog strings", + "importMostroUserDialogTitle": "Import Mostro User", + "importMostroUserInfo1": "These are your secret words, the only way to recover your account if you lose access to this app or want to use your identity in another app.", + "importMostroUserInfo2": "Write them down carefully and store them in a secure, private location. Never share them with anyone.", + "importMostroUserInfo3": "If you lose these words, you'll permanently lose access to your account.", + "secretWordsLabel": "Secret Words", + "secretWordsPlaceholder": "Enter your 12 secret words separated by spaces", + "errorNotTwelveWords": "You must enter exactly 12 words", + "errorWordTooShort": "Each word must have at least 3 letters", + "errorInvalidCharacters": "Words can only contain letters", + + "@_comment_restore_overlay": "Restore overlay messages", + "restoringOrders": "Restoring Orders", + "restoreRequestingData": "Requesting restore data...", + "restoreReceivingOrders": "Receiving orders...", + "restoreLoadingDetails": "Loading order details...", + "restoreProcessingRoles": "Processing roles...", + "restoreFinalizing": "Finalizing restore...", + "restoreCompleted": "Restore completed!", + "restoreError": "Restore error" +} \ No newline at end of file diff --git a/lib/l10n/intl_es.arb b/lib/l10n/intl_es.arb index ea5a80393..a42f89000 100644 --- a/lib/l10n/intl_es.arb +++ b/lib/l10n/intl_es.arb @@ -382,10 +382,34 @@ "incrementsWithEachTrade": "Se incrementa con cada intercambio", "generateNewUser": "Generar Nuevo Usuario", "importMostroUser": "Importar Usuario Mostro", + "refreshUser": "Actualizar Usuario", "keyImportedSuccessfully": "Clave importada exitosamente", "importFailed": "Importación falló: {error}", + "@importFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "refreshSuccessful": "Datos de usuario actualizados exitosamente", + "refreshFailed": "Actualización falló: {error}", + "@refreshFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "noMnemonicFound": "No se encontró mnemónico", "errorLoadingMnemonic": "Error: {error}", + "@errorLoadingMnemonic": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "noMessagesAvailable": "No hay mensajes disponibles", "back": "ATRÁS", @@ -864,6 +888,9 @@ "currentTradeIndexInfoText": "Con cada nuevo intercambio, se genera una Clave de Intercambio única para asegurar que tus transacciones permanezcan privadas. El índice de intercambio indica cuántas claves has usado. Si estás en 'Modo Reputación,' permites a Mostro vincular todas tus Claves de Intercambio para calcular y mantener tu reputación.", "generateNewUserDialogTitle": "¿Generar un nuevo usuario?", "generateNewUserDialogContent": "Si continúas, se generará un nuevo conjunto de 12 palabras como tu nuevo usuario. Este nuevo usuario no tendrá reputación, y tendrás que empezar desde cero.", + "refreshUserDialogTitle": "¿Actualizar datos de usuario?", + "refreshUserDialogContent": "Esto volverá a solicitar tus intercambios y órdenes desde la instancia de Mostro. Úsalo si crees que tus datos están desincronizados o faltan órdenes.", + "refresh": "Actualizar", "continueButton": "Continuar", "show": "Mostrar", "hide": "Ocultar", @@ -1122,6 +1149,27 @@ "deleteUserRelayCancel": "No", "@_comment_session_timeout": "Session timeout message", - "sessionTimeoutMessage": "No hubo respuesta, verifica tu conexión e inténtalo más tarde" + "sessionTimeoutMessage": "No hubo respuesta, verifica tu conexión e inténtalo más tarde", + + "@_comment_import_mnemonic_dialog": "Import Mnemonic Dialog strings", + "importMostroUserDialogTitle": "Importar Usuario Mostro", + "importMostroUserInfo1": "Estas son tus palabras secretas, la única forma de recuperar tu cuenta si pierdes acceso a esta app o quieres usar tu identidad en otra app.", + "importMostroUserInfo2": "Escríbelas con cuidado y guárdalas en un lugar seguro y privado. Nunca las compartas con nadie.", + "importMostroUserInfo3": "Si pierdes estas palabras, perderás permanentemente el acceso a tu cuenta.", + "secretWordsLabel": "Palabras Secretas", + "secretWordsPlaceholder": "Ingresa tus 12 palabras secretas separadas por espacios", + "errorNotTwelveWords": "Debes ingresar exactamente 12 palabras", + "errorWordTooShort": "Cada palabra debe tener al menos 3 letras", + "errorInvalidCharacters": "Las palabras solo pueden contener letras", + + "@_comment_restore_overlay": "Mensajes de overlay de restauración", + "restoringOrders": "Restaurando Órdenes", + "restoreRequestingData": "Solicitando datos de restauración...", + "restoreReceivingOrders": "Recibiendo órdenes...", + "restoreLoadingDetails": "Cargando detalles de órdenes...", + "restoreProcessingRoles": "Procesando roles...", + "restoreFinalizing": "Finalizando restauración...", + "restoreCompleted": "¡Restauración completada!", + "restoreError": "Error de restauración" } \ No newline at end of file diff --git a/lib/l10n/intl_it.arb b/lib/l10n/intl_it.arb index a0e59dc07..93f3f4d0f 100644 --- a/lib/l10n/intl_it.arb +++ b/lib/l10n/intl_it.arb @@ -411,10 +411,34 @@ "incrementsWithEachTrade": "Si incrementa ad ogni scambio", "generateNewUser": "Genera Nuovo Utente", "importMostroUser": "Importa Utente Mostro", + "refreshUser": "Aggiorna Utente", "keyImportedSuccessfully": "Chiave importata con successo", "importFailed": "Importazione fallita: {error}", + "@importFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "refreshSuccessful": "Dati utente aggiornati con successo", + "refreshFailed": "Aggiornamento fallito: {error}", + "@refreshFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "noMnemonicFound": "Nessun mnemonico trovato", "errorLoadingMnemonic": "Errore: {error}", + "@errorLoadingMnemonic": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "noMessagesAvailable": "Nessun messaggio disponibile", "back": "INDIETRO", @@ -920,6 +944,9 @@ "currentTradeIndexInfoText": "Con ogni nuovo scambio, viene generata una Chiave di Scambio unica per assicurare che le tue transazioni rimangano private. L'indice di scambio indica quante chiavi hai usato. Se sei in 'Modalità Reputazione,' permetti a Mostro di collegare tutte le tue Chiavi di Scambio per calcolare e mantenere la tua reputazione.", "generateNewUserDialogTitle": "Generare un nuovo utente?", "generateNewUserDialogContent": "Se continui, verrà generato un nuovo set di 12 parole come tuo nuovo utente. Questo nuovo utente non avrà reputazione, e dovrai ricominciare da capo.", + "refreshUserDialogTitle": "Aggiornare i dati utente?", + "refreshUserDialogContent": "Questo richiederà nuovamente i tuoi scambi e ordini dall'istanza Mostro. Usalo se pensi che i tuoi dati non siano sincronizzati o manchino ordini.", + "refresh": "Aggiorna", "continueButton": "Continua", "show": "Mostra", "hide": "Nascondi", @@ -1177,5 +1204,26 @@ "deleteUserRelayCancel": "No", "@_comment_session_timeout": "Session timeout message", - "sessionTimeoutMessage": "Nessuna risposta ricevuta, verifica la tua connessione e riprova più tardi" -} + "sessionTimeoutMessage": "Nessuna risposta ricevuta, verifica la tua connessione e riprova più tardi", + + "@_comment_import_mnemonic_dialog": "Import Mnemonic Dialog strings", + "importMostroUserDialogTitle": "Importa Utente Mostro", + "importMostroUserInfo1": "Queste sono le tue parole segrete, l'unico modo per recuperare il tuo account se perdi l'accesso a questa app o vuoi usare la tua identità in un'altra app.", + "importMostroUserInfo2": "Scrivile con attenzione e conservale in un luogo sicuro e privato. Non condividerle mai con nessuno.", + "importMostroUserInfo3": "Se perdi queste parole, perderai permanentemente l'accesso al tuo account.", + "secretWordsLabel": "Parole Segrete", + "secretWordsPlaceholder": "Inserisci le tue 12 parole segrete separate da spazi", + "errorNotTwelveWords": "Devi inserire esattamente 12 parole", + "errorWordTooShort": "Ogni parola deve avere almeno 3 lettere", + "errorInvalidCharacters": "Le parole possono contenere solo lettere", + + "@_comment_restore_overlay": "Messaggi di overlay di ripristino", + "restoringOrders": "Ripristino Ordini", + "restoreRequestingData": "Richiesta dati di ripristino...", + "restoreReceivingOrders": "Ricezione ordini...", + "restoreLoadingDetails": "Caricamento dettagli ordini...", + "restoreProcessingRoles": "Elaborazione ruoli...", + "restoreFinalizing": "Finalizzazione ripristino...", + "restoreCompleted": "Ripristino completato!", + "restoreError": "Errore di ripristino" +} \ No newline at end of file From 6aa579b2ee9d9efcf583407b91b3f0cfe3125df6 Mon Sep 17 00:00:00 2001 From: bracr10 Date: Wed, 5 Nov 2025 14:16:24 -0600 Subject: [PATCH 06/25] feat: Add 'orders' action enum and notification placeholders --- lib/data/models/enums/action.dart | 3 ++- .../notifications/utils/notification_message_mapper.dart | 4 ++++ lib/features/notifications/widgets/notification_item.dart | 1 + 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/lib/data/models/enums/action.dart b/lib/data/models/enums/action.dart index 0b58bc4ec..a97d2811e 100644 --- a/lib/data/models/enums/action.dart +++ b/lib/data/models/enums/action.dart @@ -39,7 +39,8 @@ enum Action { invoiceUpdated('invoice-updated'), sendDm('send-dm'), tradePubkey('trade-pubkey'), - restore('restore-session'); + restore('restore-session'), + orders('orders'); final String value; diff --git a/lib/features/notifications/utils/notification_message_mapper.dart b/lib/features/notifications/utils/notification_message_mapper.dart index 4c2e98f05..34aac105e 100644 --- a/lib/features/notifications/utils/notification_message_mapper.dart +++ b/lib/features/notifications/utils/notification_message_mapper.dart @@ -86,6 +86,8 @@ class NotificationMessageMapper { return 'notification_order_update_title'; case mostro.Action.restore: return 'TODO: implement restore title key'; + case mostro.Action.orders: + return 'TODO: implement orders title key'; } } @@ -180,6 +182,8 @@ class NotificationMessageMapper { return 'notification_order_update_message'; case mostro.Action.restore: return 'TODO: implement restore message key'; + case mostro.Action.orders: + return 'TODO: implement orders message key'; } } diff --git a/lib/features/notifications/widgets/notification_item.dart b/lib/features/notifications/widgets/notification_item.dart index ae7a51d33..0376d75a6 100644 --- a/lib/features/notifications/widgets/notification_item.dart +++ b/lib/features/notifications/widgets/notification_item.dart @@ -122,6 +122,7 @@ class NotificationItem extends ConsumerWidget { case mostro_action.Action.invoiceUpdated: case mostro_action.Action.tradePubkey: case mostro_action.Action.restore: + case mostro_action.Action.orders: break; } } From 6a86f740bda3473e16fef566b1bf662a6467d75d Mon Sep 17 00:00:00 2001 From: bracr10 Date: Wed, 5 Nov 2025 14:16:25 -0600 Subject: [PATCH 07/25] feat: Introduce OrdersRequest and OrdersResponse data models --- lib/data/models/orders_request.dart | 21 ++++++ lib/data/models/orders_response.dart | 95 ++++++++++++++++++++++++++++ 2 files changed, 116 insertions(+) create mode 100644 lib/data/models/orders_request.dart create mode 100644 lib/data/models/orders_response.dart diff --git a/lib/data/models/orders_request.dart b/lib/data/models/orders_request.dart new file mode 100644 index 000000000..2c1b87234 --- /dev/null +++ b/lib/data/models/orders_request.dart @@ -0,0 +1,21 @@ +import 'package:mostro_mobile/data/models/payload.dart'; + +class OrdersPayload implements Payload { + final List ids; + + const OrdersPayload({required this.ids}); + + @override + String get type => 'orders'; + + factory OrdersPayload.fromJson(Map json) { + return OrdersPayload( + ids: (json['ids'] as List).map((e) => e as String).toList(), + ); + } + + @override + Map toJson() => { + 'ids': ids, + }; +} diff --git a/lib/data/models/orders_response.dart b/lib/data/models/orders_response.dart new file mode 100644 index 000000000..051f810da --- /dev/null +++ b/lib/data/models/orders_response.dart @@ -0,0 +1,95 @@ +import 'package:mostro_mobile/data/models/payload.dart'; + + +class OrdersResponse implements Payload { + final List orders; + + OrdersResponse({required this.orders}); + + @override + String get type => 'orders'; + + factory OrdersResponse.fromJson(Map json) { + return OrdersResponse( + orders: (json['orders'] as List?) + ?.map((o) => OrderDetail.fromJson(o as Map)) + .toList() ?? + [], + ); + } + + @override + Map toJson() => { + 'orders': orders.map((o) => o.toJson()).toList(), + }; +} + +class OrderDetail { + final String id; + final String kind; + final String status; + final int amount; + final String fiatCode; + final int? minAmount; + final int? maxAmount; + final int fiatAmount; + final String paymentMethod; + final int premium; + final String? buyerTradePubkey; + final String? sellerTradePubkey; + final int? createdAt; + final int? expiresAt; + + OrderDetail({ + required this.id, + required this.kind, + required this.status, + required this.amount, + required this.fiatCode, + this.minAmount, + this.maxAmount, + required this.fiatAmount, + required this.paymentMethod, + required this.premium, + this.buyerTradePubkey, + this.sellerTradePubkey, + this.createdAt, + this.expiresAt, + }); + + factory OrderDetail.fromJson(Map json) { + return OrderDetail( + id: json['id'] as String, + kind: json['kind'] as String, + status: json['status'] as String, + amount: json['amount'] as int, + fiatCode: json['fiat_code'] as String, + minAmount: json['min_amount'] != null ? json['min_amount'] as int : null, + maxAmount: json['max_amount'] != null ? json['max_amount'] as int : null, + fiatAmount: json['fiat_amount'] as int, + paymentMethod: json['payment_method'] as String, + premium: json['premium'] as int, + buyerTradePubkey: json['buyer_trade_pubkey'] != null ? json['buyer_trade_pubkey'] as String : null, + sellerTradePubkey: json['seller_trade_pubkey'] != null ? json['seller_trade_pubkey'] as String : null, + createdAt: json['created_at'] != null ? json['created_at'] as int : null, + expiresAt: json['expires_at'] != null ? json['expires_at'] as int : null, + ); + } + + Map toJson() => { + 'id': id, + 'kind': kind, + 'status': status, + 'amount': amount, + 'fiat_code': fiatCode, + 'min_amount': minAmount, + 'max_amount': maxAmount, + 'fiat_amount': fiatAmount, + 'payment_method': paymentMethod, + 'premium': premium, + 'buyer_trade_pubkey': buyerTradePubkey, + 'seller_trade_pubkey': sellerTradePubkey, + 'created_at': createdAt, + 'expires_at': expiresAt, + }; +} From c6230078ba59dd3b323250b2b80b87941b1e9edf Mon Sep 17 00:00:00 2001 From: bracr10 Date: Wed, 5 Nov 2025 14:16:25 -0600 Subject: [PATCH 08/25] refactor(restore): Remove old RestoreMessage and rename restore method --- lib/data/models/restore_request.dart | 21 ------------------- .../key_manager/key_management_screen.dart | 2 +- lib/features/restore/restore_manager.dart | 2 +- 3 files changed, 2 insertions(+), 23 deletions(-) delete mode 100644 lib/data/models/restore_request.dart diff --git a/lib/data/models/restore_request.dart b/lib/data/models/restore_request.dart deleted file mode 100644 index 9d351f98f..000000000 --- a/lib/data/models/restore_request.dart +++ /dev/null @@ -1,21 +0,0 @@ -import 'dart:convert'; - -class RestoreMessage { - final int version; - final String action; - - RestoreMessage({ - this.version = 1, - this.action = 'restore-session', - }); - - Map toJson() => { - 'restore': { - 'version': version, - 'action': action, - 'payload': null, - }, - }; - - String toJsonString() => jsonEncode([toJson(), null]); -} \ No newline at end of file diff --git a/lib/features/key_manager/key_management_screen.dart b/lib/features/key_manager/key_management_screen.dart index 9ad633132..dea943a58 100644 --- a/lib/features/key_manager/key_management_screen.dart +++ b/lib/features/key_manager/key_management_screen.dart @@ -787,7 +787,7 @@ class _KeyManagementScreenState extends ConsumerState { try { final restoreService = ref.read(restoreServiceProvider); - await restoreService.restore(); + await restoreService.initRestoreProcess(); if (!mounted) return; scaffoldMessenger.showSnackBar( diff --git a/lib/features/restore/restore_manager.dart b/lib/features/restore/restore_manager.dart index e9b35c6bc..4a2a7479a 100644 --- a/lib/features/restore/restore_manager.dart +++ b/lib/features/restore/restore_manager.dart @@ -29,7 +29,7 @@ class RestoreService { _logger.i('Restore: importing mnemonic'); final keyManager = ref.read(keyManagerProvider); await keyManager.importMnemonic(mnemonic); - await restore(); + await initRestoreProcess(); } Future _clearAll() async { From 98c9f93b0588766c4b7eb316145592e79fa51ff9 Mon Sep 17 00:00:00 2001 From: bracr10 Date: Wed, 5 Nov 2025 14:16:26 -0600 Subject: [PATCH 09/25] refactor(restore): Setup event-driven infrastructure in RestoreService --- lib/features/restore/restore_manager.dart | 42 ++++++++++++++++++++--- 1 file changed, 38 insertions(+), 4 deletions(-) diff --git a/lib/features/restore/restore_manager.dart b/lib/features/restore/restore_manager.dart index 4a2a7479a..042d225b4 100644 --- a/lib/features/restore/restore_manager.dart +++ b/lib/features/restore/restore_manager.dart @@ -1,13 +1,17 @@ import 'dart:async'; +import 'dart:convert'; import 'package:dart_nostr/nostr/model/event/event.dart'; import 'package:dart_nostr/nostr/model/request/filter.dart'; import 'package:dart_nostr/nostr/model/request/request.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:logger/logger.dart'; import 'package:mostro_mobile/data/models/enums/action.dart'; +import 'package:mostro_mobile/data/models/nostr_event.dart'; +import 'package:mostro_mobile/data/models/orders_request.dart'; +import 'package:mostro_mobile/data/models/orders_response.dart'; import 'package:mostro_mobile/data/models/payload.dart'; +import 'package:mostro_mobile/data/models/restore_response.dart'; import 'package:mostro_mobile/features/key_manager/key_manager_provider.dart'; -import 'package:mostro_mobile/features/mostro/mostro_instance.dart'; import 'package:mostro_mobile/features/restore/restore_progress_notifier.dart'; import 'package:mostro_mobile/features/settings/settings_provider.dart'; import 'package:mostro_mobile/shared/providers/local_notifications_providers.dart'; @@ -17,11 +21,19 @@ import 'package:mostro_mobile/shared/providers/notifications_history_repository_ import 'package:mostro_mobile/shared/providers/session_notifier_provider.dart'; import 'package:mostro_mobile/data/models/mostro_message.dart'; +enum RestoreStage { + gettingRestoreData, + gettingOrdersDetails, + gettingTradeIndex, +} + class RestoreService { - + final Ref ref; final Logger _logger = Logger(); StreamSubscription? _tempSubscription; + Completer? _currentCompleter; + RestoreStage _currentStage = RestoreStage.gettingRestoreData; RestoreService(this.ref); @@ -43,9 +55,29 @@ class RestoreService { } } + Future _waitForEvent(RestoreStage stage, {Duration timeout = const Duration(seconds: 10)}) async { + _currentStage = stage; + _currentCompleter = Completer(); + + try { + final event = await _currentCompleter!.future.timeout( + timeout, + onTimeout: () { + throw TimeoutException('Stage $stage timed out after ${timeout.inSeconds}s'); + }, + ); + _logger.i('Restore: stage $_currentStage completed - Event: ${event.id}'); + return event; + } catch (e) { + _logger.e('Restore: stage $_currentStage failed', error: e); + rethrow; + } + } + void _handleTempSubscriptionsResponse(NostrEvent event) { - _logger.i('Restore: received restore notification event ${event.id}'); - + if (_currentCompleter != null && !_currentCompleter!.isCompleted) { + _currentCompleter!.complete(event); + } } Future> _createTempSubscription() async { @@ -56,6 +88,7 @@ class RestoreService { final filter = NostrFilter( kinds: [1059], p: [tempTradeKey.public], + limit: 0, //IMPORTANT: limit 0 indicates we don’t want historical events, only new ones https://nostrbook.dev/protocol/filter ); final request = NostrRequest(filters: [filter]); @@ -69,6 +102,7 @@ class RestoreService { cancelOnError: false, ); + _logger.i('Restore: temporary subscription created'); return subscription; } From 3f569751c42b66862e8491e6ef93a267e50b1c51 Mon Sep 17 00:00:00 2001 From: bracr10 Date: Wed, 5 Nov 2025 14:16:26 -0600 Subject: [PATCH 10/25] feat(restore): Implement staged restore process with order details fetching --- lib/features/restore/restore_manager.dart | 187 ++++++++++++++++++---- 1 file changed, 153 insertions(+), 34 deletions(-) diff --git a/lib/features/restore/restore_manager.dart b/lib/features/restore/restore_manager.dart index 042d225b4..5109bc81e 100644 --- a/lib/features/restore/restore_manager.dart +++ b/lib/features/restore/restore_manager.dart @@ -131,56 +131,175 @@ class RestoreService { await ref.read(nostrServiceProvider).publishEvent(wrappedEvent); _logger.i('Restore: request sent successfully'); } - - //Workflow: - // 1. Clear existing data - // 2. Create temporary subscription to key index 1 for restore notifications - // 3. Send restore request - Future restore() async { - //Clear existing data - await _clearAll(); - //Show restore overlay - final progress = ref.read(restoreProgressProvider.notifier); - progress.startRestore(); + Future> _extractRestoreData(NostrEvent event) async { + try { - //Validate master key - final keyManager = ref.read(keyManagerProvider); - if (keyManager.masterKeyPair == null) { - _logger.e('Restore: master key not found after import'); - throw Exception('Master key not found'); - } + final keyManager = ref.read(keyManagerProvider); + final tempTradeKey = await keyManager.deriveTradeKeyFromIndex(1); - //Validate Mostro public key - final settings = ref.read(settingsProvider); - if (settings.mostroPublicKey.isEmpty) { - _logger.e('Restore: Mostro not configured'); - throw Exception('Mostro not configured'); + // Unwrap the gift wrap (kind 1059) to get the rumor + final rumor = await event.mostroUnWrap(tempTradeKey); + + if (rumor.content == null || rumor.content!.isEmpty) { + throw Exception('Rumor content is empty'); + } + + final contentList = jsonDecode(rumor.content!) as List; + final messageData = contentList[0] as Map; + + // Extract payload from restore wrapper + final restoreWrapper = messageData['restore'] as Map; + final payload = restoreWrapper['payload'] as Map; + + final restoreData = RestoreData.fromJson(payload); + + Map orderIds = {}; + + for (var order in restoreData.orders) { + orderIds[order.id] = order.tradeIndex; + } + + for (var dispute in restoreData.disputes) { + orderIds[dispute.orderId] = dispute.tradeIndex; + } + + return orderIds; + } catch (e, stack) { + _logger.e('Restore: failed to extract restore data', error: e, stackTrace: stack); + rethrow; } + } + + Future _sendOrdersDetailsRequest(List orderIds) async { + _logger.i('Restore: sending orders details request for ${orderIds.length} orders'); - //Subscribe to temporary notifications - _tempSubscription = await _createTempSubscription(); + final keyManager = ref.read(keyManagerProvider); + final settings = ref.read(settingsProvider); + final tempTradeKey = await keyManager.deriveTradeKeyFromIndex(1); - //Send restore request - await _sendRestoreRequest(); + // Create orders message following same pattern as restore + final mostroMessage = MostroMessage( + action: Action.orders, + requestId: DateTime.now().millisecondsSinceEpoch, + payload: OrdersPayload(ids: orderIds), + ); - await Future.delayed(const Duration(seconds: 3)); - _tempSubscription?.cancel(); + final wrappedEvent = await mostroMessage.wrap( + tradeKey: tempTradeKey, + recipientPubKey: settings.mostroPublicKey, + masterKey: settings.fullPrivacyMode ? null : keyManager.masterKeyPair + ); + await ref.read(nostrServiceProvider).publishEvent(wrappedEvent); + _logger.i('Restore: orders details request sent successfully'); } + Future _extractOrdersDetails(NostrEvent event) async { + try { + _logger.i('Restore: extracting orders details from gift wrap event ${event.id}'); -// Future _buildRestoreRequest() async { - - // } + final keyManager = ref.read(keyManagerProvider); + final tempTradeKey = await keyManager.deriveTradeKeyFromIndex(1); + + // Unwrap the gift wrap (kind 1059) to get the rumor + final rumor = await event.mostroUnWrap(tempTradeKey); + _logger.i('Restore: unwrapped rumor event ${rumor.id}'); + + if (rumor.content == null || rumor.content!.isEmpty) { + throw Exception('Rumor content is empty'); + } + + // Parse response format: [{"order": {...}}, null] + final contentList = jsonDecode(rumor.content!) as List; + final messageData = contentList[0] as Map; + + // Extract payload from order wrapper + final orderWrapper = messageData['order'] as Map; + final payload = orderWrapper['payload'] as Map; + + final ordersResponse = OrdersResponse.fromJson(payload); + + _logger.i('Restore: found ${ordersResponse.orders.length} order details'); -//Future _buildOrderDetailsRequest(List orderIds) async { - // } + for (var order in ordersResponse.orders) { + _logger.i('Restore: Order ${order.id} - status: ${order.status}, amount: ${order.amount} sats'); + } + return ordersResponse; + } catch (e, stack) { + _logger.e('Restore: failed to extract orders details', error: e, stackTrace: stack); + rethrow; + } + } - + //Workflow: + // 1. Clear existing data + // 2. Create temporary subscription to key index 1 for restore notifications + // 3. Send restore request and wait for response (Stage 1: GettingRestoreData) + // 4. Process restore data and request order details (Stage 2: GettingOrdersDetails) + + Future initRestoreProcess() async { + try { + // Clear existing data + await _clearAll(); + + // Show restore overlay + final progress = ref.read(restoreProgressProvider.notifier); + progress.startRestore(); + + // Validate master key + final keyManager = ref.read(keyManagerProvider); + if (keyManager.masterKeyPair == null) { + _logger.e('Restore: master key not found after import'); + throw Exception('Master key not found'); + } + + // Validate Mostro public key + final settings = ref.read(settingsProvider); + if (settings.mostroPublicKey.isEmpty) { + _logger.e('Restore: Mostro not configured'); + throw Exception('Mostro not configured'); + } + + // Subscribe to temporary notifications + _tempSubscription = await _createTempSubscription(); + + // STAGE 1: Getting Restore Data + await _sendRestoreRequest(); + final restoreDataEvent = await _waitForEvent(RestoreStage.gettingRestoreData); + final restoreData = await _extractRestoreData(restoreDataEvent); + progress.setOrdersReceived(restoreData.length); + + if (restoreData.isEmpty) { + _logger.w('Restore: no orders or disputes to restore'); + return; + } + + // STAGE 2: Getting Orders Details + await _sendOrdersDetailsRequest(restoreData.keys.toList()); + final ordersDetailsEvent = await _waitForEvent(RestoreStage.gettingOrdersDetails); + final ordersResponse = await _extractOrdersDetails(ordersDetailsEvent); + + _logger.i('Restore: processing ${ordersResponse.toJson()} orders'); + + } on TimeoutException catch (e, stack) { + _logger.e('Restore: timeout error', error: e, stackTrace: stack); + rethrow; + } catch (e, stack) { + _logger.e('Restore: error during restore process', error: e, stackTrace: stack); + rethrow; + } finally { + // Cleanup: always cancel subscription + _logger.i('Restore: cleaning up subscription'); + await _tempSubscription?.cancel(); + _tempSubscription = null; + _currentCompleter = null; + ref.read(restoreProgressProvider.notifier).completeRestore(); + } + } } final restoreServiceProvider = Provider((ref) { From 0fad4e0396c9c2214e1318172c6253459a71c181 Mon Sep 17 00:00:00 2001 From: bracr10 Date: Wed, 5 Nov 2025 20:14:56 -0600 Subject: [PATCH 11/25] feat: Add LastTradeIndex action and response model --- lib/data/models/enums/action.dart | 3 ++- .../models/last_trade_index_response.dart | 21 +++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) create mode 100644 lib/data/models/last_trade_index_response.dart diff --git a/lib/data/models/enums/action.dart b/lib/data/models/enums/action.dart index a97d2811e..38418e82b 100644 --- a/lib/data/models/enums/action.dart +++ b/lib/data/models/enums/action.dart @@ -40,7 +40,8 @@ enum Action { sendDm('send-dm'), tradePubkey('trade-pubkey'), restore('restore-session'), - orders('orders'); + orders('orders'), + lastTradeIndex('last-trade-index'); final String value; diff --git a/lib/data/models/last_trade_index_response.dart b/lib/data/models/last_trade_index_response.dart new file mode 100644 index 000000000..2a3394054 --- /dev/null +++ b/lib/data/models/last_trade_index_response.dart @@ -0,0 +1,21 @@ +import 'package:mostro_mobile/data/models/payload.dart'; + +class LastTradeIndexResponse implements Payload { + final int tradeIndex; + + const LastTradeIndexResponse({required this.tradeIndex}); + + @override + String get type => 'last-trade-index'; + + factory LastTradeIndexResponse.fromJson(Map json) { + return LastTradeIndexResponse( + tradeIndex: json['trade_index'] as int, + ); + } + + @override + Map toJson() => { + 'trade_index': tradeIndex, + }; +} From b91c40f2a2d41a1821a976050c5a50cd645e41db Mon Sep 17 00:00:00 2001 From: bracr10 Date: Wed, 5 Nov 2025 20:14:56 -0600 Subject: [PATCH 12/25] chore: Update notification message mapping for new actions --- .../notifications/utils/notification_message_mapper.dart | 9 +++++---- .../notifications/widgets/notification_item.dart | 1 + 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/lib/features/notifications/utils/notification_message_mapper.dart b/lib/features/notifications/utils/notification_message_mapper.dart index 34aac105e..26e5f22ef 100644 --- a/lib/features/notifications/utils/notification_message_mapper.dart +++ b/lib/features/notifications/utils/notification_message_mapper.dart @@ -85,9 +85,9 @@ class NotificationMessageMapper { case mostro.Action.tradePubkey: return 'notification_order_update_title'; case mostro.Action.restore: - return 'TODO: implement restore title key'; case mostro.Action.orders: - return 'TODO: implement orders title key'; + case mostro.Action.lastTradeIndex: + return 'TODO: implement title key if needed'; } } @@ -181,9 +181,10 @@ class NotificationMessageMapper { case mostro.Action.tradePubkey: return 'notification_order_update_message'; case mostro.Action.restore: - return 'TODO: implement restore message key'; case mostro.Action.orders: - return 'TODO: implement orders message key'; + case mostro.Action.lastTradeIndex: + return 'TODO: implement message key if needed'; + } } diff --git a/lib/features/notifications/widgets/notification_item.dart b/lib/features/notifications/widgets/notification_item.dart index 0376d75a6..6b5d2902c 100644 --- a/lib/features/notifications/widgets/notification_item.dart +++ b/lib/features/notifications/widgets/notification_item.dart @@ -123,6 +123,7 @@ class NotificationItem extends ConsumerWidget { case mostro_action.Action.tradePubkey: case mostro_action.Action.restore: case mostro_action.Action.orders: + case mostro_action.Action.lastTradeIndex: break; } } From 27408563f262998a4667d3fd7ba3c932ba06037c Mon Sep 17 00:00:00 2001 From: bracr10 Date: Wed, 5 Nov 2025 20:14:57 -0600 Subject: [PATCH 13/25] refactor(restore): Update dependencies and cleanup logic in RestoreService --- lib/features/restore/restore_manager.dart | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/lib/features/restore/restore_manager.dart b/lib/features/restore/restore_manager.dart index 5109bc81e..a18781562 100644 --- a/lib/features/restore/restore_manager.dart +++ b/lib/features/restore/restore_manager.dart @@ -11,15 +11,19 @@ import 'package:mostro_mobile/data/models/orders_request.dart'; import 'package:mostro_mobile/data/models/orders_response.dart'; import 'package:mostro_mobile/data/models/payload.dart'; import 'package:mostro_mobile/data/models/restore_response.dart'; +import 'package:mostro_mobile/data/models/last_trade_index_response.dart'; import 'package:mostro_mobile/features/key_manager/key_manager_provider.dart'; import 'package:mostro_mobile/features/restore/restore_progress_notifier.dart'; import 'package:mostro_mobile/features/settings/settings_provider.dart'; -import 'package:mostro_mobile/shared/providers/local_notifications_providers.dart'; +import 'package:mostro_mobile/shared/providers/mostro_service_provider.dart'; import 'package:mostro_mobile/shared/providers/mostro_storage_provider.dart'; import 'package:mostro_mobile/shared/providers/nostr_service_provider.dart'; import 'package:mostro_mobile/shared/providers/notifications_history_repository_provider.dart'; import 'package:mostro_mobile/shared/providers/session_notifier_provider.dart'; import 'package:mostro_mobile/data/models/mostro_message.dart'; +import 'package:mostro_mobile/data/models/session.dart'; +import 'package:mostro_mobile/data/models/enums/role.dart'; + enum RestoreStage { gettingRestoreData, @@ -48,8 +52,9 @@ class RestoreService { try { await ref.read(sessionNotifierProvider.notifier).reset(); await ref.read(mostroStorageProvider).deleteAll(); + await ref.read(eventStorageProvider).deleteAll(); await ref.read(notificationsRepositoryProvider).clearAll(); - await ref.read(localNotificationsProvider).cancelAll(); + } catch (e) { _logger.w('Restore: cleanup error', error: e); } @@ -294,7 +299,6 @@ class RestoreService { } finally { // Cleanup: always cancel subscription _logger.i('Restore: cleaning up subscription'); - await _tempSubscription?.cancel(); _tempSubscription = null; _currentCompleter = null; ref.read(restoreProgressProvider.notifier).completeRestore(); From 2006194f072e53246c0ac499d53a5282c6626c94 Mon Sep 17 00:00:00 2001 From: bracr10 Date: Wed, 5 Nov 2025 20:14:58 -0600 Subject: [PATCH 14/25] feat(restore): Implement session-based restore and last trade index request --- lib/features/restore/restore_manager.dart | 168 +++++++++++++++++++++- 1 file changed, 167 insertions(+), 1 deletion(-) diff --git a/lib/features/restore/restore_manager.dart b/lib/features/restore/restore_manager.dart index a18781562..d97e08efe 100644 --- a/lib/features/restore/restore_manager.dart +++ b/lib/features/restore/restore_manager.dart @@ -238,12 +238,178 @@ class RestoreService { } } + Future _sendLastTradeIndexRequest() async { + _logger.i('Restore: sending last trade index request'); + + final keyManager = ref.read(keyManagerProvider); + final settings = ref.read(settingsProvider); + final tempTradeKey = await keyManager.deriveTradeKeyFromIndex(1); + + // Create last-trade-index message with EmptyPayload (serializes as null per protocol spec) + final mostroMessage = MostroMessage( + action: Action.lastTradeIndex, + payload: EmptyPayload(), + ); + + final wrappedEvent = await mostroMessage.wrap( + tradeKey: tempTradeKey, + recipientPubKey: settings.mostroPublicKey, + masterKey: settings.fullPrivacyMode ? null : keyManager.masterKeyPair + ); + + await ref.read(nostrServiceProvider).publishEvent(wrappedEvent); + _logger.i('Restore: last trade index request sent successfully'); + } + + Future _extractLastTradeIndex(NostrEvent event) async { + try { + _logger.i('Restore: extracting last trade index from gift wrap event ${event.id}'); + + final keyManager = ref.read(keyManagerProvider); + final tempTradeKey = await keyManager.deriveTradeKeyFromIndex(1); + + // Unwrap the gift wrap (kind 1059) to get the rumor + final rumor = await event.mostroUnWrap(tempTradeKey); + _logger.i('Restore: unwrapped rumor event ${rumor.id}'); + + if (rumor.content == null || rumor.content!.isEmpty) { + throw Exception('Rumor content is empty'); + } + + // Parse response format: [{"restore": {...}}, null] + final contentList = jsonDecode(rumor.content!) as List; + _logger.i('Restore: raw content: ${rumor.content}'); + final messageData = contentList[0] as Map; + + // Extract trade_index from restore wrapper + final restoreWrapper = messageData['restore'] as Map; + + final response = LastTradeIndexResponse.fromJson(restoreWrapper); + + _logger.i('Restore: last trade index is ${response.tradeIndex}'); + + return response; + } catch (e, stack) { + _logger.e('Restore: failed to extract last trade index', error: e, stackTrace: stack); + rethrow; + } + } + + Future restore(Map ordersIds, int lastTradeIndex, OrdersResponse ordersResponse) async { + try { + + final keyManager = ref.read(keyManagerProvider); + final sessionNotifier = ref.read(sessionNotifierProvider.notifier); + final progress = ref.read(restoreProgressProvider.notifier); + final settings = ref.read(settingsProvider); + + // Set the next trade key index + await keyManager.setCurrentKeyIndex(lastTradeIndex + 1); + + // Get master key + final masterKey = keyManager.masterKeyPair; + if (masterKey == null) { + throw Exception('Master key not available'); + } + + int restoredCount = 0; + + // Restore each order as a session + for (final entry in ordersIds.entries) { + final orderId = entry.key; + final tradeIndex = entry.value; + + // Find the order detail for this orderId + final orderDetail = ordersResponse.orders.firstWhere( + (order) => order.id == orderId, + orElse: () => throw Exception('Order detail not found for orderId: $orderId'), + ); + + // Derive trade key for this trade index + final tradeKey = keyManager.deriveTradeKeyPair(tradeIndex); + + // Determine role by comparing trade keys + Role? role; + if (orderDetail.buyerTradePubkey != null && orderDetail.buyerTradePubkey == tradeKey.public) { + role = Role.buyer; + } else if (orderDetail.sellerTradePubkey != null && orderDetail.sellerTradePubkey == tradeKey.public) { + role = Role.seller; + } + + // Use createdAt if available, otherwise use current time + final startTime = orderDetail.createdAt != null + ? DateTime.fromMillisecondsSinceEpoch(orderDetail.createdAt! * 1000) + : DateTime.now(); + + + // Create session + final session = Session( + masterKey: masterKey, + tradeKey: tradeKey, + keyIndex: tradeIndex, + fullPrivacy: settings.fullPrivacyMode, + startTime: startTime, + orderId: orderDetail.id, + role: role, + ); + + // Store session + await sessionNotifier.saveSession(session); + progress.incrementProgress(); + } + /* + // Convert OrderDetail to Order and store as message + final order = Order( + id: orderDetail.id, + kind: OrderType.fromString(orderDetail.kind), + status: Status.fromString(orderDetail.status), + amount: orderDetail.amount, + fiatCode: orderDetail.fiatCode, + minAmount: orderDetail.minAmount, + maxAmount: orderDetail.maxAmount, + fiatAmount: orderDetail.fiatAmount, + paymentMethod: orderDetail.paymentMethod, + premium: orderDetail.premium, + buyerTradePubkey: orderDetail.buyerTradePubkey, + sellerTradePubkey: orderDetail.sellerTradePubkey, + createdAt: orderDetail.createdAt, + expiresAt: orderDetail.expiresAt, + ); + + // Determine action based on status + final action = _mapStatusToAction(order.status); + + // Create MostroMessage with the order + final orderMessage = MostroMessage( + action: action, + id: orderDetail.id, + payload: order, + tradeIndex: tradeIndex, + timestamp: orderDetail.createdAt ?? DateTime.now().millisecondsSinceEpoch ~/ 1000, + ); + + // Store message with timestamp-based key + final messageKey = '$orderId-restore-${DateTime.now().millisecondsSinceEpoch}'; + await mostroStorage.addMessage(messageKey, orderMessage); + + restoredCount++; + progress.incrementProgress(); + } + */ + + } catch (e, stack) { + _logger.e('Restore: error during restore', error: e, stackTrace: stack); + rethrow; + } + } + //Workflow: // 1. Clear existing data // 2. Create temporary subscription to key index 1 for restore notifications // 3. Send restore request and wait for response (Stage 1: GettingRestoreData) // 4. Process restore data and request order details (Stage 2: GettingOrdersDetails) - + // 5. Request last trade index (Stage 3: GettingTradeIndex) + // 6. Complete restore process Future initRestoreProcess() async { try { // Clear existing data From 33d1c27cde24eaaba05a186cb979817769f89622 Mon Sep 17 00:00:00 2001 From: bracr10 Date: Wed, 5 Nov 2025 20:14:58 -0600 Subject: [PATCH 15/25] feat(restore): Orchestrate new restore process flow --- lib/features/restore/restore_manager.dart | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/lib/features/restore/restore_manager.dart b/lib/features/restore/restore_manager.dart index d97e08efe..daf59d09b 100644 --- a/lib/features/restore/restore_manager.dart +++ b/lib/features/restore/restore_manager.dart @@ -439,22 +439,29 @@ class RestoreService { // STAGE 1: Getting Restore Data await _sendRestoreRequest(); final restoreDataEvent = await _waitForEvent(RestoreStage.gettingRestoreData); - final restoreData = await _extractRestoreData(restoreDataEvent); - progress.setOrdersReceived(restoreData.length); + final ordersIds = await _extractRestoreData(restoreDataEvent); + progress.setOrdersReceived(ordersIds.length); - if (restoreData.isEmpty) { + if (ordersIds.isEmpty) { _logger.w('Restore: no orders or disputes to restore'); return; } // STAGE 2: Getting Orders Details - await _sendOrdersDetailsRequest(restoreData.keys.toList()); + await _sendOrdersDetailsRequest(ordersIds.keys.toList()); final ordersDetailsEvent = await _waitForEvent(RestoreStage.gettingOrdersDetails); final ordersResponse = await _extractOrdersDetails(ordersDetailsEvent); - _logger.i('Restore: processing ${ordersResponse.toJson()} orders'); + // STAGE 3: Getting Last Trade Index + //await _sendLastTradeIndexRequest(); + //final lastTradeIndexEvent = await _waitForEvent(RestoreStage.gettingTradeIndex); + //final lastTradeIndexResponse = await _extractLastTradeIndex(lastTradeIndexEvent); + //final lastTradeIndex = lastTradeIndexResponse.tradeIndex; - + // IMPORTANT: Cancel temporary subscription before proceeding to avoid interference + await _tempSubscription?.cancel(); + + await restore(ordersIds, 1, ordersResponse); } on TimeoutException catch (e, stack) { _logger.e('Restore: timeout error', error: e, stackTrace: stack); From 8fce8ba86f3e01b1a303e7d2f0c1cf19ad1b1aad Mon Sep 17 00:00:00 2001 From: bracr10 Date: Wed, 5 Nov 2025 20:14:59 -0600 Subject: [PATCH 16/25] chore: Clear notifications on master key regeneration --- lib/features/key_manager/key_management_screen.dart | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/features/key_manager/key_management_screen.dart b/lib/features/key_manager/key_management_screen.dart index dea943a58..288dc1a3b 100644 --- a/lib/features/key_manager/key_management_screen.dart +++ b/lib/features/key_manager/key_management_screen.dart @@ -12,6 +12,7 @@ import 'package:mostro_mobile/features/settings/settings_provider.dart'; import 'package:mostro_mobile/features/restore/restore_manager.dart'; import 'package:mostro_mobile/shared/providers.dart'; import 'package:mostro_mobile/generated/l10n.dart'; +import 'package:mostro_mobile/shared/providers/notifications_history_repository_provider.dart'; class KeyManagementScreen extends ConsumerStatefulWidget { const KeyManagementScreen({super.key}); @@ -70,6 +71,8 @@ class _KeyManagementScreenState extends ConsumerState { final eventStorage = ref.read(eventStorageProvider); await eventStorage.deleteAll(); + await ref.read(notificationsRepositoryProvider).clearAll(); + final keyManager = ref.read(keyManagerProvider); await keyManager.generateAndStoreMasterKey(); From d3f9960874101044aa3246ca2298b798b676bf80 Mon Sep 17 00:00:00 2001 From: bracr10 Date: Thu, 6 Nov 2025 14:21:47 -0600 Subject: [PATCH 17/25] feat(restore) : Manage index trade resquest and show overley --- lib/core/app.dart | 9 ++++ lib/data/models/mostro_message.dart | 6 +-- lib/features/restore/restore_manager.dart | 38 ++++++++++++----- .../restore/restore_progress_notifier.dart | 4 +- lib/services/mostro_service.dart | 41 +++++++++++++++++++ 5 files changed, 82 insertions(+), 16 deletions(-) diff --git a/lib/core/app.dart b/lib/core/app.dart index 1ce8b8d39..6ef74ba63 100644 --- a/lib/core/app.dart +++ b/lib/core/app.dart @@ -16,6 +16,7 @@ import 'package:mostro_mobile/shared/providers/app_init_provider.dart'; import 'package:mostro_mobile/features/settings/settings_provider.dart'; import 'package:mostro_mobile/shared/notifiers/locale_notifier.dart'; import 'package:mostro_mobile/features/walkthrough/providers/first_run_provider.dart'; +import 'package:mostro_mobile/features/restore/restore_overlay.dart'; class MostroApp extends ConsumerStatefulWidget { const MostroApp({super.key}); @@ -163,6 +164,14 @@ class _MostroAppState extends ConsumerState { theme: AppTheme.theme, darkTheme: AppTheme.theme, routerConfig: _router!, + builder: (context, child) { + return Stack( + children: [ + if (child != null) child, + const RestoreOverlay(), + ], + ); + }, // Use language override from settings if available, otherwise let callback handle detection locale: settings.selectedLanguage != null ? Locale(settings.selectedLanguage!) diff --git a/lib/data/models/mostro_message.dart b/lib/data/models/mostro_message.dart index 6768332b8..17d2828ef 100644 --- a/lib/data/models/mostro_message.dart +++ b/lib/data/models/mostro_message.dart @@ -99,8 +99,8 @@ class MostroMessage { } String sign(NostrKeyPairs keyPair) { - // Use 'restore' key for restore action, 'order' for everything else - final wrapperKey = action == Action.restore ? 'restore' : 'order'; + // Use 'restore' key for restore and last-trade-index actions, 'order' for everything else + final wrapperKey = action == Action.restore || action == Action.lastTradeIndex ? 'restore' : 'order'; final message = {wrapperKey: toJson()}; final serializedEvent = jsonEncode(message); final bytes = utf8.encode(serializedEvent); @@ -112,7 +112,7 @@ class MostroMessage { String serialize({NostrKeyPairs? keyPair}) { // Use 'restore' key for restore action, 'order' for everything else - final wrapperKey = action == Action.restore ? 'restore' : 'order'; + final wrapperKey = action == Action.restore || action == Action.lastTradeIndex ? 'restore' : 'order'; final message = {wrapperKey: toJson()}; final serializedEvent = jsonEncode(message); final signature = (keyPair != null) ? '"${sign(keyPair)}"' : null; diff --git a/lib/features/restore/restore_manager.dart b/lib/features/restore/restore_manager.dart index daf59d09b..fdf9ea339 100644 --- a/lib/features/restore/restore_manager.dart +++ b/lib/features/restore/restore_manager.dart @@ -14,6 +14,7 @@ import 'package:mostro_mobile/data/models/restore_response.dart'; import 'package:mostro_mobile/data/models/last_trade_index_response.dart'; import 'package:mostro_mobile/features/key_manager/key_manager_provider.dart'; import 'package:mostro_mobile/features/restore/restore_progress_notifier.dart'; +import 'package:mostro_mobile/features/restore/restore_progress_state.dart'; import 'package:mostro_mobile/features/settings/settings_provider.dart'; import 'package:mostro_mobile/shared/providers/mostro_service_provider.dart'; import 'package:mostro_mobile/shared/providers/mostro_storage_provider.dart'; @@ -276,9 +277,7 @@ class RestoreService { throw Exception('Rumor content is empty'); } - // Parse response format: [{"restore": {...}}, null] final contentList = jsonDecode(rumor.content!) as List; - _logger.i('Restore: raw content: ${rumor.content}'); final messageData = contentList[0] as Map; // Extract trade_index from restore wrapper @@ -312,8 +311,6 @@ class RestoreService { throw Exception('Master key not available'); } - int restoredCount = 0; - // Restore each order as a session for (final entry in ordersIds.entries) { final orderId = entry.key; @@ -437,44 +434,63 @@ class RestoreService { _tempSubscription = await _createTempSubscription(); // STAGE 1: Getting Restore Data + progress.updateStep(RestoreStep.requesting); await _sendRestoreRequest(); final restoreDataEvent = await _waitForEvent(RestoreStage.gettingRestoreData); final ordersIds = await _extractRestoreData(restoreDataEvent); progress.setOrdersReceived(ordersIds.length); - if (ordersIds.isEmpty) { + if (ordersIds.isEmpty) { _logger.w('Restore: no orders or disputes to restore'); + await _sendLastTradeIndexRequest(); + final lastTradeIndexEvent = await _waitForEvent(RestoreStage.gettingTradeIndex); + final lastTradeIndexResponse = await _extractLastTradeIndex(lastTradeIndexEvent); + final lastTradeIndex = lastTradeIndexResponse.tradeIndex; + await keyManager.setCurrentKeyIndex(lastTradeIndex + 1); + progress.completeRestore(); return; } // STAGE 2: Getting Orders Details + progress.updateStep(RestoreStep.loadingDetails); await _sendOrdersDetailsRequest(ordersIds.keys.toList()); final ordersDetailsEvent = await _waitForEvent(RestoreStage.gettingOrdersDetails); final ordersResponse = await _extractOrdersDetails(ordersDetailsEvent); // STAGE 3: Getting Last Trade Index - //await _sendLastTradeIndexRequest(); - //final lastTradeIndexEvent = await _waitForEvent(RestoreStage.gettingTradeIndex); - //final lastTradeIndexResponse = await _extractLastTradeIndex(lastTradeIndexEvent); - //final lastTradeIndex = lastTradeIndexResponse.tradeIndex; + await _sendLastTradeIndexRequest(); + final lastTradeIndexEvent = await _waitForEvent(RestoreStage.gettingTradeIndex); + final lastTradeIndexResponse = await _extractLastTradeIndex(lastTradeIndexEvent); + final lastTradeIndex = lastTradeIndexResponse.tradeIndex; // IMPORTANT: Cancel temporary subscription before proceeding to avoid interference await _tempSubscription?.cancel(); + _tempSubscription = null; - await restore(ordersIds, 1, ordersResponse); + // STAGE 4: Processing and restoring sessions + progress.updateStep(RestoreStep.processingRoles); + await restore(ordersIds, lastTradeIndex, ordersResponse); } on TimeoutException catch (e, stack) { _logger.e('Restore: timeout error', error: e, stackTrace: stack); + ref.read(restoreProgressProvider.notifier).showError('Request timeout'); rethrow; } catch (e, stack) { _logger.e('Restore: error during restore process', error: e, stackTrace: stack); + ref.read(restoreProgressProvider.notifier).showError(e.toString()); rethrow; } finally { // Cleanup: always cancel subscription _logger.i('Restore: cleaning up subscription'); + await _tempSubscription?.cancel(); _tempSubscription = null; _currentCompleter = null; - ref.read(restoreProgressProvider.notifier).completeRestore(); + + // Only call completeRestore if not in error state + final currentState = ref.read(restoreProgressProvider); + if (currentState.step != RestoreStep.error) { + ref.read(restoreProgressProvider.notifier).completeRestore(); + } } } } diff --git a/lib/features/restore/restore_progress_notifier.dart b/lib/features/restore/restore_progress_notifier.dart index e59bd66ee..0ee622795 100644 --- a/lib/features/restore/restore_progress_notifier.dart +++ b/lib/features/restore/restore_progress_notifier.dart @@ -58,8 +58,8 @@ class RestoreProgressNotifier extends StateNotifier { step: RestoreStep.completed, ); - // Auto-hide after 1 second - Future.delayed(const Duration(seconds: 1), () { + // Auto-hide after 3 seconds + Future.delayed(const Duration(seconds: 3), () { if (mounted) { hide(); } diff --git a/lib/services/mostro_service.dart b/lib/services/mostro_service.dart index 6f14bd47c..60a3f21f3 100644 --- a/lib/services/mostro_service.dart +++ b/lib/services/mostro_service.dart @@ -41,6 +41,40 @@ class MostroService { _logger.i('MostroService disposed'); } + bool _isRestorePayload(Map json) { + // Check if this is a restore-specific payload that should be ignored + // These payloads are only used during restore process via temporary trade key + final wrapper = json['restore'] ?? json['order']; + if (wrapper == null || wrapper['payload'] == null) return false; + + final payload = wrapper['payload'] as Map; + + // RestoreData: has 'restore_data' wrapper with 'orders' and 'disputes' arrays + if (payload.containsKey('restore_data')) { + return true; + } + + // LastTradeIndexResponse: has 'trade_index' field + if (payload.containsKey('trade_index')) { + return true; + } + + // OrdersResponse: has 'orders' array with OrderDetail objects + // OrderDetail has buyer_trade_pubkey/seller_trade_pubkey fields + if (payload.containsKey('orders') && payload['orders'] is List) { + final orders = payload['orders'] as List; + if (orders.isNotEmpty && orders[0] is Map) { + final firstOrder = orders[0] as Map; + if (firstOrder.containsKey('buyer_trade_pubkey') || + firstOrder.containsKey('seller_trade_pubkey')) { + return true; + } + } + } + + return false; + } + Future _onData(NostrEvent event) async { final eventStore = ref.read(eventStorageProvider); @@ -65,6 +99,7 @@ class MostroService { try { final decryptedEvent = await event.unWrap(privateKey); + if (decryptedEvent.content == null) return; final result = jsonDecode(decryptedEvent.content!); @@ -81,7 +116,13 @@ class MostroService { return; } + // Skip restore-specific payloads that arrive as historical events due to temporary subscription + if (result[0] is Map && _isRestorePayload(result[0] as Map)) { + return; + } + final msg = MostroMessage.fromJson(result[0]); + final messageStorage = ref.read(mostroStorageProvider); // Use decryptedEvent.id if available, otherwise fall back to original event.id From b93271567b5c799edf2f659769f647c9143fcfb7 Mon Sep 17 00:00:00 2001 From: bracr10 Date: Sun, 9 Nov 2025 12:08:31 -0600 Subject: [PATCH 18/25] feat : build msg logic --- .../repositories/open_orders_repository.dart | 7 + .../order/notfiers/order_notifier.dart | 7 + lib/features/restore/restore_manager.dart | 203 +++++++++++++----- 3 files changed, 164 insertions(+), 53 deletions(-) diff --git a/lib/data/repositories/open_orders_repository.dart b/lib/data/repositories/open_orders_repository.dart index 325049d00..ea0be7da8 100644 --- a/lib/data/repositories/open_orders_repository.dart +++ b/lib/data/repositories/open_orders_repository.dart @@ -137,4 +137,11 @@ class OpenOrdersRepository implements OrderRepository { _subscribeToOrders(); _emitEvents(); } + + /// Clear in-memory order cache and reload from relays (used during account restore) + void clearCache() { + _logger.i('Clearing order cache and reloading'); + _events.clear(); + _subscribeToOrders(); // Resubscribe to reload orders from relays + } } diff --git a/lib/features/order/notfiers/order_notifier.dart b/lib/features/order/notfiers/order_notifier.dart index 5e8f161ea..8d854976d 100644 --- a/lib/features/order/notfiers/order_notifier.dart +++ b/lib/features/order/notfiers/order_notifier.dart @@ -139,6 +139,13 @@ class OrderNotifier extends AbstractMostroNotifier { ); } + /// Update state from MostroMessage (used during restore) + void updateStateFromMessage(MostroMessage message) { + if (mounted) { + state = state.updateWith(message); + } + } + /// Subscribe to public events (38383) to detect automatic order cancellation void _subscribeToPublicEvents() { _publicEventsSubscription = ref.listen( diff --git a/lib/features/restore/restore_manager.dart b/lib/features/restore/restore_manager.dart index fdf9ea339..db02d6c57 100644 --- a/lib/features/restore/restore_manager.dart +++ b/lib/features/restore/restore_manager.dart @@ -6,24 +6,31 @@ import 'package:dart_nostr/nostr/model/request/request.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:logger/logger.dart'; import 'package:mostro_mobile/data/models/enums/action.dart'; +import 'package:mostro_mobile/data/models/enums/role.dart'; +import 'package:mostro_mobile/data/models/enums/order_type.dart'; +import 'package:mostro_mobile/data/models/enums/status.dart'; +import 'package:mostro_mobile/data/models/order.dart'; +import 'package:mostro_mobile/data/models/last_trade_index_response.dart'; +import 'package:mostro_mobile/data/models/mostro_message.dart'; import 'package:mostro_mobile/data/models/nostr_event.dart'; import 'package:mostro_mobile/data/models/orders_request.dart'; import 'package:mostro_mobile/data/models/orders_response.dart'; import 'package:mostro_mobile/data/models/payload.dart'; import 'package:mostro_mobile/data/models/restore_response.dart'; -import 'package:mostro_mobile/data/models/last_trade_index_response.dart'; +import 'package:mostro_mobile/data/models/session.dart'; import 'package:mostro_mobile/features/key_manager/key_manager_provider.dart'; import 'package:mostro_mobile/features/restore/restore_progress_notifier.dart'; import 'package:mostro_mobile/features/restore/restore_progress_state.dart'; import 'package:mostro_mobile/features/settings/settings_provider.dart'; import 'package:mostro_mobile/shared/providers/mostro_service_provider.dart'; import 'package:mostro_mobile/shared/providers/mostro_storage_provider.dart'; +import 'package:mostro_mobile/shared/providers/navigation_notifier_provider.dart'; import 'package:mostro_mobile/shared/providers/nostr_service_provider.dart'; import 'package:mostro_mobile/shared/providers/notifications_history_repository_provider.dart'; +import 'package:mostro_mobile/shared/providers/order_repository_provider.dart'; import 'package:mostro_mobile/shared/providers/session_notifier_provider.dart'; -import 'package:mostro_mobile/data/models/mostro_message.dart'; -import 'package:mostro_mobile/data/models/session.dart'; -import 'package:mostro_mobile/data/models/enums/role.dart'; +import 'package:mostro_mobile/features/order/providers/order_notifier_provider.dart'; +import 'package:mostro_mobile/features/notifications/providers/notifications_provider.dart'; enum RestoreStage { @@ -55,7 +62,8 @@ class RestoreService { await ref.read(mostroStorageProvider).deleteAll(); await ref.read(eventStorageProvider).deleteAll(); await ref.read(notificationsRepositoryProvider).clearAll(); - + ref.read(orderRepositoryProvider).clearCache(); + } catch (e) { _logger.w('Restore: cleanup error', error: e); } @@ -154,9 +162,26 @@ class RestoreService { final contentList = jsonDecode(rumor.content!) as List; final messageData = contentList[0] as Map; + // Check if Mostro returned cant-do (not found) + if (messageData.containsKey('cant-do')) { + _logger.w('Restore: Mostro returned cant-do for restore data (no orders found)'); + return {}; + } + // Extract payload from restore wrapper - final restoreWrapper = messageData['restore'] as Map; - final payload = restoreWrapper['payload'] as Map; + final restoreWrapper = messageData['restore'] as Map?; + + if (restoreWrapper == null) { + _logger.w('Restore: no restore wrapper found, returning empty orders'); + return {}; + } + + final payload = restoreWrapper['payload'] as Map?; + + if (payload == null) { + _logger.w('Restore: no payload found in restore wrapper, returning empty orders'); + return {}; + } final restoreData = RestoreData.fromJson(payload); @@ -280,8 +305,19 @@ class RestoreService { final contentList = jsonDecode(rumor.content!) as List; final messageData = contentList[0] as Map; + // Check if Mostro returned cant-do (not found) + if (messageData.containsKey('cant-do')) { + _logger.w('Restore: Mostro returned cant-do for last trade index, defaulting to 0'); + return LastTradeIndexResponse(tradeIndex: 0); + } + // Extract trade_index from restore wrapper - final restoreWrapper = messageData['restore'] as Map; + final restoreWrapper = messageData['restore'] as Map?; + + if (restoreWrapper == null) { + _logger.w('Restore: no restore wrapper found, defaulting trade index to 0'); + return LastTradeIndexResponse(tradeIndex: 0); + } final response = LastTradeIndexResponse.fromJson(restoreWrapper); @@ -294,6 +330,44 @@ class RestoreService { } } + /// Maps Status to the appropriate Action for restored orders + Action _getActionFromStatus(Status status) { + switch (status) { + case Status.pending: + return Action.newOrder; + case Status.waitingBuyerInvoice: + return Action.waitingBuyerInvoice; + case Status.waitingPayment: + return Action.waitingSellerToPay; + case Status.active: + return Action.buyerTookOrder; + case Status.fiatSent: + return Action.fiatSentOk; + case Status.settledHoldInvoice: + return Action.holdInvoicePaymentSettled; + case Status.success: + return Action.purchaseCompleted; + case Status.canceled: + return Action.canceled; + case Status.canceledByAdmin: + return Action.adminCanceled; + case Status.settledByAdmin: + return Action.adminSettled; + case Status.completedByAdmin: + return Action.adminSettled; + case Status.dispute: + return Action.disputeInitiatedByPeer; // Default to peer-initiated + case Status.expired: + return Action.canceled; + case Status.paymentFailed: + return Action.paymentFailed; + case Status.cooperativelyCanceled: + return Action.cooperativeCancelAccepted; + case Status.inProgress: + return Action.buyerTookOrder; + } + } + Future restore(Map ordersIds, int lastTradeIndex, OrdersResponse ordersResponse) async { try { @@ -302,6 +376,7 @@ class RestoreService { final progress = ref.read(restoreProgressProvider.notifier); final settings = ref.read(settingsProvider); + // Set the next trade key index await keyManager.setCurrentKeyIndex(lastTradeIndex + 1); @@ -333,66 +408,86 @@ class RestoreService { role = Role.seller; } - // Use createdAt if available, otherwise use current time - final startTime = orderDetail.createdAt != null - ? DateTime.fromMillisecondsSinceEpoch(orderDetail.createdAt! * 1000) - : DateTime.now(); - - - // Create session final session = Session( masterKey: masterKey, tradeKey: tradeKey, keyIndex: tradeIndex, fullPrivacy: settings.fullPrivacyMode, - startTime: startTime, + startTime: DateTime.now(), orderId: orderDetail.id, role: role, ); // Store session await sessionNotifier.saveSession(session); + + _logger.i('Restore: created session for order ${orderDetail.id} (isRestored: true)'); + progress.incrementProgress(); } - /* - // Convert OrderDetail to Order and store as message - final order = Order( - id: orderDetail.id, - kind: OrderType.fromString(orderDetail.kind), - status: Status.fromString(orderDetail.status), - amount: orderDetail.amount, - fiatCode: orderDetail.fiatCode, - minAmount: orderDetail.minAmount, - maxAmount: orderDetail.maxAmount, - fiatAmount: orderDetail.fiatAmount, - paymentMethod: orderDetail.paymentMethod, - premium: orderDetail.premium, - buyerTradePubkey: orderDetail.buyerTradePubkey, - sellerTradePubkey: orderDetail.sellerTradePubkey, - createdAt: orderDetail.createdAt, - expiresAt: orderDetail.expiresAt, - ); - // Determine action based on status - final action = _mapStatusToAction(order.status); + // Wait for historical messages to arrive and be saved to storage + _logger.i('Restore: waiting 5 seconds for historical messages to be saved...'); + await Future.delayed(const Duration(seconds: 5)); + + // Build MostroMessages from ordersResponse and update state (source of truth from Mostro) + _logger.i('Restore: building messages for ${ordersResponse.orders.length} orders from ordersResponse'); + final storage = ref.read(mostroStorageProvider); + + for (final orderDetail in ordersResponse.orders) { + try { + // Convert OrderDetail to Order + final order = Order( + id: orderDetail.id, + kind: OrderType.fromString(orderDetail.kind), + status: Status.fromString(orderDetail.status), + amount: orderDetail.amount, + fiatCode: orderDetail.fiatCode, + minAmount: orderDetail.minAmount, + maxAmount: orderDetail.maxAmount, + fiatAmount: orderDetail.fiatAmount, + paymentMethod: orderDetail.paymentMethod, + premium: orderDetail.premium, + buyerTradePubkey: orderDetail.buyerTradePubkey, + sellerTradePubkey: orderDetail.sellerTradePubkey, + createdAt: orderDetail.createdAt, + expiresAt: orderDetail.expiresAt, + ); + + // Derive appropriate action from status + final action = _getActionFromStatus(order.status); + + // Build MostroMessage with Order payload + final mostroMessage = MostroMessage( + id: orderDetail.id, + action: action, + payload: order, + timestamp: orderDetail.createdAt ?? DateTime.now().millisecondsSinceEpoch, + ); + + // Save message to storage for future sync() + final key = '${orderDetail.id}_restore_${action.value}_${DateTime.now().millisecondsSinceEpoch}'; + await storage.addMessage(key, mostroMessage); + + // Update state using public method that calls updateWith internally + ref.read(orderNotifierProvider(orderDetail.id).notifier).updateStateFromMessage(mostroMessage); + + _logger.i('Restore: built message for order ${orderDetail.id} with status ${orderDetail.status}, action $action'); + } catch (e, stack) { + _logger.e('Restore: failed to process order ${orderDetail.id}', error: e, stackTrace: stack); + } + } - // Create MostroMessage with the order - final orderMessage = MostroMessage( - action: action, - id: orderDetail.id, - payload: order, - tradeIndex: tradeIndex, - timestamp: orderDetail.createdAt ?? DateTime.now().millisecondsSinceEpoch ~/ 1000, - ); + _logger.i('Restore: state update completed for all orders'); - // Store message with timestamp-based key - final messageKey = '$orderId-restore-${DateTime.now().millisecondsSinceEpoch}'; - await mostroStorage.addMessage(messageKey, orderMessage); + // Navigate to home and clear notification tray + final navProvider = ref.read(navigationProvider.notifier); + navProvider.go('/'); - restoredCount++; - progress.incrementProgress(); - } - */ + final notifProvider = ref.read(notificationActionsProvider.notifier); + notifProvider.clearAll(); + + _logger.i('Restore: navigated to home and cleared notification tray'); } catch (e, stack) { _logger.e('Restore: error during restore', error: e, stackTrace: stack); @@ -439,7 +534,7 @@ class RestoreService { final restoreDataEvent = await _waitForEvent(RestoreStage.gettingRestoreData); final ordersIds = await _extractRestoreData(restoreDataEvent); progress.setOrdersReceived(ordersIds.length); - + if (ordersIds.isEmpty) { _logger.w('Restore: no orders or disputes to restore'); await _sendLastTradeIndexRequest(); @@ -453,7 +548,9 @@ class RestoreService { // STAGE 2: Getting Orders Details progress.updateStep(RestoreStep.loadingDetails); - await _sendOrdersDetailsRequest(ordersIds.keys.toList()); + final orderIdsList = ordersIds.keys.toList(); + _logger.i('Restore: requesting details for ${orderIdsList.length} orders: $orderIdsList'); + await _sendOrdersDetailsRequest(orderIdsList); final ordersDetailsEvent = await _waitForEvent(RestoreStage.gettingOrdersDetails); final ordersResponse = await _extractOrdersDetails(ordersDetailsEvent); From 54db81182f806bab4ab3c44b05c2f5dee399295b Mon Sep 17 00:00:00 2001 From: bracr10 Date: Sun, 9 Nov 2025 17:55:49 -0600 Subject: [PATCH 19/25] feat : disputes restore management --- .../key_manager/key_management_screen.dart | 46 +----- .../notfiers/abstract_mostro_notifier.dart | 19 ++- .../order/notfiers/order_notifier.dart | 7 + lib/features/restore/restore_manager.dart | 141 +++++++++++++++--- lib/features/restore/restore_overlay.dart | 2 +- lib/features/settings/settings_screen.dart | 18 ++- lib/l10n/intl_en.arb | 3 +- lib/l10n/intl_es.arb | 3 +- lib/l10n/intl_it.arb | 3 +- 9 files changed, 170 insertions(+), 72 deletions(-) diff --git a/lib/features/key_manager/key_management_screen.dart b/lib/features/key_manager/key_management_screen.dart index 288dc1a3b..f6f68fc96 100644 --- a/lib/features/key_manager/key_management_screen.dart +++ b/lib/features/key_manager/key_management_screen.dart @@ -3,7 +3,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:heroicons/heroicons.dart'; -import 'package:logger/logger.dart'; import 'package:lucide_icons/lucide_icons.dart'; import 'package:mostro_mobile/core/app_theme.dart'; import 'package:mostro_mobile/features/key_manager/key_manager_provider.dart'; @@ -23,7 +22,6 @@ class KeyManagementScreen extends ConsumerStatefulWidget { } class _KeyManagementScreenState extends ConsumerState { - final Logger _logger = Logger(); String? _mnemonic; int? _tradeKeyIndex; bool _loading = false; @@ -783,26 +781,8 @@ class _KeyManagementScreenState extends ConsumerState { onPressed: () async { Navigator.of(dialogContext).pop(); - // Capture context-dependent values before async gap - final successMessage = S.of(context)!.refreshSuccessful; - final scaffoldMessenger = ScaffoldMessenger.of(context); - final localizations = S.of(context)!; - - try { - final restoreService = ref.read(restoreServiceProvider); - await restoreService.initRestoreProcess(); - - if (!mounted) return; - scaffoldMessenger.showSnackBar( - SnackBar(content: Text(successMessage)), - ); - } catch (e) { - _logger.e('Refresh failed: $e'); - if (!mounted) return; - scaffoldMessenger.showSnackBar( - SnackBar(content: Text(localizations.refreshFailed(e.toString()))), - ); - } + final restoreService = ref.read(restoreServiceProvider); + await restoreService.initRestoreProcess(); }, style: ElevatedButton.styleFrom( backgroundColor: AppTheme.activeColor, @@ -827,9 +807,6 @@ class _KeyManagementScreenState extends ConsumerState { } Future _showImportMnemonicDialog(BuildContext context) async { - // Capture context-dependent values before async gap - final scaffoldMessenger = ScaffoldMessenger.of(context); - final localizations = S.of(context)!; final mnemonic = await showDialog( context: context, @@ -839,23 +816,8 @@ class _KeyManagementScreenState extends ConsumerState { ); if (mnemonic != null && mnemonic.isNotEmpty) { - - try { - final restoreService = ref.read(restoreServiceProvider); - await restoreService.importMnemonicAndRestore(mnemonic); - await _loadKeys(); - - if (!mounted) return; - scaffoldMessenger.showSnackBar( - SnackBar(content: Text(localizations.keyImportedSuccessfully)), - ); - } catch (e) { - _logger.e('Import failed: $e'); - if (!mounted) return; - scaffoldMessenger.showSnackBar( - SnackBar(content: Text(localizations.importFailed(e.toString()))), - ); - } + final restoreService = ref.read(restoreServiceProvider); + await restoreService.importMnemonicAndRestore(mnemonic); } } } \ No newline at end of file diff --git a/lib/features/order/notfiers/abstract_mostro_notifier.dart b/lib/features/order/notfiers/abstract_mostro_notifier.dart index acd56c63e..e0713607b 100644 --- a/lib/features/order/notfiers/abstract_mostro_notifier.dart +++ b/lib/features/order/notfiers/abstract_mostro_notifier.dart @@ -21,10 +21,19 @@ class AbstractMostroNotifier extends StateNotifier { ProviderSubscription>? subscription; final Set _processedEventIds = {}; - + // Timer storage for orphan session cleanup static final Map _sessionTimeouts = {}; + // Restore mode flag - blocks all message processing during restore + static bool _isRestoring = false; + + static void setRestoring(bool value) { + _isRestoring = value; + } + + static bool get isRestoring => _isRestoring; + AbstractMostroNotifier( this.orderId, this.ref, { @@ -48,6 +57,12 @@ class AbstractMostroNotifier extends StateNotifier { (_, next) { next.when( data: (MostroMessage? msg) { + // Skip all processing during restore - messages are saved but state is not updated + if (isRestoring) { + logger.d('Skipping message processing during restore: ${msg?.action}'); + return; + } + if (kDebugMode) { logger.i('Received message: ${msg?.toJson()}'); } else { @@ -56,7 +71,7 @@ class AbstractMostroNotifier extends StateNotifier { if (msg != null) { // Cancel timer on ANY response from Mostro for this order cancelSessionTimeoutCleanup(orderId); - + if (mounted) { state = state.updateWith(msg); } diff --git a/lib/features/order/notfiers/order_notifier.dart b/lib/features/order/notfiers/order_notifier.dart index 8d854976d..dc1a1446a 100644 --- a/lib/features/order/notfiers/order_notifier.dart +++ b/lib/features/order/notfiers/order_notifier.dart @@ -146,6 +146,13 @@ class OrderNotifier extends AbstractMostroNotifier { } } + /// Update dispute in state (used during restore) + void updateDispute(Dispute dispute) { + if (mounted) { + state = state.copyWith(dispute: dispute); + } + } + /// Subscribe to public events (38383) to detect automatic order cancellation void _subscribeToPublicEvents() { _publicEventsSubscription = ref.listen( diff --git a/lib/features/restore/restore_manager.dart b/lib/features/restore/restore_manager.dart index db02d6c57..d5ca426e1 100644 --- a/lib/features/restore/restore_manager.dart +++ b/lib/features/restore/restore_manager.dart @@ -12,6 +12,7 @@ import 'package:mostro_mobile/data/models/enums/status.dart'; import 'package:mostro_mobile/data/models/order.dart'; import 'package:mostro_mobile/data/models/last_trade_index_response.dart'; import 'package:mostro_mobile/data/models/mostro_message.dart'; +import 'package:mostro_mobile/data/models/dispute.dart'; import 'package:mostro_mobile/data/models/nostr_event.dart'; import 'package:mostro_mobile/data/models/orders_request.dart'; import 'package:mostro_mobile/data/models/orders_response.dart'; @@ -31,6 +32,7 @@ import 'package:mostro_mobile/shared/providers/order_repository_provider.dart'; import 'package:mostro_mobile/shared/providers/session_notifier_provider.dart'; import 'package:mostro_mobile/features/order/providers/order_notifier_provider.dart'; import 'package:mostro_mobile/features/notifications/providers/notifications_provider.dart'; +import 'package:mostro_mobile/features/order/notfiers/abstract_mostro_notifier.dart'; enum RestoreStage { @@ -51,8 +53,22 @@ class RestoreService { Future importMnemonicAndRestore(String mnemonic) async { _logger.i('Restore: importing mnemonic'); + + // Import the mnemonic - this saves to storage final keyManager = ref.read(keyManagerProvider); await keyManager.importMnemonic(mnemonic); + _logger.i('Restore: mnemonic imported and saved to storage'); + + // Invalidate keyManagerProvider to force re-initialization + // This ensures all providers get a fresh instance with the new key + ref.invalidate(keyManagerProvider); + _logger.i('Restore: invalidated keyManagerProvider'); + + // Get the new instance and initialize it + final newKeyManager = ref.read(keyManagerProvider); + await newKeyManager.init(); + _logger.i('Restore: new keyManager initialized, masterKeyPair=${newKeyManager.masterKeyPair != null}'); + await initRestoreProcess(); } @@ -146,7 +162,7 @@ class RestoreService { _logger.i('Restore: request sent successfully'); } - Future> _extractRestoreData(NostrEvent event) async { + Future<({Map orderIds, List disputes})> _extractRestoreData(NostrEvent event) async { try { final keyManager = ref.read(keyManagerProvider); @@ -165,7 +181,7 @@ class RestoreService { // Check if Mostro returned cant-do (not found) if (messageData.containsKey('cant-do')) { _logger.w('Restore: Mostro returned cant-do for restore data (no orders found)'); - return {}; + return (orderIds: {}, disputes: []); } // Extract payload from restore wrapper @@ -173,19 +189,19 @@ class RestoreService { if (restoreWrapper == null) { _logger.w('Restore: no restore wrapper found, returning empty orders'); - return {}; + return (orderIds: {}, disputes: []); } final payload = restoreWrapper['payload'] as Map?; if (payload == null) { _logger.w('Restore: no payload found in restore wrapper, returning empty orders'); - return {}; + return (orderIds: {}, disputes: []); } final restoreData = RestoreData.fromJson(payload); - Map orderIds = {}; + final Map orderIds = {}; for (var order in restoreData.orders) { orderIds[order.id] = order.tradeIndex; @@ -195,7 +211,9 @@ class RestoreService { orderIds[dispute.orderId] = dispute.tradeIndex; } - return orderIds; + final List disputesList = restoreData.disputes; + + return (orderIds: orderIds, disputes: disputesList); } catch (e, stack) { _logger.e('Restore: failed to extract restore data', error: e, stackTrace: stack); rethrow; @@ -330,6 +348,36 @@ class RestoreService { } } + /// Determines if the user initiated the dispute based on available information + bool _determineIfUserInitiatedDispute({ + required RestoredDispute restoredDispute, + required Order order, + Session? session, + }) { + // Without session role, we can't determine who initiated + if (session?.role == null) { + _logger.w('Restore: cannot determine dispute initiator, no session role for order ${order.id}'); + return false; // Default to peer-initiated + } + + // Check trade pubkeys to determine who initiated + // The user is either buyer or seller based on session.role + final userPubkey = session!.tradeKey.public; + final isBuyer = session.role == Role.buyer; + + // Compare with order pubkeys to determine if user initiated + // This logic depends on how dispute initiator info is stored + // For now, we'll use a heuristic: if user is buyer and buyerTradePubkey matches, user initiated + if (isBuyer && order.buyerTradePubkey == userPubkey) { + return true; // User is buyer and likely initiated + } else if (!isBuyer && order.sellerTradePubkey == userPubkey) { + return true; // User is seller and likely initiated + } + + // Default to peer-initiated if we can't determine + return false; + } + /// Maps Status to the appropriate Action for restored orders Action _getActionFromStatus(Status status) { switch (status) { @@ -356,7 +404,7 @@ class RestoreService { case Status.completedByAdmin: return Action.adminSettled; case Status.dispute: - return Action.disputeInitiatedByPeer; // Default to peer-initiated + return Action.disputeInitiatedByPeer; //No should be used - Default to peer-initiated case Status.expired: return Action.canceled; case Status.paymentFailed: @@ -368,7 +416,7 @@ class RestoreService { } } - Future restore(Map ordersIds, int lastTradeIndex, OrdersResponse ordersResponse) async { + Future restore(Map ordersIds, int lastTradeIndex, OrdersResponse ordersResponse, List disputes) async { try { final keyManager = ref.read(keyManagerProvider); @@ -426,6 +474,10 @@ class RestoreService { progress.incrementProgress(); } + // Enable restore mode to block all message processing + AbstractMostroNotifier.setRestoring(true); + _logger.i('Restore: enabled restore mode - blocking all message processing'); + // Wait for historical messages to arrive and be saved to storage _logger.i('Restore: waiting 5 seconds for historical messages to be saved...'); await Future.delayed(const Duration(seconds: 5)); @@ -454,8 +506,45 @@ class RestoreService { expiresAt: orderDetail.expiresAt, ); - // Derive appropriate action from status - final action = _getActionFromStatus(order.status); + // Check if this order has a dispute + final restoredDispute = disputes.where((d) => d.orderId == orderDetail.id).firstOrNull; + + // Determine action and create dispute if needed + Action action; + Dispute? dispute; + + if (restoredDispute != null && order.status == Status.dispute) { + // This is a disputed order - determine who initiated + final session = ref.read(sessionNotifierProvider.notifier).getSessionByOrderId(orderDetail.id); + + // Determine if user initiated the dispute + // If user is buyer and buyer initiated, or user is seller and seller initiated + final userInitiated = _determineIfUserInitiatedDispute( + restoredDispute: restoredDispute, + order: order, + session: session, + ); + + action = userInitiated + ? Action.disputeInitiatedByYou + : Action.disputeInitiatedByPeer; + + // Create Dispute object + dispute = Dispute( + disputeId: restoredDispute.disputeId, + orderId: restoredDispute.orderId, + status: restoredDispute.status, + createdAt: orderDetail.createdAt != null + ? DateTime.fromMillisecondsSinceEpoch(orderDetail.createdAt!) + : DateTime.now(), + action: userInitiated ? 'dispute-initiated-by-you' : 'dispute-initiated-by-peer', + ); + + _logger.i('Restore: dispute found for order ${orderDetail.id}, action: $action'); + } else { + // Regular order without dispute + action = _getActionFromStatus(order.status); + } // Build MostroMessage with Order payload final mostroMessage = MostroMessage( @@ -470,7 +559,14 @@ class RestoreService { await storage.addMessage(key, mostroMessage); // Update state using public method that calls updateWith internally - ref.read(orderNotifierProvider(orderDetail.id).notifier).updateStateFromMessage(mostroMessage); + final notifier = ref.read(orderNotifierProvider(orderDetail.id).notifier); + notifier.updateStateFromMessage(mostroMessage); + + // If dispute exists, update state with dispute object using public method + if (dispute != null) { + notifier.updateDispute(dispute); + _logger.i('Restore: added dispute to state for order ${orderDetail.id}'); + } _logger.i('Restore: built message for order ${orderDetail.id} with status ${orderDetail.status}, action $action'); } catch (e, stack) { @@ -480,16 +576,23 @@ class RestoreService { _logger.i('Restore: state update completed for all orders'); + // Disable restore mode - back to normal message processing + AbstractMostroNotifier.setRestoring(false); + _logger.i('Restore: disabled restore mode - re-enabling message processing'); + // Navigate to home and clear notification tray final navProvider = ref.read(navigationProvider.notifier); navProvider.go('/'); + //While bulding subscriptions, some old notifications may have arrived - clear them all final notifProvider = ref.read(notificationActionsProvider.notifier); notifProvider.clearAll(); _logger.i('Restore: navigated to home and cleared notification tray'); } catch (e, stack) { + // Ensure flag is cleared even on error + AbstractMostroNotifier.setRestoring(false); _logger.e('Restore: error during restore', error: e, stackTrace: stack); rethrow; } @@ -532,9 +635,11 @@ class RestoreService { progress.updateStep(RestoreStep.requesting); await _sendRestoreRequest(); final restoreDataEvent = await _waitForEvent(RestoreStage.gettingRestoreData); - final ordersIds = await _extractRestoreData(restoreDataEvent); + final extracted = await _extractRestoreData(restoreDataEvent); + final ordersIds = extracted.orderIds; + final disputes = extracted.disputes; progress.setOrdersReceived(ordersIds.length); - + if (ordersIds.isEmpty) { _logger.w('Restore: no orders or disputes to restore'); await _sendLastTradeIndexRequest(); @@ -566,16 +671,10 @@ class RestoreService { // STAGE 4: Processing and restoring sessions progress.updateStep(RestoreStep.processingRoles); - await restore(ordersIds, lastTradeIndex, ordersResponse); - - } on TimeoutException catch (e, stack) { - _logger.e('Restore: timeout error', error: e, stackTrace: stack); - ref.read(restoreProgressProvider.notifier).showError('Request timeout'); - rethrow; + await restore(ordersIds, lastTradeIndex, ordersResponse, disputes); } catch (e, stack) { _logger.e('Restore: error during restore process', error: e, stackTrace: stack); - ref.read(restoreProgressProvider.notifier).showError(e.toString()); - rethrow; + ref.read(restoreProgressProvider.notifier).showError(''); } finally { // Cleanup: always cancel subscription _logger.i('Restore: cleaning up subscription'); diff --git a/lib/features/restore/restore_overlay.dart b/lib/features/restore/restore_overlay.dart index 1da4450fc..e0988b044 100644 --- a/lib/features/restore/restore_overlay.dart +++ b/lib/features/restore/restore_overlay.dart @@ -149,7 +149,7 @@ class RestoreOverlay extends ConsumerWidget { String _getMessage(BuildContext context, RestoreProgressState state) { if (state.step == RestoreStep.error) { - return state.errorMessage ?? S.of(context)!.restoreError; + return S.of(context)!.restoreErrorMessage; } switch (state.step) { diff --git a/lib/features/settings/settings_screen.dart b/lib/features/settings/settings_screen.dart index b58412bc5..a078a8f1e 100644 --- a/lib/features/settings/settings_screen.dart +++ b/lib/features/settings/settings_screen.dart @@ -7,6 +7,7 @@ import 'package:mostro_mobile/core/app_theme.dart'; import 'package:mostro_mobile/features/relays/widgets/relay_selector.dart'; import 'package:mostro_mobile/features/settings/settings_provider.dart'; import 'package:mostro_mobile/features/settings/settings.dart'; +import 'package:mostro_mobile/features/restore/restore_manager.dart'; import 'package:mostro_mobile/shared/widgets/currency_selection_dialog.dart'; import 'package:mostro_mobile/shared/providers/exchange_service_provider.dart'; import 'package:mostro_mobile/shared/widgets/language_selector.dart'; @@ -460,9 +461,20 @@ class _SettingsScreenState extends ConsumerState { child: TextFormField( controller: controller, style: const TextStyle(color: AppTheme.textPrimary), - onChanged: (value) => ref - .watch(settingsProvider.notifier) - .updateMostroInstance(value), + onChanged: (value) async { + final oldValue = ref.read(settingsProvider).mostroPublicKey; + await ref.read(settingsProvider.notifier).updateMostroInstance(value); + + // Trigger restore if pubkey changed + if (oldValue != value && value.isNotEmpty) { + try { + final restoreService = ref.read(restoreServiceProvider); + await restoreService.initRestoreProcess(); + } catch (e) { + // Ignore errors during restore + } + } + }, decoration: InputDecoration( border: InputBorder.none, labelText: S.of(context)!.mostroPubkey, diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index 5ea2ed1e8..1ac2cf54e 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -1192,5 +1192,6 @@ "restoreProcessingRoles": "Processing roles...", "restoreFinalizing": "Finalizing restore...", "restoreCompleted": "Restore completed!", - "restoreError": "Restore error" + "restoreError": "Restore error", + "restoreErrorMessage": "Error restoring user data. Please check your connection and try again." } \ No newline at end of file diff --git a/lib/l10n/intl_es.arb b/lib/l10n/intl_es.arb index a42f89000..405d3fdb6 100644 --- a/lib/l10n/intl_es.arb +++ b/lib/l10n/intl_es.arb @@ -1170,6 +1170,7 @@ "restoreProcessingRoles": "Procesando roles...", "restoreFinalizing": "Finalizando restauración...", "restoreCompleted": "¡Restauración completada!", - "restoreError": "Error de restauración" + "restoreError": "Error de restauración", + "restoreErrorMessage": "Error al restaurar datos del usuario. Verifica tu conexión e inténtalo más tarde." } \ No newline at end of file diff --git a/lib/l10n/intl_it.arb b/lib/l10n/intl_it.arb index 93f3f4d0f..2d917ac01 100644 --- a/lib/l10n/intl_it.arb +++ b/lib/l10n/intl_it.arb @@ -1225,5 +1225,6 @@ "restoreProcessingRoles": "Elaborazione ruoli...", "restoreFinalizing": "Finalizzazione ripristino...", "restoreCompleted": "Ripristino completato!", - "restoreError": "Errore di ripristino" + "restoreError": "Errore di ripristino", + "restoreErrorMessage": "Errore nel ripristino dei dati utente. Verifica la tua connessione e riprova più tardi." } \ No newline at end of file From 68e45657dfe6933751e950d79a57532916505f94 Mon Sep 17 00:00:00 2001 From: bracr10 Date: Sun, 9 Nov 2025 18:36:16 -0600 Subject: [PATCH 20/25] feat : mnemonic checksum validator --- .../key_manager/import_mnemonic_dialog.dart | 32 ++++--------------- lib/l10n/intl_en.arb | 8 ++--- lib/l10n/intl_es.arb | 8 ++--- lib/l10n/intl_it.arb | 8 ++--- lib/shared/utils/mnemonic_validator.dart | 22 +++++++++++++ test/mocks.mocks.dart | 28 ++++++++++++++++ 6 files changed, 65 insertions(+), 41 deletions(-) create mode 100644 lib/shared/utils/mnemonic_validator.dart diff --git a/lib/features/key_manager/import_mnemonic_dialog.dart b/lib/features/key_manager/import_mnemonic_dialog.dart index 81c19adc2..bb42f0d12 100644 --- a/lib/features/key_manager/import_mnemonic_dialog.dart +++ b/lib/features/key_manager/import_mnemonic_dialog.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:mostro_mobile/core/app_theme.dart'; import 'package:mostro_mobile/generated/l10n.dart'; +import 'package:mostro_mobile/shared/utils/mnemonic_validator.dart'; class ImportMnemonicDialog extends StatefulWidget { const ImportMnemonicDialog({super.key}); @@ -28,35 +29,14 @@ class _ImportMnemonicDialogState extends State { return false; } - final words = trimmed.split(RegExp(r'\s+')); - - if (words.length != 12) { - setState(() { - _errorMessage = S.of(context)!.errorNotTwelveWords; - }); - return false; - } - - for (final word in words) { - if (word.length < 3) { - setState(() { - _errorMessage = S.of(context)!.errorWordTooShort; - }); - return false; - } - - if (!RegExp(r'^[a-zA-Z]+$').hasMatch(word)) { - setState(() { - _errorMessage = S.of(context)!.errorInvalidCharacters; - }); - return false; - } - } + // Use BIP39 checksum validation + final isValid = validateMnemonic(trimmed); setState(() { - _errorMessage = null; + _errorMessage = isValid ? null : S.of(context)!.invalidMnemonic; }); - return true; + + return isValid; } void _handleImport() { diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index 1ac2cf54e..bb9a9cff7 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -459,7 +459,7 @@ "yourTradeCounter": "Your trade counter", "incrementsWithEachTrade": "Increments with each trade", "generateNewUser": "Generate New User", - "importMostroUser": "Import Mostro User", + "importMostroUser": "Import User", "refreshUser": "Refresh User", "keyImportedSuccessfully": "Key imported successfully", "importFailed": "Import failed: {error}", @@ -1174,15 +1174,13 @@ "sessionTimeoutMessage": "No response received, check your connection and try again later", "@_comment_import_mnemonic_dialog": "Import Mnemonic Dialog strings", - "importMostroUserDialogTitle": "Import Mostro User", + "importMostroUserDialogTitle": "Import User", "importMostroUserInfo1": "These are your secret words, the only way to recover your account if you lose access to this app or want to use your identity in another app.", "importMostroUserInfo2": "Write them down carefully and store them in a secure, private location. Never share them with anyone.", "importMostroUserInfo3": "If you lose these words, you'll permanently lose access to your account.", "secretWordsLabel": "Secret Words", "secretWordsPlaceholder": "Enter your 12 secret words separated by spaces", - "errorNotTwelveWords": "You must enter exactly 12 words", - "errorWordTooShort": "Each word must have at least 3 letters", - "errorInvalidCharacters": "Words can only contain letters", + "invalidMnemonic": "Invalid mnemonic. Please check your words", "@_comment_restore_overlay": "Restore overlay messages", "restoringOrders": "Restoring Orders", diff --git a/lib/l10n/intl_es.arb b/lib/l10n/intl_es.arb index 405d3fdb6..c4ae6db36 100644 --- a/lib/l10n/intl_es.arb +++ b/lib/l10n/intl_es.arb @@ -381,7 +381,7 @@ "yourTradeCounter": "Tu contador de intercambios", "incrementsWithEachTrade": "Se incrementa con cada intercambio", "generateNewUser": "Generar Nuevo Usuario", - "importMostroUser": "Importar Usuario Mostro", + "importMostroUser": "Importar Usuario", "refreshUser": "Actualizar Usuario", "keyImportedSuccessfully": "Clave importada exitosamente", "importFailed": "Importación falló: {error}", @@ -1152,15 +1152,13 @@ "sessionTimeoutMessage": "No hubo respuesta, verifica tu conexión e inténtalo más tarde", "@_comment_import_mnemonic_dialog": "Import Mnemonic Dialog strings", - "importMostroUserDialogTitle": "Importar Usuario Mostro", + "importMostroUserDialogTitle": "Importar Usuario", "importMostroUserInfo1": "Estas son tus palabras secretas, la única forma de recuperar tu cuenta si pierdes acceso a esta app o quieres usar tu identidad en otra app.", "importMostroUserInfo2": "Escríbelas con cuidado y guárdalas en un lugar seguro y privado. Nunca las compartas con nadie.", "importMostroUserInfo3": "Si pierdes estas palabras, perderás permanentemente el acceso a tu cuenta.", "secretWordsLabel": "Palabras Secretas", "secretWordsPlaceholder": "Ingresa tus 12 palabras secretas separadas por espacios", - "errorNotTwelveWords": "Debes ingresar exactamente 12 palabras", - "errorWordTooShort": "Cada palabra debe tener al menos 3 letras", - "errorInvalidCharacters": "Las palabras solo pueden contener letras", + "invalidMnemonic": "Mnemonic inválido. Revisa tus palabras", "@_comment_restore_overlay": "Mensajes de overlay de restauración", "restoringOrders": "Restaurando Órdenes", diff --git a/lib/l10n/intl_it.arb b/lib/l10n/intl_it.arb index 2d917ac01..9b4a9053c 100644 --- a/lib/l10n/intl_it.arb +++ b/lib/l10n/intl_it.arb @@ -410,7 +410,7 @@ "yourTradeCounter": "Il tuo contatore di scambi", "incrementsWithEachTrade": "Si incrementa ad ogni scambio", "generateNewUser": "Genera Nuovo Utente", - "importMostroUser": "Importa Utente Mostro", + "importMostroUser": "Importa Utente", "refreshUser": "Aggiorna Utente", "keyImportedSuccessfully": "Chiave importata con successo", "importFailed": "Importazione fallita: {error}", @@ -1207,15 +1207,13 @@ "sessionTimeoutMessage": "Nessuna risposta ricevuta, verifica la tua connessione e riprova più tardi", "@_comment_import_mnemonic_dialog": "Import Mnemonic Dialog strings", - "importMostroUserDialogTitle": "Importa Utente Mostro", + "importMostroUserDialogTitle": "Importa Utente", "importMostroUserInfo1": "Queste sono le tue parole segrete, l'unico modo per recuperare il tuo account se perdi l'accesso a questa app o vuoi usare la tua identità in un'altra app.", "importMostroUserInfo2": "Scrivile con attenzione e conservale in un luogo sicuro e privato. Non condividerle mai con nessuno.", "importMostroUserInfo3": "Se perdi queste parole, perderai permanentemente l'accesso al tuo account.", "secretWordsLabel": "Parole Segrete", "secretWordsPlaceholder": "Inserisci le tue 12 parole segrete separate da spazi", - "errorNotTwelveWords": "Devi inserire esattamente 12 parole", - "errorWordTooShort": "Ogni parola deve avere almeno 3 lettere", - "errorInvalidCharacters": "Le parole possono contenere solo lettere", + "invalidMnemonic": "Mnemonic non valido. Verifica le tue parole", "@_comment_restore_overlay": "Messaggi di overlay di ripristino", "restoringOrders": "Ripristino Ordini", diff --git a/lib/shared/utils/mnemonic_validator.dart b/lib/shared/utils/mnemonic_validator.dart new file mode 100644 index 000000000..fb068e2e7 --- /dev/null +++ b/lib/shared/utils/mnemonic_validator.dart @@ -0,0 +1,22 @@ +import 'package:bip39/bip39.dart' as bip39; + +/// Validates a BIP39 mnemonic phrase +/// +/// Returns true if the mnemonic is valid (correct words and checksum) +/// Returns false if invalid +bool validateMnemonic(String mnemonic) { + try { + final trimmed = mnemonic.trim(); + if (trimmed.isEmpty) { + return false; + } + + // bip39.validateMnemonic checks: + // 1. Word count is 12, 15, 18, 21, or 24 + // 2. All words are in the BIP39 wordlist + // 3. Checksum is valid + return bip39.validateMnemonic(trimmed); + } catch (e) { + return false; + } +} \ No newline at end of file diff --git a/test/mocks.mocks.dart b/test/mocks.mocks.dart index 60c9d0e76..2d89bea4d 100644 --- a/test/mocks.mocks.dart +++ b/test/mocks.mocks.dart @@ -845,6 +845,15 @@ class MockOpenOrdersRepository extends _i1.Mock ), returnValueForMissingStub: null, ); + + @override + void clearCache() => super.noSuchMethod( + Invocation.method( + #clearCache, + [], + ), + returnValueForMissingStub: null, + ); } /// A class which mocks [SharedPreferencesAsync]. @@ -2918,6 +2927,25 @@ class MockOrderNotifier extends _i1.Mock implements _i28.OrderNotifier { returnValueForMissingStub: _i5.Future.value(), ) as _i5.Future); + @override + void updateStateFromMessage(_i7.MostroMessage<_i7.Payload>? message) => + super.noSuchMethod( + Invocation.method( + #updateStateFromMessage, + [message], + ), + returnValueForMissingStub: null, + ); + + @override + void updateDispute(_i7.Dispute? dispute) => super.noSuchMethod( + Invocation.method( + #updateDispute, + [dispute], + ), + returnValueForMissingStub: null, + ); + @override void dispose() => super.noSuchMethod( Invocation.method( From b619b2bfd78d6826b71e71a51b7b5189c8475a13 Mon Sep 17 00:00:00 2001 From: bracr10 Date: Sun, 9 Nov 2025 21:21:13 -0600 Subject: [PATCH 21/25] refactor(restore): improve logs and comments --- lib/data/models/mostro_message.dart | 6 +- .../notfiers/abstract_mostro_notifier.dart | 6 +- lib/features/restore/restore_manager.dart | 278 ++++++++++-------- lib/services/mostro_service.dart | 4 +- 4 files changed, 164 insertions(+), 130 deletions(-) diff --git a/lib/data/models/mostro_message.dart b/lib/data/models/mostro_message.dart index 17d2828ef..39991b483 100644 --- a/lib/data/models/mostro_message.dart +++ b/lib/data/models/mostro_message.dart @@ -43,7 +43,7 @@ class MostroMessage { factory MostroMessage.fromJson(Map json) { final timestamp = json['timestamp']; - // Support both 'order', 'restore', and 'cant-do' wrapper keys + // IMPORTANT : Use 'order', 'restore' or 'cant-do' key as per protocol json = json['order'] ?? json['restore'] ?? json['cant-do'] ?? json; final num requestId = json['request_id'] ?? 0; @@ -99,7 +99,7 @@ class MostroMessage { } String sign(NostrKeyPairs keyPair) { - // Use 'restore' key for restore and last-trade-index actions, 'order' for everything else + //IMPORTANT : Use 'restore' key for restore and last-trade-index actions, 'order' for everything else, as per protocol final wrapperKey = action == Action.restore || action == Action.lastTradeIndex ? 'restore' : 'order'; final message = {wrapperKey: toJson()}; final serializedEvent = jsonEncode(message); @@ -111,7 +111,7 @@ class MostroMessage { } String serialize({NostrKeyPairs? keyPair}) { - // Use 'restore' key for restore action, 'order' for everything else + //IMPORTANT : Use 'restore' key for restore and last-trade-index actions, 'order' for everything else, as per protocol final wrapperKey = action == Action.restore || action == Action.lastTradeIndex ? 'restore' : 'order'; final message = {wrapperKey: toJson()}; final serializedEvent = jsonEncode(message); diff --git a/lib/features/order/notfiers/abstract_mostro_notifier.dart b/lib/features/order/notfiers/abstract_mostro_notifier.dart index e0713607b..03f712a48 100644 --- a/lib/features/order/notfiers/abstract_mostro_notifier.dart +++ b/lib/features/order/notfiers/abstract_mostro_notifier.dart @@ -25,7 +25,7 @@ class AbstractMostroNotifier extends StateNotifier { // Timer storage for orphan session cleanup static final Map _sessionTimeouts = {}; - // Restore mode flag - blocks all message processing during restore + // Restore mode flag - blocks all old message processing during restore static bool _isRestoring = false; static void setRestoring(bool value) { @@ -57,9 +57,9 @@ class AbstractMostroNotifier extends StateNotifier { (_, next) { next.when( data: (MostroMessage? msg) { - // Skip all processing during restore - messages are saved but state is not updated + // Skip all old message processing during restore - messages are saved but state is not updated if (isRestoring) { - logger.d('Skipping message processing during restore: ${msg?.action}'); + logger.d('Skipping old message processing during restore: ${msg?.action}'); return; } diff --git a/lib/features/restore/restore_manager.dart b/lib/features/restore/restore_manager.dart index d5ca426e1..7eddf8cb1 100644 --- a/lib/features/restore/restore_manager.dart +++ b/lib/features/restore/restore_manager.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:convert'; +import 'package:dart_nostr/nostr/core/key_pairs.dart'; import 'package:dart_nostr/nostr/model/event/event.dart'; import 'package:dart_nostr/nostr/model/request/filter.dart'; import 'package:dart_nostr/nostr/model/request/request.dart'; @@ -48,6 +49,8 @@ class RestoreService { StreamSubscription? _tempSubscription; Completer? _currentCompleter; RestoreStage _currentStage = RestoreStage.gettingRestoreData; + NostrKeyPairs? _tempTradeKey; // Temporary trade key (index 1) used during restore process + NostrKeyPairs? _masterKey; // Master key pair used during restore process RestoreService(this.ref); @@ -62,18 +65,17 @@ class RestoreService { // Invalidate keyManagerProvider to force re-initialization // This ensures all providers get a fresh instance with the new key ref.invalidate(keyManagerProvider); - _logger.i('Restore: invalidated keyManagerProvider'); // Get the new instance and initialize it final newKeyManager = ref.read(keyManagerProvider); await newKeyManager.init(); - _logger.i('Restore: new keyManager initialized, masterKeyPair=${newKeyManager.masterKeyPair != null}'); await initRestoreProcess(); } Future _clearAll() async { try { + _logger.i('Restore: clearing all existing data before restore'); await ref.read(sessionNotifierProvider.notifier).reset(); await ref.read(mostroStorageProvider).deleteAll(); await ref.read(eventStorageProvider).deleteAll(); @@ -105,6 +107,7 @@ class RestoreService { } void _handleTempSubscriptionsResponse(NostrEvent event) { + // Check if event matches current stage criteria if (_currentCompleter != null && !_currentCompleter!.isCompleted) { _currentCompleter!.complete(event); } @@ -112,13 +115,14 @@ class RestoreService { Future> _createTempSubscription() async { //use temporary trade key 1 to subscribe to restore notifications - final keyManager = ref.read(keyManagerProvider); - final tempTradeKey = await keyManager.deriveTradeKeyFromIndex(1); + if (_tempTradeKey == null) { + throw Exception('Temp trade key not initialized'); + } final filter = NostrFilter( kinds: [1059], - p: [tempTradeKey.public], - limit: 0, //IMPORTANT: limit 0 indicates we don’t want historical events, only new ones https://nostrbook.dev/protocol/filter + p: [_tempTradeKey!.public], + limit: 0, //IMPORTANT: limit 0 indicates we don't want historical events, only new ones https://nostrbook.dev/protocol/filter ); final request = NostrRequest(filters: [filter]); @@ -131,45 +135,48 @@ class RestoreService { }, cancelOnError: false, ); - + _logger.i('Restore: temporary subscription created'); return subscription; } Future _sendRestoreRequest() async { - _logger.i('Restore: sending restore request'); + _logger.i('Restore: sending restore data request'); - final keyManager = ref.read(keyManagerProvider); - final settings = ref.read(settingsProvider); + if (_tempTradeKey == null && _masterKey == null) { + throw Exception('Temp trade key or master key not initialized'); + } - // Use temporary trade key 1 for restore communication - final tempTradeKey = await keyManager.deriveTradeKeyFromIndex(1); + final settings = ref.read(settingsProvider); - // Create restore message with EmptyPayload (serializes as null per protocol spec) + // Create restore message with EmptyPayload as protocol spec final mostroMessage = MostroMessage( action: Action.restore, payload: EmptyPayload(), ); - // Respect full privacy mode: if enabled, don't pass master key + // Respect full privacy mode: if enabled, don't pass master key, wrap will be done just with trade key final wrappedEvent = await mostroMessage.wrap( - tradeKey: tempTradeKey, + tradeKey: _tempTradeKey!, recipientPubKey: settings.mostroPublicKey, - masterKey: settings.fullPrivacyMode ? null : keyManager.masterKeyPair + masterKey: settings.fullPrivacyMode ? null : _masterKey ); await ref.read(nostrServiceProvider).publishEvent(wrappedEvent); _logger.i('Restore: request sent successfully'); } - - Future<({Map orderIds, List disputes})> _extractRestoreData(NostrEvent event) async { + + //Extracts restore data, returns: + // Orders map {orderId: tradeIndex} + // List of disputes + Future<({Map ordersMap, List disputes})> _extractRestoreData(NostrEvent event) async { try { - - final keyManager = ref.read(keyManagerProvider); - final tempTradeKey = await keyManager.deriveTradeKeyFromIndex(1); + if (_tempTradeKey == null) { + throw Exception('Temp trade key not initialized'); + } // Unwrap the gift wrap (kind 1059) to get the rumor - final rumor = await event.mostroUnWrap(tempTradeKey); + final rumor = await event.mostroUnWrap(_tempTradeKey!); if (rumor.content == null || rumor.content!.isEmpty) { throw Exception('Rumor content is empty'); @@ -181,7 +188,7 @@ class RestoreService { // Check if Mostro returned cant-do (not found) if (messageData.containsKey('cant-do')) { _logger.w('Restore: Mostro returned cant-do for restore data (no orders found)'); - return (orderIds: {}, disputes: []); + return (ordersMap: {}, disputes: []); } // Extract payload from restore wrapper @@ -189,31 +196,32 @@ class RestoreService { if (restoreWrapper == null) { _logger.w('Restore: no restore wrapper found, returning empty orders'); - return (orderIds: {}, disputes: []); + return (ordersMap: {}, disputes: []); } final payload = restoreWrapper['payload'] as Map?; if (payload == null) { _logger.w('Restore: no payload found in restore wrapper, returning empty orders'); - return (orderIds: {}, disputes: []); + return (ordersMap: {}, disputes: []); } final restoreData = RestoreData.fromJson(payload); - final Map orderIds = {}; + final Map ordersMap = {}; for (var order in restoreData.orders) { - orderIds[order.id] = order.tradeIndex; + ordersMap[order.id] = order.tradeIndex; } + //Also orders with disputes must be included for (var dispute in restoreData.disputes) { - orderIds[dispute.orderId] = dispute.tradeIndex; + ordersMap[dispute.orderId] = dispute.tradeIndex; } final List disputesList = restoreData.disputes; - return (orderIds: orderIds, disputes: disputesList); + return (ordersMap: ordersMap, disputes: disputesList); } catch (e, stack) { _logger.e('Restore: failed to extract restore data', error: e, stackTrace: stack); rethrow; @@ -223,37 +231,40 @@ class RestoreService { Future _sendOrdersDetailsRequest(List orderIds) async { _logger.i('Restore: sending orders details request for ${orderIds.length} orders'); - final keyManager = ref.read(keyManagerProvider); + if (_tempTradeKey == null && _masterKey == null) { + throw Exception('Temp trade key or master key not initialized'); + } + final settings = ref.read(settingsProvider); - final tempTradeKey = await keyManager.deriveTradeKeyFromIndex(1); - // Create orders message following same pattern as restore final mostroMessage = MostroMessage( action: Action.orders, requestId: DateTime.now().millisecondsSinceEpoch, payload: OrdersPayload(ids: orderIds), ); + // Respect full privacy mode: if enabled, don't pass master key, wrap will be done just with trade key final wrappedEvent = await mostroMessage.wrap( - tradeKey: tempTradeKey, + tradeKey: _tempTradeKey!, recipientPubKey: settings.mostroPublicKey, - masterKey: settings.fullPrivacyMode ? null : keyManager.masterKeyPair + masterKey: settings.fullPrivacyMode ? null : _masterKey ); await ref.read(nostrServiceProvider).publishEvent(wrappedEvent); _logger.i('Restore: orders details request sent successfully'); } + //Extracts orders details from gift wrap event, returns OrdersResponse Future _extractOrdersDetails(NostrEvent event) async { try { _logger.i('Restore: extracting orders details from gift wrap event ${event.id}'); - final keyManager = ref.read(keyManagerProvider); - final tempTradeKey = await keyManager.deriveTradeKeyFromIndex(1); + if (_tempTradeKey == null) { + throw Exception('Temp trade key not initialized'); + } // Unwrap the gift wrap (kind 1059) to get the rumor - final rumor = await event.mostroUnWrap(tempTradeKey); - _logger.i('Restore: unwrapped rumor event ${rumor.id}'); + final rumor = await event.mostroUnWrap(_tempTradeKey!); if (rumor.content == null || rumor.content!.isEmpty) { throw Exception('Rumor content is empty'); @@ -271,10 +282,6 @@ class RestoreService { _logger.i('Restore: found ${ordersResponse.orders.length} order details'); - for (var order in ordersResponse.orders) { - _logger.i('Restore: Order ${order.id} - status: ${order.status}, amount: ${order.amount} sats'); - } - return ordersResponse; } catch (e, stack) { _logger.e('Restore: failed to extract orders details', error: e, stackTrace: stack); @@ -285,20 +292,23 @@ class RestoreService { Future _sendLastTradeIndexRequest() async { _logger.i('Restore: sending last trade index request'); - final keyManager = ref.read(keyManagerProvider); + if (_tempTradeKey == null && _masterKey == null) { + throw Exception('Temp trade key or master key not initialized'); + } + final settings = ref.read(settingsProvider); - final tempTradeKey = await keyManager.deriveTradeKeyFromIndex(1); - // Create last-trade-index message with EmptyPayload (serializes as null per protocol spec) + // Create last-trade-index message with EmptyPayload as protocol spec final mostroMessage = MostroMessage( action: Action.lastTradeIndex, payload: EmptyPayload(), ); + // Respect full privacy mode: if enabled, don't pass master key, wrap will be done just with trade key final wrappedEvent = await mostroMessage.wrap( - tradeKey: tempTradeKey, + tradeKey: _tempTradeKey!, recipientPubKey: settings.mostroPublicKey, - masterKey: settings.fullPrivacyMode ? null : keyManager.masterKeyPair + masterKey: settings.fullPrivacyMode ? null : _masterKey ); await ref.read(nostrServiceProvider).publishEvent(wrappedEvent); @@ -309,12 +319,11 @@ class RestoreService { try { _logger.i('Restore: extracting last trade index from gift wrap event ${event.id}'); - final keyManager = ref.read(keyManagerProvider); - final tempTradeKey = await keyManager.deriveTradeKeyFromIndex(1); + if (_tempTradeKey == null) { + throw Exception('Temp trade key not initialized'); + } - // Unwrap the gift wrap (kind 1059) to get the rumor - final rumor = await event.mostroUnWrap(tempTradeKey); - _logger.i('Restore: unwrapped rumor event ${rumor.id}'); + final rumor = await event.mostroUnWrap(_tempTradeKey!); if (rumor.content == null || rumor.content!.isEmpty) { throw Exception('Rumor content is empty'); @@ -348,34 +357,46 @@ class RestoreService { } } - /// Determines if the user initiated the dispute based on available information + /// Determines if the user initiated the dispute with double verification + /// + /// Security checks: + /// 1. Verify session belongs to this order (compare pubkeys based on role) + /// 2. Compare trade_index to determine who initiated the dispute + /// + /// The dispute's trade_index indicates which party initiated it. + /// If it matches the user's session trade_index, the user initiated the dispute. bool _determineIfUserInitiatedDispute({ required RestoredDispute restoredDispute, + required Session session, required Order order, - Session? session, }) { - // Without session role, we can't determine who initiated - if (session?.role == null) { - _logger.w('Restore: cannot determine dispute initiator, no session role for order ${order.id}'); - return false; // Default to peer-initiated + // Security verification: ensure session's trade pubkey matches order's pubkey for the role + final sessionPubkey = session.tradeKey.public; + final sessionRole = session.role; + + bool sessionMatchesOrder = false; + if (sessionRole == Role.buyer && order.buyerTradePubkey == sessionPubkey) { + sessionMatchesOrder = true; + } else if (sessionRole == Role.seller && order.sellerTradePubkey == sessionPubkey) { + sessionMatchesOrder = true; } - // Check trade pubkeys to determine who initiated - // The user is either buyer or seller based on session.role - final userPubkey = session!.tradeKey.public; - final isBuyer = session.role == Role.buyer; - - // Compare with order pubkeys to determine if user initiated - // This logic depends on how dispute initiator info is stored - // For now, we'll use a heuristic: if user is buyer and buyerTradePubkey matches, user initiated - if (isBuyer && order.buyerTradePubkey == userPubkey) { - return true; // User is buyer and likely initiated - } else if (!isBuyer && order.sellerTradePubkey == userPubkey) { - return true; // User is seller and likely initiated + if (!sessionMatchesOrder) { + _logger.w( + 'Restore: session pubkey mismatch for order ${order.id} - ' + 'session role: $sessionRole, session pubkey: $sessionPubkey, ' + 'buyer pubkey: ${order.buyerTradePubkey}, seller pubkey: ${order.sellerTradePubkey}' + ); + // Default to peer-initiated if we can't verify session belongs to order + return false; } - // Default to peer-initiated if we can't determine - return false; + // Compare trade indexes: if dispute trade_index matches user's session trade_index, + // then the user initiated the dispute + final userInitiated = restoredDispute.tradeIndex == session.keyIndex; + + //TODO: Improve dispute initiation detection if protocol changes in future + return userInitiated; } /// Maps Status to the appropriate Action for restored orders @@ -418,23 +439,23 @@ class RestoreService { Future restore(Map ordersIds, int lastTradeIndex, OrdersResponse ordersResponse, List disputes) async { try { + if (_masterKey == null) { + throw Exception('Master key not initialized'); + } final keyManager = ref.read(keyManagerProvider); final sessionNotifier = ref.read(sessionNotifierProvider.notifier); final progress = ref.read(restoreProgressProvider.notifier); final settings = ref.read(settingsProvider); - // Set the next trade key index await keyManager.setCurrentKeyIndex(lastTradeIndex + 1); - // Get master key - final masterKey = keyManager.masterKeyPair; - if (masterKey == null) { - throw Exception('Master key not available'); - } + // Enable restore mode to block all old message processing + AbstractMostroNotifier.setRestoring(true); + _logger.i('Restore: enabled restore mode - blocking all old message processing'); - // Restore each order as a session + // Restore each a session to get future messages for (final entry in ordersIds.entries) { final orderId = entry.key; final tradeIndex = entry.value; @@ -457,7 +478,7 @@ class RestoreService { } final session = Session( - masterKey: masterKey, + masterKey: _masterKey!, tradeKey: tradeKey, keyIndex: tradeIndex, fullPrivacy: settings.fullPrivacyMode, @@ -469,23 +490,22 @@ class RestoreService { // Store session await sessionNotifier.saveSession(session); - _logger.i('Restore: created session for order ${orderDetail.id} (isRestored: true)'); + _logger.i('Restore: created session for order ${orderDetail.id}'); progress.incrementProgress(); } - // Enable restore mode to block all message processing - AbstractMostroNotifier.setRestoring(true); - _logger.i('Restore: enabled restore mode - blocking all message processing'); - // Wait for historical messages to arrive and be saved to storage - _logger.i('Restore: waiting 5 seconds for historical messages to be saved...'); - await Future.delayed(const Duration(seconds: 5)); + _logger.i('Restore: waiting 8 seconds for historical messages to be saved...'); + //WARNING: It is very important to wait here to ensure all historical messages arrive before rebuilding state + // Relays could send them with delay + await Future.delayed(const Duration(seconds: 8)); // Build MostroMessages from ordersResponse and update state (source of truth from Mostro) _logger.i('Restore: building messages for ${ordersResponse.orders.length} orders from ordersResponse'); final storage = ref.read(mostroStorageProvider); + // Process each order detail for (final orderDetail in ordersResponse.orders) { try { // Convert OrderDetail to Order @@ -517,17 +537,23 @@ class RestoreService { // This is a disputed order - determine who initiated final session = ref.read(sessionNotifierProvider.notifier).getSessionByOrderId(orderDetail.id); - // Determine if user initiated the dispute - // If user is buyer and buyer initiated, or user is seller and seller initiated - final userInitiated = _determineIfUserInitiatedDispute( - restoredDispute: restoredDispute, - order: order, - session: session, - ); - - action = userInitiated - ? Action.disputeInitiatedByYou - : Action.disputeInitiatedByPeer; + // We need the session to compare trade indexes + bool userInitiated = false; + if (session == null) { + _logger.w('Restore: no session found for disputed order ${orderDetail.id}, defaulting to peer-initiated'); + action = Action.disputeInitiatedByPeer; + } else { + // Determine if user initiated with double verification TODO : improve if protocol changes + userInitiated = _determineIfUserInitiatedDispute( + restoredDispute: restoredDispute, + session: session, + order: order, + ); + + action = userInitiated + ? Action.disputeInitiatedByYou + : Action.disputeInitiatedByPeer; + } // Create Dispute object dispute = Dispute( @@ -540,13 +566,14 @@ class RestoreService { action: userInitiated ? 'dispute-initiated-by-you' : 'dispute-initiated-by-peer', ); - _logger.i('Restore: dispute found for order ${orderDetail.id}, action: $action'); + _logger.i('Restore: dispute found for order ${orderDetail.id}'); } else { // Regular order without dispute action = _getActionFromStatus(order.status); } - // Build MostroMessage with Order payload + // Build generic MostroMessage with Order payload + // IMPORTAN : we need to create new message due to synchronization with stored messages final mostroMessage = MostroMessage( id: orderDetail.id, action: action, @@ -554,7 +581,7 @@ class RestoreService { timestamp: orderDetail.createdAt ?? DateTime.now().millisecondsSinceEpoch, ); - // Save message to storage for future sync() + // Save message to storage final key = '${orderDetail.id}_restore_${action.value}_${DateTime.now().millisecondsSinceEpoch}'; await storage.addMessage(key, mostroMessage); @@ -567,8 +594,6 @@ class RestoreService { notifier.updateDispute(dispute); _logger.i('Restore: added dispute to state for order ${orderDetail.id}'); } - - _logger.i('Restore: built message for order ${orderDetail.id} with status ${orderDetail.status}, action $action'); } catch (e, stack) { _logger.e('Restore: failed to process order ${orderDetail.id}', error: e, stackTrace: stack); } @@ -580,16 +605,6 @@ class RestoreService { AbstractMostroNotifier.setRestoring(false); _logger.i('Restore: disabled restore mode - re-enabling message processing'); - // Navigate to home and clear notification tray - final navProvider = ref.read(navigationProvider.notifier); - navProvider.go('/'); - - //While bulding subscriptions, some old notifications may have arrived - clear them all - final notifProvider = ref.read(notificationActionsProvider.notifier); - notifProvider.clearAll(); - - _logger.i('Restore: navigated to home and cleared notification tray'); - } catch (e, stack) { // Ensure flag is cleared even on error AbstractMostroNotifier.setRestoring(false); @@ -614,12 +629,14 @@ class RestoreService { final progress = ref.read(restoreProgressProvider.notifier); progress.startRestore(); - // Validate master key + // Validate and initialize master key final keyManager = ref.read(keyManagerProvider); if (keyManager.masterKeyPair == null) { _logger.e('Restore: master key not found after import'); throw Exception('Master key not found'); } + _masterKey = keyManager.masterKeyPair; + _logger.i('Restore: initialized master key'); // Validate Mostro public key final settings = ref.read(settingsProvider); @@ -628,6 +645,10 @@ class RestoreService { throw Exception('Mostro not configured'); } + // Initialize temporary trade key (index 1) for entire restore process + _tempTradeKey = await keyManager.deriveTradeKeyFromIndex(1); + _logger.i('Restore: initialized temp trade key with pubkey ${_tempTradeKey!.public}'); + // Subscribe to temporary notifications _tempSubscription = await _createTempSubscription(); @@ -636,11 +657,11 @@ class RestoreService { await _sendRestoreRequest(); final restoreDataEvent = await _waitForEvent(RestoreStage.gettingRestoreData); final extracted = await _extractRestoreData(restoreDataEvent); - final ordersIds = extracted.orderIds; + final ordersMap = extracted.ordersMap; final disputes = extracted.disputes; - progress.setOrdersReceived(ordersIds.length); + progress.setOrdersReceived(ordersMap.length); - if (ordersIds.isEmpty) { + if (ordersMap.isEmpty) { _logger.w('Restore: no orders or disputes to restore'); await _sendLastTradeIndexRequest(); final lastTradeIndexEvent = await _waitForEvent(RestoreStage.gettingTradeIndex); @@ -653,9 +674,9 @@ class RestoreService { // STAGE 2: Getting Orders Details progress.updateStep(RestoreStep.loadingDetails); - final orderIdsList = ordersIds.keys.toList(); - _logger.i('Restore: requesting details for ${orderIdsList.length} orders: $orderIdsList'); - await _sendOrdersDetailsRequest(orderIdsList); + final ordersIdsList = ordersMap.keys.toList(); + _logger.i('Restore: requesting details for ${ordersIdsList.length} orders: $ordersIdsList'); + await _sendOrdersDetailsRequest(ordersIdsList); final ordersDetailsEvent = await _waitForEvent(RestoreStage.gettingOrdersDetails); final ordersResponse = await _extractOrdersDetails(ordersDetailsEvent); @@ -671,16 +692,27 @@ class RestoreService { // STAGE 4: Processing and restoring sessions progress.updateStep(RestoreStep.processingRoles); - await restore(ordersIds, lastTradeIndex, ordersResponse, disputes); + await restore(ordersMap, lastTradeIndex, ordersResponse, disputes); + + // Navigate to home and clear notification tray + final navProvider = ref.read(navigationProvider.notifier); + navProvider.go('/'); + + //While bulding subscriptions, some old notifications may have arrived - clear them all + final notifProvider = ref.read(notificationActionsProvider.notifier); + notifProvider.clearAll(); + } catch (e, stack) { _logger.e('Restore: error during restore process', error: e, stackTrace: stack); ref.read(restoreProgressProvider.notifier).showError(''); } finally { - // Cleanup: always cancel subscription - _logger.i('Restore: cleaning up subscription'); + // Cleanup: always cancel subscription and clear keys + _logger.i('Restore: cleaning up subscription and keys'); await _tempSubscription?.cancel(); _tempSubscription = null; _currentCompleter = null; + _tempTradeKey = null; + _masterKey = null; // Only call completeRestore if not in error state final currentState = ref.read(restoreProgressProvider); diff --git a/lib/services/mostro_service.dart b/lib/services/mostro_service.dart index 60a3f21f3..35054fc92 100644 --- a/lib/services/mostro_service.dart +++ b/lib/services/mostro_service.dart @@ -40,7 +40,9 @@ class MostroService { _ordersSubscription?.cancel(); _logger.i('MostroService disposed'); } - + + //IMPORTANT : The app always use trade index 1 for restore-related messages + // When subscribtions are created from restore process for real orders, restore related messages may be avoided bool _isRestorePayload(Map json) { // Check if this is a restore-specific payload that should be ignored // These payloads are only used during restore process via temporary trade key From bd2b14049f12367228258e1b640ddfd4c60ec631 Mon Sep 17 00:00:00 2001 From: bracr10 Date: Sun, 9 Nov 2025 23:23:04 -0600 Subject: [PATCH 22/25] Refactors restore mode implementation --- lib/data/models/restore_response.dart | 2 +- .../notfiers/abstract_mostro_notifier.dart | 11 ++---- lib/features/restore/restore_manager.dart | 10 +++--- .../restore/restore_mode_provider.dart | 9 +++++ lib/services/mostro_service.dart | 34 ++++++++++++++----- 5 files changed, 43 insertions(+), 23 deletions(-) create mode 100644 lib/features/restore/restore_mode_provider.dart diff --git a/lib/data/models/restore_response.dart b/lib/data/models/restore_response.dart index 85dc20f41..3fad332bd 100644 --- a/lib/data/models/restore_response.dart +++ b/lib/data/models/restore_response.dart @@ -54,7 +54,7 @@ class RestoredOrder { } Map toJson() => { - 'id': id, + 'order_id': id, 'trade_index': tradeIndex, 'status': status, }; diff --git a/lib/features/order/notfiers/abstract_mostro_notifier.dart b/lib/features/order/notfiers/abstract_mostro_notifier.dart index 03f712a48..eac892b05 100644 --- a/lib/features/order/notfiers/abstract_mostro_notifier.dart +++ b/lib/features/order/notfiers/abstract_mostro_notifier.dart @@ -5,6 +5,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:mostro_mobile/data/enums.dart'; import 'package:mostro_mobile/data/models.dart'; import 'package:mostro_mobile/features/order/models/order_state.dart'; +import 'package:mostro_mobile/features/restore/restore_mode_provider.dart'; import 'package:mostro_mobile/shared/providers.dart'; import 'package:mostro_mobile/features/chat/providers/chat_room_providers.dart'; import 'package:mostro_mobile/features/notifications/providers/notifications_provider.dart'; @@ -25,15 +26,6 @@ class AbstractMostroNotifier extends StateNotifier { // Timer storage for orphan session cleanup static final Map _sessionTimeouts = {}; - // Restore mode flag - blocks all old message processing during restore - static bool _isRestoring = false; - - static void setRestoring(bool value) { - _isRestoring = value; - } - - static bool get isRestoring => _isRestoring; - AbstractMostroNotifier( this.orderId, this.ref, { @@ -58,6 +50,7 @@ class AbstractMostroNotifier extends StateNotifier { next.when( data: (MostroMessage? msg) { // Skip all old message processing during restore - messages are saved but state is not updated + final isRestoring = ref.read(isRestoringProvider); if (isRestoring) { logger.d('Skipping old message processing during restore: ${msg?.action}'); return; diff --git a/lib/features/restore/restore_manager.dart b/lib/features/restore/restore_manager.dart index 7eddf8cb1..98a910099 100644 --- a/lib/features/restore/restore_manager.dart +++ b/lib/features/restore/restore_manager.dart @@ -23,6 +23,7 @@ import 'package:mostro_mobile/data/models/session.dart'; import 'package:mostro_mobile/features/key_manager/key_manager_provider.dart'; import 'package:mostro_mobile/features/restore/restore_progress_notifier.dart'; import 'package:mostro_mobile/features/restore/restore_progress_state.dart'; +import 'package:mostro_mobile/features/restore/restore_mode_provider.dart'; import 'package:mostro_mobile/features/settings/settings_provider.dart'; import 'package:mostro_mobile/shared/providers/mostro_service_provider.dart'; import 'package:mostro_mobile/shared/providers/mostro_storage_provider.dart'; @@ -33,7 +34,6 @@ import 'package:mostro_mobile/shared/providers/order_repository_provider.dart'; import 'package:mostro_mobile/shared/providers/session_notifier_provider.dart'; import 'package:mostro_mobile/features/order/providers/order_notifier_provider.dart'; import 'package:mostro_mobile/features/notifications/providers/notifications_provider.dart'; -import 'package:mostro_mobile/features/order/notfiers/abstract_mostro_notifier.dart'; enum RestoreStage { @@ -451,8 +451,8 @@ class RestoreService { // Set the next trade key index await keyManager.setCurrentKeyIndex(lastTradeIndex + 1); - // Enable restore mode to block all old message processing - AbstractMostroNotifier.setRestoring(true); + // Enable restore mode to block all old message processing + ref.read(isRestoringProvider.notifier).state = true; _logger.i('Restore: enabled restore mode - blocking all old message processing'); // Restore each a session to get future messages @@ -602,12 +602,12 @@ class RestoreService { _logger.i('Restore: state update completed for all orders'); // Disable restore mode - back to normal message processing - AbstractMostroNotifier.setRestoring(false); + ref.read(isRestoringProvider.notifier).state = false; _logger.i('Restore: disabled restore mode - re-enabling message processing'); } catch (e, stack) { // Ensure flag is cleared even on error - AbstractMostroNotifier.setRestoring(false); + ref.read(isRestoringProvider.notifier).state = false; _logger.e('Restore: error during restore', error: e, stackTrace: stack); rethrow; } diff --git a/lib/features/restore/restore_mode_provider.dart b/lib/features/restore/restore_mode_provider.dart new file mode 100644 index 000000000..b7b8d384d --- /dev/null +++ b/lib/features/restore/restore_mode_provider.dart @@ -0,0 +1,9 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// Provider that manages the restore mode state +/// +/// When true, blocks all old message processing in AbstractMostroNotifier +/// to prevent state updates during the restore process. +/// +/// This replaces the previous static mutable flag with proper Riverpod state management. +final isRestoringProvider = StateProvider((ref) => false); diff --git a/lib/services/mostro_service.dart b/lib/services/mostro_service.dart index 35054fc92..adf531867 100644 --- a/lib/services/mostro_service.dart +++ b/lib/services/mostro_service.dart @@ -46,10 +46,18 @@ class MostroService { bool _isRestorePayload(Map json) { // Check if this is a restore-specific payload that should be ignored // These payloads are only used during restore process via temporary trade key + + // Safely get wrapper and validate it's a Map final wrapper = json['restore'] ?? json['order']; - if (wrapper == null || wrapper['payload'] == null) return false; + if (wrapper == null) return false; + if (wrapper is! Map) return false; + + // Safely get payload and validate it's a Map + final payloadValue = wrapper['payload']; + if (payloadValue == null) return false; + if (payloadValue is! Map) return false; - final payload = wrapper['payload'] as Map; + final payload = payloadValue; // RestoreData: has 'restore_data' wrapper with 'orders' and 'disputes' arrays if (payload.containsKey('restore_data')) { @@ -63,12 +71,22 @@ class MostroService { // OrdersResponse: has 'orders' array with OrderDetail objects // OrderDetail has buyer_trade_pubkey/seller_trade_pubkey fields - if (payload.containsKey('orders') && payload['orders'] is List) { - final orders = payload['orders'] as List; - if (orders.isNotEmpty && orders[0] is Map) { - final firstOrder = orders[0] as Map; - if (firstOrder.containsKey('buyer_trade_pubkey') || - firstOrder.containsKey('seller_trade_pubkey')) { + if (payload.containsKey('orders')) { + final ordersValue = payload['orders']; + + // Validate orders is a List + if (ordersValue is! List) return false; + + // Check first element if list is not empty + if (ordersValue.isNotEmpty) { + final firstOrderValue = ordersValue[0]; + + // Validate first element is a Map + if (firstOrderValue is! Map) return false; + + // Check for restore-specific fields + if (firstOrderValue.containsKey('buyer_trade_pubkey') || + firstOrderValue.containsKey('seller_trade_pubkey')) { return true; } } From e254926a746b834a501b874b18a83c1e6b56043a Mon Sep 17 00:00:00 2001 From: bracr10 Date: Mon, 10 Nov 2025 15:43:09 -0600 Subject: [PATCH 23/25] fix : solving action issues according with user roles --- lib/features/restore/restore_manager.dart | 36 +++++++++++++++++------ 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/lib/features/restore/restore_manager.dart b/lib/features/restore/restore_manager.dart index 98a910099..585bbe3a3 100644 --- a/lib/features/restore/restore_manager.dart +++ b/lib/features/restore/restore_manager.dart @@ -400,18 +400,34 @@ class RestoreService { } /// Maps Status to the appropriate Action for restored orders - Action _getActionFromStatus(Status status) { + Action _getActionFromStatus(Status status, Role? userRole) { switch (status) { case Status.pending: return Action.newOrder; case Status.waitingBuyerInvoice: - return Action.waitingBuyerInvoice; + // If user is buyer, they need to add invoice + // If user is seller, they are waiting for buyer to add invoice + return userRole == Role.buyer + ? Action.addInvoice + : Action.waitingBuyerInvoice; case Status.waitingPayment: - return Action.waitingSellerToPay; + // If user is seller, they need to pay invoice + // If user is buyer, they are waiting for seller to pay + return userRole == Role.seller + ? Action.payInvoice + : Action.waitingSellerToPay; case Status.active: - return Action.buyerTookOrder; + // If user is buyer, they need to confirm fiat sent + // If user is seller, buyer took the order and seller waits + return userRole == Role.buyer + ? Action.fiatSent + : Action.buyerTookOrder; case Status.fiatSent: - return Action.fiatSentOk; + // If user is seller, they need to release funds + // If user is buyer, they are waiting for seller to release + return userRole == Role.seller + ? Action.release + : Action.fiatSentOk; case Status.settledHoldInvoice: return Action.holdInvoicePaymentSettled; case Status.success: @@ -471,9 +487,11 @@ class RestoreService { // Determine role by comparing trade keys Role? role; - if (orderDetail.buyerTradePubkey != null && orderDetail.buyerTradePubkey == tradeKey.public) { + final userPubkey = tradeKey.public; + + if (orderDetail.buyerTradePubkey != null && orderDetail.buyerTradePubkey == userPubkey) { role = Role.buyer; - } else if (orderDetail.sellerTradePubkey != null && orderDetail.sellerTradePubkey == tradeKey.public) { + } else if (orderDetail.sellerTradePubkey != null && orderDetail.sellerTradePubkey == userPubkey) { role = Role.seller; } @@ -490,8 +508,6 @@ class RestoreService { // Store session await sessionNotifier.saveSession(session); - _logger.i('Restore: created session for order ${orderDetail.id}'); - progress.incrementProgress(); } @@ -570,6 +586,8 @@ class RestoreService { } else { // Regular order without dispute action = _getActionFromStatus(order.status); + final session = ref.read(sessionNotifierProvider.notifier).getSessionByOrderId(orderDetail.id); + action = _getActionFromStatus(order.status, session?.role); } // Build generic MostroMessage with Order payload From 3c59a2959530e7e3bb5a3f100aad00580222de17 Mon Sep 17 00:00:00 2001 From: bracr10 Date: Mon, 10 Nov 2025 15:56:57 -0600 Subject: [PATCH 24/25] fix : removing duplicated var --- lib/features/restore/restore_manager.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/features/restore/restore_manager.dart b/lib/features/restore/restore_manager.dart index 585bbe3a3..f5b0ee661 100644 --- a/lib/features/restore/restore_manager.dart +++ b/lib/features/restore/restore_manager.dart @@ -585,7 +585,6 @@ class RestoreService { _logger.i('Restore: dispute found for order ${orderDetail.id}'); } else { // Regular order without dispute - action = _getActionFromStatus(order.status); final session = ref.read(sessionNotifierProvider.notifier).getSessionByOrderId(orderDetail.id); action = _getActionFromStatus(order.status, session?.role); } From cf3f61ae427e6202dae9c871633c4341e566627b Mon Sep 17 00:00:00 2001 From: bracr10 Date: Tue, 11 Nov 2025 01:11:45 -0600 Subject: [PATCH 25/25] fix : restore msgs state issues --- lib/features/restore/restore_manager.dart | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/lib/features/restore/restore_manager.dart b/lib/features/restore/restore_manager.dart index f5b0ee661..ce1f7603c 100644 --- a/lib/features/restore/restore_manager.dart +++ b/lib/features/restore/restore_manager.dart @@ -420,14 +420,10 @@ class RestoreService { // If user is buyer, they need to confirm fiat sent // If user is seller, buyer took the order and seller waits return userRole == Role.buyer - ? Action.fiatSent + ? Action.holdInvoicePaymentAccepted : Action.buyerTookOrder; case Status.fiatSent: - // If user is seller, they need to release funds - // If user is buyer, they are waiting for seller to release - return userRole == Role.seller - ? Action.release - : Action.fiatSentOk; + return Action.fiatSentOk; case Status.settledHoldInvoice: return Action.holdInvoicePaymentSettled; case Status.success: