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/enums/action.dart b/lib/data/models/enums/action.dart index 64cf32843..38418e82b 100644 --- a/lib/data/models/enums/action.dart +++ b/lib/data/models/enums/action.dart @@ -38,7 +38,10 @@ enum Action { paymentFailed('payment-failed'), invoiceUpdated('invoice-updated'), sendDm('send-dm'), - tradePubkey('trade-pubkey'); + tradePubkey('trade-pubkey'), + restore('restore-session'), + 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, + }; +} diff --git a/lib/data/models/mostro_message.dart b/lib/data/models/mostro_message.dart index 5b34998c2..39991b483 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; + // 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; return MostroMessage( @@ -97,7 +99,9 @@ class MostroMessage { } String sign(NostrKeyPairs keyPair) { - final message = {'order': toJson()}; + //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); final bytes = utf8.encode(serializedEvent); final digest = sha256.convert(bytes); @@ -107,7 +111,9 @@ class MostroMessage { } String serialize({NostrKeyPairs? keyPair}) { - final message = {'order': toJson()}; + //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); final signature = (keyPair != null) ? '"${sign(keyPair)}"' : null; final content = '[$serializedEvent, $signature]'; 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, + }; +} 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_response.dart b/lib/data/models/restore_response.dart new file mode 100644 index 000000000..3fad332bd --- /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() => { + 'order_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 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/key_manager/import_mnemonic_dialog.dart b/lib/features/key_manager/import_mnemonic_dialog.dart new file mode 100644 index 000000000..bb42f0d12 --- /dev/null +++ b/lib/features/key_manager/import_mnemonic_dialog.dart @@ -0,0 +1,227 @@ +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}); + + @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; + } + + // Use BIP39 checksum validation + final isValid = validateMnemonic(trimmed); + + setState(() { + _errorMessage = isValid ? null : S.of(context)!.invalidMnemonic; + }); + + return isValid; + } + + 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..f6f68fc96 100644 --- a/lib/features/key_manager/key_management_screen.dart +++ b/lib/features/key_manager/key_management_screen.dart @@ -6,9 +6,12 @@ import 'package:heroicons/heroicons.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'; +import 'package:mostro_mobile/shared/providers/notifications_history_repository_provider.dart'; class KeyManagementScreen extends ConsumerStatefulWidget { const KeyManagementScreen({super.key}); @@ -66,6 +69,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(); @@ -167,8 +172,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 +572,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 +736,88 @@ 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(); + + final restoreService = ref.read(restoreServiceProvider); + await restoreService.initRestoreProcess(); + }, + 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 { + + final mnemonic = await showDialog( + context: context, + builder: (BuildContext dialogContext) { + return const ImportMnemonicDialog(); + }, + ); + + if (mnemonic != null && mnemonic.isNotEmpty) { + final restoreService = ref.read(restoreServiceProvider); + await restoreService.importMnemonicAndRestore(mnemonic); + } + } +} \ No newline at end of file diff --git a/lib/features/notifications/utils/notification_message_mapper.dart b/lib/features/notifications/utils/notification_message_mapper.dart index 677cbe0f4..26e5f22ef 100644 --- a/lib/features/notifications/utils/notification_message_mapper.dart +++ b/lib/features/notifications/utils/notification_message_mapper.dart @@ -84,6 +84,10 @@ class NotificationMessageMapper { return 'notification_dispute_started_title'; case mostro.Action.tradePubkey: return 'notification_order_update_title'; + case mostro.Action.restore: + case mostro.Action.orders: + case mostro.Action.lastTradeIndex: + return 'TODO: implement title key if needed'; } } @@ -176,6 +180,11 @@ class NotificationMessageMapper { return 'notification_dispute_started_message'; case mostro.Action.tradePubkey: return 'notification_order_update_message'; + case mostro.Action.restore: + case mostro.Action.orders: + 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 5b7337f76..6b5d2902c 100644 --- a/lib/features/notifications/widgets/notification_item.dart +++ b/lib/features/notifications/widgets/notification_item.dart @@ -121,6 +121,9 @@ class NotificationItem extends ConsumerWidget { case mostro_action.Action.adminTookDispute: case mostro_action.Action.invoiceUpdated: case mostro_action.Action.tradePubkey: + case mostro_action.Action.restore: + case mostro_action.Action.orders: + case mostro_action.Action.lastTradeIndex: break; } } diff --git a/lib/features/order/notfiers/abstract_mostro_notifier.dart b/lib/features/order/notfiers/abstract_mostro_notifier.dart index acd56c63e..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'; @@ -21,7 +22,7 @@ class AbstractMostroNotifier extends StateNotifier { ProviderSubscription>? subscription; final Set _processedEventIds = {}; - + // Timer storage for orphan session cleanup static final Map _sessionTimeouts = {}; @@ -48,6 +49,13 @@ class AbstractMostroNotifier extends StateNotifier { (_, next) { 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; + } + if (kDebugMode) { logger.i('Received message: ${msg?.toJson()}'); } else { @@ -56,7 +64,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 5e8f161ea..dc1a1446a 100644 --- a/lib/features/order/notfiers/order_notifier.dart +++ b/lib/features/order/notfiers/order_notifier.dart @@ -139,6 +139,20 @@ class OrderNotifier extends AbstractMostroNotifier { ); } + /// Update state from MostroMessage (used during restore) + void updateStateFromMessage(MostroMessage message) { + if (mounted) { + state = state.updateWith(message); + } + } + + /// 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 new file mode 100644 index 000000000..ce1f7603c --- /dev/null +++ b/lib/features/restore/restore_manager.dart @@ -0,0 +1,741 @@ +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'; +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/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'; +import 'package:mostro_mobile/data/models/payload.dart'; +import 'package:mostro_mobile/data/models/restore_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/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'; +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/features/order/providers/order_notifier_provider.dart'; +import 'package:mostro_mobile/features/notifications/providers/notifications_provider.dart'; + + +enum RestoreStage { + gettingRestoreData, + gettingOrdersDetails, + gettingTradeIndex, +} + +class RestoreService { + + final Ref ref; + final Logger _logger = Logger(); + 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); + + 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); + + // Get the new instance and initialize it + final newKeyManager = ref.read(keyManagerProvider); + await newKeyManager.init(); + + 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(); + await ref.read(notificationsRepositoryProvider).clearAll(); + ref.read(orderRepositoryProvider).clearCache(); + + } catch (e) { + _logger.w('Restore: cleanup error', error: e); + } + } + + 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) { + // Check if event matches current stage criteria + if (_currentCompleter != null && !_currentCompleter!.isCompleted) { + _currentCompleter!.complete(event); + } + } + + Future> _createTempSubscription() async { + //use temporary trade key 1 to subscribe to restore notifications + 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 + ); + + 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, + ); + + _logger.i('Restore: temporary subscription created'); + return subscription; + } + + Future _sendRestoreRequest() async { + _logger.i('Restore: sending restore data request'); + + if (_tempTradeKey == null && _masterKey == null) { + throw Exception('Temp trade key or master key not initialized'); + } + + final settings = ref.read(settingsProvider); + + // 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, wrap will be done just with trade key + final wrappedEvent = await mostroMessage.wrap( + tradeKey: _tempTradeKey!, + recipientPubKey: settings.mostroPublicKey, + masterKey: settings.fullPrivacyMode ? null : _masterKey + ); + + await ref.read(nostrServiceProvider).publishEvent(wrappedEvent); + _logger.i('Restore: request sent successfully'); + } + + //Extracts restore data, returns: + // Orders map {orderId: tradeIndex} + // List of disputes + Future<({Map ordersMap, List disputes})> _extractRestoreData(NostrEvent event) async { + try { + 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!); + + 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; + + // 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 (ordersMap: {}, disputes: []); + } + + // Extract payload from restore wrapper + final restoreWrapper = messageData['restore'] as Map?; + + if (restoreWrapper == null) { + _logger.w('Restore: no restore wrapper found, returning empty orders'); + 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 (ordersMap: {}, disputes: []); + } + + final restoreData = RestoreData.fromJson(payload); + + final Map ordersMap = {}; + + for (var order in restoreData.orders) { + ordersMap[order.id] = order.tradeIndex; + } + + //Also orders with disputes must be included + for (var dispute in restoreData.disputes) { + ordersMap[dispute.orderId] = dispute.tradeIndex; + } + + final List disputesList = restoreData.disputes; + + return (ordersMap: ordersMap, disputes: disputesList); + } 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'); + + if (_tempTradeKey == null && _masterKey == null) { + throw Exception('Temp trade key or master key not initialized'); + } + + final settings = ref.read(settingsProvider); + + 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!, + recipientPubKey: settings.mostroPublicKey, + 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}'); + + 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!); + + 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'); + + return ordersResponse; + } catch (e, stack) { + _logger.e('Restore: failed to extract orders details', error: e, stackTrace: stack); + rethrow; + } + } + + Future _sendLastTradeIndexRequest() async { + _logger.i('Restore: sending last trade index request'); + + if (_tempTradeKey == null && _masterKey == null) { + throw Exception('Temp trade key or master key not initialized'); + } + + final settings = ref.read(settingsProvider); + + // 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!, + recipientPubKey: settings.mostroPublicKey, + masterKey: settings.fullPrivacyMode ? null : _masterKey + ); + + 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}'); + + if (_tempTradeKey == null) { + throw Exception('Temp trade key not initialized'); + } + + 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; + + // 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?; + + if (restoreWrapper == null) { + _logger.w('Restore: no restore wrapper found, defaulting trade index to 0'); + return LastTradeIndexResponse(tradeIndex: 0); + } + + 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; + } + } + + /// 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, + }) { + // 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; + } + + 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; + } + + // 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 + Action _getActionFromStatus(Status status, Role? userRole) { + switch (status) { + case Status.pending: + return Action.newOrder; + case Status.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: + // 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: + // 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.holdInvoicePaymentAccepted + : 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; //No should be used - 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, 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); + + // 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 + 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; + final userPubkey = tradeKey.public; + + if (orderDetail.buyerTradePubkey != null && orderDetail.buyerTradePubkey == userPubkey) { + role = Role.buyer; + } else if (orderDetail.sellerTradePubkey != null && orderDetail.sellerTradePubkey == userPubkey) { + role = Role.seller; + } + + final session = Session( + masterKey: _masterKey!, + tradeKey: tradeKey, + keyIndex: tradeIndex, + fullPrivacy: settings.fullPrivacyMode, + startTime: DateTime.now(), + orderId: orderDetail.id, + role: role, + ); + + // Store session + await sessionNotifier.saveSession(session); + + progress.incrementProgress(); + } + + // Wait for historical messages to arrive and be saved to storage + _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 + 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, + ); + + // 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); + + // 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( + 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}'); + } else { + // Regular order without dispute + final session = ref.read(sessionNotifierProvider.notifier).getSessionByOrderId(orderDetail.id); + action = _getActionFromStatus(order.status, session?.role); + } + + // 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, + payload: order, + timestamp: orderDetail.createdAt ?? DateTime.now().millisecondsSinceEpoch, + ); + + // Save message to storage + final key = '${orderDetail.id}_restore_${action.value}_${DateTime.now().millisecondsSinceEpoch}'; + await storage.addMessage(key, mostroMessage); + + // Update state using public method that calls updateWith internally + 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}'); + } + } catch (e, stack) { + _logger.e('Restore: failed to process order ${orderDetail.id}', error: e, stackTrace: stack); + } + } + + _logger.i('Restore: state update completed for all orders'); + + // Disable restore mode - back to normal message processing + 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 + ref.read(isRestoringProvider.notifier).state = false; + _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 + await _clearAll(); + + // Show restore overlay + final progress = ref.read(restoreProgressProvider.notifier); + progress.startRestore(); + + // 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); + if (settings.mostroPublicKey.isEmpty) { + _logger.e('Restore: Mostro not configured'); + 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(); + + // STAGE 1: Getting Restore Data + progress.updateStep(RestoreStep.requesting); + await _sendRestoreRequest(); + final restoreDataEvent = await _waitForEvent(RestoreStage.gettingRestoreData); + final extracted = await _extractRestoreData(restoreDataEvent); + final ordersMap = extracted.ordersMap; + final disputes = extracted.disputes; + progress.setOrdersReceived(ordersMap.length); + + if (ordersMap.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); + 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); + + // 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(); + _tempSubscription = null; + + // STAGE 4: Processing and restoring sessions + progress.updateStep(RestoreStep.processingRoles); + 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 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); + if (currentState.step != RestoreStep.error) { + ref.read(restoreProgressProvider.notifier).completeRestore(); + } + } + } +} + +final restoreServiceProvider = Provider((ref) { + return RestoreService(ref); +}); \ No newline at end of file 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/features/restore/restore_overlay.dart b/lib/features/restore/restore_overlay.dart new file mode 100644 index 000000000..e0988b044 --- /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 S.of(context)!.restoreErrorMessage; + } + + 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 diff --git a/lib/features/restore/restore_progress_notifier.dart b/lib/features/restore/restore_progress_notifier.dart new file mode 100644 index 000000000..0ee622795 --- /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 3 seconds + Future.delayed(const Duration(seconds: 3), () { + 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 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 ab95ab4a7..bb9a9cff7 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -459,11 +459,35 @@ "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}", + "@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,25 @@ "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 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", + "invalidMnemonic": "Invalid mnemonic. Please check your words", + + "@_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", + "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 ea5a80393..c4ae6db36 100644 --- a/lib/l10n/intl_es.arb +++ b/lib/l10n/intl_es.arb @@ -381,11 +381,35 @@ "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}", + "@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,26 @@ "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", + "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", + "invalidMnemonic": "Mnemonic inválido. Revisa tus palabras", + + "@_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", + "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 a0e59dc07..9b4a9053c 100644 --- a/lib/l10n/intl_it.arb +++ b/lib/l10n/intl_it.arb @@ -410,11 +410,35 @@ "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}", + "@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,25 @@ "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", + "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", + "invalidMnemonic": "Mnemonic non valido. Verifica le tue parole", + + "@_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", + "restoreErrorMessage": "Errore nel ripristino dei dati utente. Verifica la tua connessione e riprova più tardi." +} \ No newline at end of file diff --git a/lib/services/mostro_service.dart b/lib/services/mostro_service.dart index 6f14bd47c..adf531867 100644 --- a/lib/services/mostro_service.dart +++ b/lib/services/mostro_service.dart @@ -40,6 +40,60 @@ 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 + + // Safely get wrapper and validate it's a Map + final wrapper = json['restore'] ?? json['order']; + 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 = payloadValue; + + // 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')) { + 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; + } + } + } + + return false; + } Future _onData(NostrEvent event) async { final eventStore = ref.read(eventStorageProvider); @@ -65,6 +119,7 @@ class MostroService { try { final decryptedEvent = await event.unWrap(privateKey); + if (decryptedEvent.content == null) return; final result = jsonDecode(decryptedEvent.content!); @@ -81,7 +136,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 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(