diff --git a/lib/core/config.dart b/lib/core/config.dart index 3333f4f0..853a382f 100644 --- a/lib/core/config.dart +++ b/lib/core/config.dart @@ -1,7 +1,7 @@ import 'package:flutter/foundation.dart'; class Config { - // Configuración de Nostr + // Nostr configuration static const List nostrRelays = [ 'wss://relay.mostro.network', //'ws://127.0.0.1:7000', @@ -9,7 +9,7 @@ class Config { //'ws://10.0.2.2:7000', // mobile emulator ]; - // hexkey de Mostro + // Mostro hexkey static const String mostroPubKey = '82fa8cb978b43c79b2156585bac2c011176a21d2aead6d9f7c575c005be88390'; //'9d9d0455a96871f2dc4289b8312429db2e925f167b37c77bf7b28014be235980'; @@ -17,21 +17,25 @@ class Config { static const String dBName = 'mostro.db'; static const String dBPassword = 'mostro'; - // Tiempo de espera para conexiones a relays + // Timeout for relay connections static const Duration nostrConnectionTimeout = Duration(seconds: 30); static bool fullPrivacyMode = false; - // Modo de depuración + // Debug mode static bool get isDebug => !kReleaseMode; - // Versión de Mostro + // Mostro version static int mostroVersion = 1; static int expirationSeconds = 900; static int expirationHours = 24; - // Configuración de notificaciones + // Notification configuration static String notificationChannelId = 'mostro_mobile'; static int notificationId = 38383; + + // Timeouts for timeout detection (new critical operations) + static const Duration timeoutDetectionTimeout = Duration(seconds: 8); + static const Duration messageStorageTimeout = Duration(seconds: 5); } diff --git a/lib/data/models/enums/action.dart b/lib/data/models/enums/action.dart index 64cf3284..1fd94065 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'), + timeoutReversal('timeout-reversal'); final String value; diff --git a/lib/data/models/mostro_message.dart b/lib/data/models/mostro_message.dart index f908bd44..1addf6ef 100644 --- a/lib/data/models/mostro_message.dart +++ b/lib/data/models/mostro_message.dart @@ -5,6 +5,10 @@ import 'package:dart_nostr/nostr/core/key_pairs.dart'; import 'package:dart_nostr/nostr/model/event/event.dart'; import 'package:mostro_mobile/core/config.dart'; import 'package:mostro_mobile/data/models/enums/action.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/nostr_event.dart'; +import 'package:mostro_mobile/data/models/order.dart'; import 'package:mostro_mobile/data/models/payload.dart'; import 'package:mostro_mobile/shared/utils/nostr_utils.dart'; @@ -25,6 +29,57 @@ class MostroMessage { this.timestamp}) : _payload = payload; + /// Factory constructor for creating timeout reversal messages + /// This creates a synthetic message to persist the timeout state change + /// with complete order information from the public event + factory MostroMessage.createTimeoutReversal({ + required String orderId, + required int timestamp, + required Status originalStatus, + required NostrEvent publicEvent, + }) { + // Extract complete information from the 38383 public event + final fiatAmountRange = publicEvent.fiatAmount; + final paymentMethodsList = publicEvent.paymentMethods; + + return MostroMessage( + action: Action.timeoutReversal, + id: orderId, + timestamp: timestamp, + payload: Order( + // Core order information from public event + id: publicEvent.orderId, + status: Status.pending, // Only this changes - reverting to pending + kind: publicEvent.orderType ?? OrderType.sell, + fiatCode: publicEvent.currency ?? 'USD', + fiatAmount: fiatAmountRange.minimum, + paymentMethod: paymentMethodsList.isNotEmpty + ? paymentMethodsList.join(', ') + : 'N/A', + + // Additional information for proper UI display + amount: int.tryParse(publicEvent.amount ?? '0') ?? 0, + minAmount: fiatAmountRange.minimum, + maxAmount: fiatAmountRange.maximum, + premium: int.tryParse(publicEvent.premium ?? '0') ?? 0, + + // Timestamps for countdown timer and creation info + createdAt: publicEvent.createdAt?.millisecondsSinceEpoch, + expiresAt: publicEvent.expirationDate.millisecondsSinceEpoch, + + // Master keys for reputation display (may be null, that's OK) + masterBuyerPubkey: publicEvent.orderType == OrderType.buy + ? publicEvent.pubkey // Creator of buy order is buyer + : null, + masterSellerPubkey: publicEvent.orderType == OrderType.sell + ? publicEvent.pubkey // Creator of sell order is seller + : null, + + // Trade keys and other fields will be null, which is fine for UI + ) as T, + ); + } + Map toJson() { Map json = { 'version': Config.mostroVersion, diff --git a/lib/features/order/models/order_state.dart b/lib/features/order/models/order_state.dart index b27e3d6a..434bb593 100644 --- a/lib/features/order/models/order_state.dart +++ b/lib/features/order/models/order_state.dart @@ -82,7 +82,7 @@ class OrderState { } OrderState updateWith(MostroMessage message) { - _logger.i('🔄 Updating OrderState with Action: ${message.action}'); + _logger.i('Updating OrderState with Action: ${message.action}'); // Preserve the current state entirely for cantDo messages - they are informational only if (message.action == Action.cantDo) { @@ -93,14 +93,14 @@ class OrderState { Status newStatus = _getStatusFromAction( message.action, message.getPayload()?.status); - // 🔍 DEBUG: Log status mapping - _logger.i('📊 Status mapping: ${message.action} → $newStatus'); + // DEBUG: Log status mapping + _logger.i('Status mapping: ${message.action} → $newStatus'); // Preserve PaymentRequest correctly PaymentRequest? newPaymentRequest; if (message.payload is PaymentRequest) { newPaymentRequest = message.getPayload(); - _logger.i('💳 New PaymentRequest found in message'); + _logger.i('New PaymentRequest found in message'); } else { newPaymentRequest = paymentRequest; // Preserve existing } @@ -119,9 +119,9 @@ class OrderState { peer: message.getPayload() ?? peer, ); - _logger.i('✅ New state: ${newState.status} - ${newState.action}'); + _logger.i('New state: ${newState.status} - ${newState.action}'); _logger - .i('💳 PaymentRequest preserved: ${newState.paymentRequest != null}'); + .i('PaymentRequest preserved: ${newState.paymentRequest != null}'); return newState; } @@ -139,7 +139,7 @@ class OrderState { case Action.addInvoice: return Status.waitingBuyerInvoice; - // ✅ FIX: Cuando alguien toma una orden, debe cambiar el status inmediatamente + // FIX: Cuando alguien toma una orden, debe cambiar el status inmediatamente case Action.takeBuy: // Cuando buyer toma sell order, seller debe esperar buyer invoice return Status.waitingBuyerInvoice; @@ -176,6 +176,10 @@ class OrderState { case Action.newOrder: return payloadStatus ?? status; + // Action for timeout reversal - always use payload status (should be pending) + case Action.timeoutReversal: + return payloadStatus ?? Status.pending; + // For other actions, keep the current status unless payload has a different one default: return payloadStatus ?? status; @@ -195,6 +199,9 @@ class OrderState { Action.takeBuy: [ Action.cancel, ], + Action.timeoutReversal: [ + Action.cancel, + ], }, Status.waitingPayment: { Action.payInvoice: [ @@ -272,6 +279,9 @@ class OrderState { Action.takeSell: [ Action.cancel, ], + Action.timeoutReversal: [ + Action.cancel, + ], }, Status.waitingPayment: { Action.waitingSellerToPay: [ diff --git a/lib/features/order/notfiers/abstract_mostro_notifier.dart b/lib/features/order/notfiers/abstract_mostro_notifier.dart index ec9d1abe..a67e9aae 100644 --- a/lib/features/order/notfiers/abstract_mostro_notifier.dart +++ b/lib/features/order/notfiers/abstract_mostro_notifier.dart @@ -214,6 +214,9 @@ class AbstractMostroNotifier extends StateNotifier { 'payment_retries_interval': -1000 }); break; + case Action.timeoutReversal: + // No automatic notification - handled manually in OrderNotifier + break; default: notifProvider.showInformation(event.action, values: {}); break; diff --git a/lib/features/order/notfiers/order_notifier.dart b/lib/features/order/notfiers/order_notifier.dart index 6994e8e6..9bb69d32 100644 --- a/lib/features/order/notfiers/order_notifier.dart +++ b/lib/features/order/notfiers/order_notifier.dart @@ -1,5 +1,9 @@ import 'dart:async'; +import 'package:dart_nostr/dart_nostr.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:mostro_mobile/core/config.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/shared/providers.dart'; import 'package:mostro_mobile/features/order/notfiers/abstract_mostro_notifier.dart'; @@ -7,13 +11,38 @@ import 'package:mostro_mobile/services/mostro_service.dart'; class OrderNotifier extends AbstractMostroNotifier { late final MostroService mostroService; + ProviderSubscription>>? _publicEventsSubscription; + bool _isProcessingTimeout = false; + OrderNotifier(super.orderId, super.ref) { mostroService = ref.read(mostroServiceProvider); sync(); subscribe(); + _subscribeToPublicEvents(); + } + + @override + void handleEvent(MostroMessage event) async { + // First handle the event normally + super.handleEvent(event); + + // Then check for timeout if we're in waiting states + // Only check if we have a valid session (this is a taker scenario) + final currentSession = ref.read(sessionNotifierProvider.notifier).getSessionByOrderId(orderId); + if (mounted && currentSession != null && (state.status == Status.waitingBuyerInvoice || state.status == Status.waitingPayment)) { + final shouldCleanup = await _checkTimeoutAndCleanup(state, event); + if (shouldCleanup) { + // Session was cleaned up, invalidate this provider + ref.invalidateSelf(); + } + } } Future sync() async { + if (_isProcessingTimeout) return; + try { + _isProcessingTimeout = true; + final storage = ref.read(mostroStorageProvider); final messages = await storage.getAllMessagesForOrderId(orderId); if (messages.isEmpty) { @@ -25,13 +54,31 @@ class OrderNotifier extends AbstractMostroNotifier { final timestampB = b.timestamp ?? 0; return timestampA.compareTo(timestampB); }); + OrderState currentState = state; + MostroMessage? latestGiftWrap; + for (final message in messages) { if (message.action != Action.cantDo) { currentState = currentState.updateWith(message); + latestGiftWrap = message; // Keep track of latest gift wrap + } + } + + // Check if we should cleanup session due to timeout + // Only check if we have a valid session (this is a taker scenario) + final currentSession = ref.read(sessionNotifierProvider.notifier).getSessionByOrderId(orderId); + if (currentSession != null) { + final shouldCleanup = await _checkTimeoutAndCleanup(currentState, latestGiftWrap); + if (shouldCleanup) { + // Session was cleaned up, this provider should be invalidated + ref.invalidateSelf(); + return; } } + state = currentState; + logger.i( 'Synced order $orderId to state: ${state.status} - ${state.action}'); } catch (e, stack) { @@ -40,6 +87,8 @@ class OrderNotifier extends AbstractMostroNotifier { error: e, stackTrace: stack, ); + } finally { + _isProcessingTimeout = false; } } @@ -105,4 +154,219 @@ class OrderNotifier extends AbstractMostroNotifier { rating, ); } + + /// Check if session should be cleaned up due to timeout + /// Returns true if session was cleaned up, false otherwise + Future _checkTimeoutAndCleanup(OrderState currentState, MostroMessage? latestGiftWrap) async { + // Only check for timeout in waiting states + if (currentState.status != Status.waitingBuyerInvoice && + currentState.status != Status.waitingPayment) { + return false; + } + + if (latestGiftWrap == null) { + return false; + } + + try { + // Get the public event for this order from 38383 events + final publicEventAsync = ref.read(eventProvider(orderId)); + if (publicEventAsync == null) { + // No public event found, no cleanup needed + return false; + } + + final publicEvent = publicEventAsync; + + // Check if public event shows pending status + if (publicEvent.status != Status.pending) { + // Public event is not pending, no cleanup needed + return false; + } + + // Compare timestamps: public event vs latest gift wrap + final publicTimestamp = publicEvent.createdAt; + final giftWrapTimestamp = DateTime.fromMillisecondsSinceEpoch(latestGiftWrap.timestamp ?? 0); + + if (publicTimestamp != null && publicTimestamp.isAfter(giftWrapTimestamp)) { + // Timeout detected: Public event is newer and shows pending + logger.i('Timeout detected for order $orderId: Public event ($publicTimestamp) is newer than gift wrap ($giftWrapTimestamp)'); + + // Determine if this is a maker (created by user) or taker (taken by user) + final currentSession = ref.read(sessionNotifierProvider.notifier).getSessionByOrderId(orderId); + if (currentSession == null) { + return false; + } + + final isCreatedByUser = _isCreatedByUser(currentSession, publicEvent); + + if (isCreatedByUser) { + // MAKER SCENARIO: Keep session but update state to pending + logger.i('Order created by user - updating state to pending while keeping session'); + + // Show notification: counterpart didn't respond, order will be republished + _showTimeoutNotification(isCreatedByUser: true); + + // CRITICAL: Persist the timeout reversal to maintain pending status after app restart + try { + final storage = ref.read(mostroStorageProvider); + final timeoutMessage = MostroMessage.createTimeoutReversal( + orderId: orderId, + timestamp: publicTimestamp.millisecondsSinceEpoch, + originalStatus: currentState.status, + publicEvent: publicEvent, + ); + + // Use a unique key that includes timestamp to avoid conflicts + final messageKey = '${orderId}_timeout_${publicTimestamp.millisecondsSinceEpoch}'; + await storage.addMessage(messageKey, timeoutMessage) + .timeout(Config.messageStorageTimeout, onTimeout: () { + logger.w('Timeout persisting timeout reversal message for order $orderId - continuing anyway'); + }); + + logger.i('Timeout reversal message persisted for order $orderId'); + } catch (e, stack) { + logger.e('Failed to persist timeout reversal message for order $orderId', + error: e, stackTrace: stack); + // Continue execution even if persistence fails + } + + // Update state to pending without removing session + state = state.copyWith( + status: Status.pending, + action: Action.timeoutReversal, + ); + + // Return false to indicate no cleanup (session preserved) + return false; + + } else { + // TAKER SCENARIO: Remove session completely + logger.i('Order taken by user - cleaning up session as order will be removed from My Trades'); + + // Show notification: user didn't respond + _showTimeoutNotification(isCreatedByUser: false); + + final sessionNotifier = ref.read(sessionNotifierProvider.notifier); + await sessionNotifier.deleteSession(orderId); + + // Return true to indicate session was cleaned up + return true; + } + } + + // No timeout detected, no cleanup needed + return false; + + } catch (e, stack) { + logger.e( + 'Error checking timeout for order $orderId', + error: e, + stackTrace: stack, + ); + // On error, no cleanup + return false; + } + } + + /// Determine if the order was created by the user (maker) or taken by user (taker) + bool _isCreatedByUser(Session session, NostrEvent publicEvent) { + final userRole = session.role; + final orderType = publicEvent.orderType; + + // Logic from TradesListItem: user is creator if role matches order type + if (userRole == Role.buyer && orderType == OrderType.buy) { + return true; // User created a buy order as buyer + } + + if (userRole == Role.seller && orderType == OrderType.sell) { + return true; // User created a sell order as seller + } + + return false; // User took someone else's order (taker) + } + + /// Subscribe to public events (38383) to detect timeout in real-time + void _subscribeToPublicEvents() { + _publicEventsSubscription = ref.listen( + orderEventsProvider, + (_, next) async { + // Prevent multiple timeout processing from running concurrently + if (_isProcessingTimeout) { + logger.d('Timeout processing already in progress for order $orderId'); + return; + } + + try { + _isProcessingTimeout = true; + + // Verify current state (could have changed) + final currentSession = ref.read(sessionNotifierProvider.notifier).getSessionByOrderId(orderId); + if (!mounted || currentSession == null) { + return; + } + + // Re-verify state after setting flag + if (state.status != Status.waitingBuyerInvoice && + state.status != Status.waitingPayment) { + return; + } + + final storage = ref.read(mostroStorageProvider); + final messages = await storage.getAllMessagesForOrderId(orderId) + .timeout(Config.timeoutDetectionTimeout, onTimeout: () { + logger.w('Timeout getting messages for timeout detection in order $orderId - skipping cleanup'); + return []; + }); + + if (messages.isNotEmpty) { + messages.sort((a, b) => (a.timestamp ?? 0).compareTo(b.timestamp ?? 0)); + final latestGiftWrap = messages.last; + + // Verify state one more time before cleanup + if (mounted && (state.status == Status.waitingBuyerInvoice || + state.status == Status.waitingPayment)) { + final shouldCleanup = await _checkTimeoutAndCleanup(state, latestGiftWrap) + .timeout(Config.timeoutDetectionTimeout, onTimeout: () { + logger.w('Timeout in cleanup detection for order $orderId - assuming no cleanup needed'); + return false; + }); + if (shouldCleanup) { + logger.i('Real-time timeout detected - cleaning up session for order $orderId'); + ref.invalidateSelf(); + } + } + } + } finally { + _isProcessingTimeout = false; + } + }, + ); + } + + /// Show timeout notification message + void _showTimeoutNotification({required bool isCreatedByUser}) { + try { + final notificationNotifier = ref.read(notificationProvider.notifier); + + // Show appropriate message based on user role + if (isCreatedByUser) { + // User is maker - counterpart didn't respond + // Use key for translation lookup in the UI + notificationNotifier.showCustomMessage('orderTimeoutMaker'); + } else { + // User is taker - user didn't respond + // Use key for translation lookup in the UI + notificationNotifier.showCustomMessage('orderTimeoutTaker'); + } + } catch (e, stack) { + logger.e('Error showing timeout notification', error: e, stackTrace: stack); + } + } + + @override + void dispose() { + _publicEventsSubscription?.close(); + super.dispose(); + } } diff --git a/lib/features/trades/widgets/mostro_message_detail_widget.dart b/lib/features/trades/widgets/mostro_message_detail_widget.dart index aa637784..ef5796c2 100644 --- a/lib/features/trades/widgets/mostro_message_detail_widget.dart +++ b/lib/features/trades/widgets/mostro_message_detail_widget.dart @@ -40,7 +40,9 @@ class MostroMessageDetail extends ConsumerWidget { text: formatTextWithBoldUsernames(actionText, context), ), const SizedBox(height: 16), - Text('${orderState.status} - ${orderState.action}'), + Text(orderState.action == actions.Action.timeoutReversal + ? orderState.status.toString() + : '${orderState.status} - ${orderState.action}'), ], ), ), @@ -191,6 +193,8 @@ class MostroMessageDetail extends ConsumerWidget { return S.of(context)!.holdInvoicePaymentCanceled; case actions.Action.cantDo: return _getCantDoMessage(context, ref); + case actions.Action.timeoutReversal: + return S.of(context)!.orderTimeoutMaker; // Counterpart didn't respond, order republished default: return 'No message found for action ${tradeState.action}'; // This is a fallback message for developers } diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index 77ee244b..2ddbbcdc 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -654,5 +654,9 @@ "editRelay": "Edit Relay", "relayUrl": "Relay URL", "add": "Add", - "save": "Save" + "save": "Save", + + "@_comment_timeout_messages": "Timeout notification messages", + "orderTimeoutTaker": "You didn't respond in time. The order will be republished", + "orderTimeoutMaker": "Your counterpart didn't respond in time. The order will be republished" } diff --git a/lib/l10n/intl_es.arb b/lib/l10n/intl_es.arb index 0758ec9d..0e0ea6d8 100644 --- a/lib/l10n/intl_es.arb +++ b/lib/l10n/intl_es.arb @@ -686,5 +686,9 @@ "editRelay": "Editar Relay", "relayUrl": "URL del Relay", "add": "Agregar", - "save": "Guardar" + "save": "Guardar", + + "@_comment_timeout_messages": "Mensajes de notificación de timeout", + "orderTimeoutTaker": "No respondiste a tiempo. La orden será republicada", + "orderTimeoutMaker": "Tu contraparte no respondió a tiempo. La orden será republicada" } \ No newline at end of file diff --git a/lib/l10n/intl_it.arb b/lib/l10n/intl_it.arb index efcbfaeb..cbf4e412 100644 --- a/lib/l10n/intl_it.arb +++ b/lib/l10n/intl_it.arb @@ -694,5 +694,9 @@ "editRelay": "Modifica Relay", "relayUrl": "URL del Relay", "add": "Aggiungi", - "save": "Salva" + "save": "Salva", + + "@_comment_timeout_messages": "Messaggi di notifica timeout", + "orderTimeoutTaker": "Non hai risposto in tempo. L'ordine sarà ripubblicato", + "orderTimeoutMaker": "La tua controparte non ha risposto in tempo. L'ordine sarà ripubblicato" } diff --git a/lib/shared/notifiers/notification_notifier.dart b/lib/shared/notifiers/notification_notifier.dart index 19bf2a99..56cc71ae 100644 --- a/lib/shared/notifiers/notification_notifier.dart +++ b/lib/shared/notifiers/notification_notifier.dart @@ -8,13 +8,15 @@ class NotificationState { final WidgetBuilder? widgetBuilder; final bool informational; final bool actionRequired; + final String? customMessage; NotificationState( {this.action, this.placeholders = const {}, this.widgetBuilder, this.informational = false, - this.actionRequired = false}); + this.actionRequired = false, + this.customMessage}); } class NotificationNotifier extends StateNotifier { @@ -26,6 +28,11 @@ class NotificationNotifier extends StateNotifier { action: action, placeholders: values, informational: true); } + void showCustomMessage(String message) { + state = NotificationState( + customMessage: message, informational: true); + } + void showActionable(actions.Action action, {Map values = const {}}) { state = NotificationState( diff --git a/lib/shared/widgets/notification_listener_widget.dart b/lib/shared/widgets/notification_listener_widget.dart index a2151690..c6a8d0d1 100644 --- a/lib/shared/widgets/notification_listener_widget.dart +++ b/lib/shared/widgets/notification_listener_widget.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import 'package:mostro_mobile/data/models/enums/action.dart' as actions; import 'package:mostro_mobile/generated/l10n.dart'; import 'package:mostro_mobile/shared/notifiers/notification_notifier.dart'; import 'package:mostro_mobile/shared/providers/notification_notifier_provider.dart'; @@ -14,9 +15,34 @@ class NotificationListenerWidget extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { ref.listen(notificationProvider, (previous, next) { if (next.informational) { + String message; + + if (next.customMessage != null) { + // Handle custom messages with localization + switch (next.customMessage) { + case 'orderTimeoutTaker': + message = S.of(context)!.orderTimeoutTaker; + break; + case 'orderTimeoutMaker': + message = S.of(context)!.orderTimeoutMaker; + break; + default: + message = next.customMessage!; + } + } else { + // Handle specific actions with proper localization + if (next.action == actions.Action.timeoutReversal) { + message = S.of(context)!.orderTimeoutMaker; // Use the appropriate timeout message + } else { + message = next.action?.toString() ?? S.of(context)!.error; + } + } + ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text(next.action?.toString() ?? S.of(context)!.error)), + content: Text(message), + duration: const Duration(seconds: 2), // Show for 2 seconds + ), ); // Clear notification after showing to prevent repetition ref.read(notificationProvider.notifier).clearNotification();