From 2c0986d5741bbd82c1fb8f508d5b48c9cb38c039 Mon Sep 17 00:00:00 2001 From: "MostronatorCoder[bot]" Date: Fri, 27 Mar 2026 20:56:56 +0000 Subject: [PATCH 01/17] feat: handle deep links from different Mostro instances Implements #541 When a deep link contains mostro= targeting a different Mostro instance, the app shows a confirmation dialog before switching. ## Changes - parseMostroUrl: extract optional 'mostro' query parameter - OrderInfo: add mostroPubkey field - DeepLinkHandler: compare link pubkey with current settings, show switch dialog if different, call updateMostroInstance on confirm - l10n: add EN/ES strings for the switch dialog - Tests: URL parsing with/without mostro param - Docs: DEEP_LINK_MOSTRO_SWITCH.md Backward compatible: links without mostro= param work as before. Closes #541 --- docs/DEEP_LINK_MOSTRO_SWITCH.md | 42 +++++++ lib/core/deep_link_handler.dart | 109 +++++++++++++++--- lib/l10n/intl_en.arb | 9 +- lib/l10n/intl_es.arb | 9 +- lib/services/deep_link_service.dart | 48 ++++---- lib/shared/utils/nostr_utils.dart | 14 ++- test/shared/utils/deep_link_parsing_test.dart | 89 ++++++++++++++ 7 files changed, 277 insertions(+), 43 deletions(-) create mode 100644 docs/DEEP_LINK_MOSTRO_SWITCH.md create mode 100644 test/shared/utils/deep_link_parsing_test.dart diff --git a/docs/DEEP_LINK_MOSTRO_SWITCH.md b/docs/DEEP_LINK_MOSTRO_SWITCH.md new file mode 100644 index 000000000..9f5082e54 --- /dev/null +++ b/docs/DEEP_LINK_MOSTRO_SWITCH.md @@ -0,0 +1,42 @@ +# Deep Link Mostro Instance Switch + +## Overview + +When a deep link contains a `mostro=` parameter identifying a different +Mostro instance than the currently connected one, the app shows a confirmation +dialog before switching. + +## Deep Link Format + +```text +mostro:?relays=,&mostro= +``` + +The `mostro` parameter is optional for backward compatibility. When absent, the +app assumes the order belongs to the currently selected Mostro instance. + +## Flow + +1. App receives `mostro:` deep link +2. `parseMostroUrl` extracts `orderId`, `relays`, and optional `mostroPubkey` +3. `DeepLinkHandler` compares `mostroPubkey` with `settings.mostroPublicKey` +4. If same (or absent) → navigate directly to order (existing behavior) +5. If different → show confirmation dialog +6. If user confirms → call `updateMostroInstance(newPubkey)` then navigate +7. If user cancels → do nothing + +## Files Changed + +| File | Change | +|------|--------| +| `lib/shared/utils/nostr_utils.dart` | Extract `mostro` query param in `parseMostroUrl` | +| `lib/services/deep_link_service.dart` | Add `mostroPubkey` field to `OrderInfo` | +| `lib/core/deep_link_handler.dart` | Pubkey comparison + switch dialog | +| `lib/l10n/intl_en.arb` | English strings for dialog | +| `lib/l10n/intl_es.arb` | Spanish strings for dialog | +| `test/shared/utils/deep_link_parsing_test.dart` | Unit tests | + +## References + +- [Issue #541](https://github.com/MostroP2P/mobile/issues/541) +- [Order Event Spec](https://mostro.network/protocol/order_event.html) diff --git a/lib/core/deep_link_handler.dart b/lib/core/deep_link_handler.dart index 67197d284..0675762c6 100644 --- a/lib/core/deep_link_handler.dart +++ b/lib/core/deep_link_handler.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:mostro_mobile/generated/l10n.dart'; +import 'package:mostro_mobile/features/settings/settings_provider.dart'; import 'package:mostro_mobile/services/deep_link_service.dart'; import 'package:mostro_mobile/services/logger_service.dart'; import 'package:mostro_mobile/shared/providers/nostr_service_provider.dart'; @@ -37,10 +38,7 @@ class DeepLinkHandler { } /// Handles incoming deep links - Future _handleDeepLink( - Uri uri, - GoRouter router, - ) async { + Future _handleDeepLink(Uri uri, GoRouter router) async { try { logger.i('Handling deep link: $uri'); @@ -64,10 +62,7 @@ class DeepLinkHandler { } /// Handles mostro: scheme deep links - Future _handleMostroDeepLink( - String url, - GoRouter router, - ) async { + Future _handleMostroDeepLink(String url, GoRouter router) async { BuildContext? context; try { // Show loading indicator @@ -81,14 +76,19 @@ class DeepLinkHandler { final deepLinkService = _ref.read(deepLinkServiceProvider); // Ensure we have a valid context for processing - final processingContext = context ?? router.routerDelegate.navigatorKey.currentContext; + final processingContext = + context ?? router.routerDelegate.navigatorKey.currentContext; if (processingContext == null || !processingContext.mounted) { logger.e('No valid context available for deep link processing'); return; } // Process the mostro link - final result = await deepLinkService.processMostroLink(url, nostrService, processingContext); + final result = await deepLinkService.processMostroLink( + url, + nostrService, + processingContext, + ); // Get fresh context after async operation final currentContext = router.routerDelegate.navigatorKey.currentContext; @@ -99,15 +99,45 @@ class DeepLinkHandler { } if (result.isSuccess && result.orderInfo != null) { + final orderInfo = result.orderInfo!; + final currentContext = + router.routerDelegate.navigatorKey.currentContext; + + // Check if the deep link targets a different Mostro instance + if (orderInfo.mostroPubkey != null && + currentContext != null && + currentContext.mounted) { + final currentPubkey = _ref.read(settingsProvider).mostroPublicKey; + if (orderInfo.mostroPubkey != currentPubkey) { + final shouldSwitch = await _showMostroSwitchDialog( + currentContext, + orderInfo.mostroPubkey!, + currentPubkey, + ); + if (shouldSwitch != true) { + logger.i('User declined Mostro switch for deep link'); + return; + } + // Switch Mostro instance + await _ref + .read(settingsProvider.notifier) + .updateMostroInstance(orderInfo.mostroPubkey!); + logger.i('Switched Mostro instance to: ${orderInfo.mostroPubkey}'); + } + } + // Navigate to the appropriate screen with proper timing WidgetsBinding.instance.addPostFrameCallback((_) { - deepLinkService.navigateToOrder(router, result.orderInfo!); + deepLinkService.navigateToOrder(router, orderInfo); }); - logger.i('Successfully navigated to order: ${result.orderInfo!.orderId} (${result.orderInfo!.orderType.value})'); + logger.i( + 'Successfully navigated to order: ${orderInfo.orderId} (${orderInfo.orderType.value})', + ); } else { final errorContext = router.routerDelegate.navigatorKey.currentContext; if (errorContext != null && errorContext.mounted) { - final errorMessage = result.error ?? S.of(errorContext)!.failedToLoadOrder; + final errorMessage = + result.error ?? S.of(errorContext)!.failedToLoadOrder; _showErrorSnackBar(errorContext, errorMessage); } logger.w('Failed to process mostro link: ${result.error}'); @@ -122,6 +152,59 @@ class DeepLinkHandler { } } + /// Shows a confirmation dialog when a deep link targets a different Mostro instance. + Future _showMostroSwitchDialog( + BuildContext context, + String linkPubkey, + String currentPubkey, + ) { + final s = S.of(context)!; + final truncatedLink = + '${linkPubkey.substring(0, 8)}...${linkPubkey.substring(linkPubkey.length - 8)}'; + final truncatedCurrent = + '${currentPubkey.substring(0, 8)}...${currentPubkey.substring(currentPubkey.length - 8)}'; + + return showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Row( + children: [ + const Icon(Icons.warning_amber_rounded, color: Colors.orange), + const SizedBox(width: 8), + Expanded(child: Text(s.deepLinkDifferentMostroTitle)), + ], + ), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(s.deepLinkDifferentMostroBody), + const SizedBox(height: 12), + Text( + '${s.deepLinkDifferentMostroFrom}\n$truncatedLink', + style: const TextStyle(fontFamily: 'monospace', fontSize: 12), + ), + const SizedBox(height: 8), + Text( + '${s.deepLinkDifferentMostroCurrent}\n$truncatedCurrent', + style: const TextStyle(fontFamily: 'monospace', fontSize: 12), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: Text(s.cancel), + ), + ElevatedButton( + onPressed: () => Navigator.of(dialogContext).pop(true), + child: Text(s.deepLinkSwitchAndView), + ), + ], + ), + ); + } + /// Shows a loading dialog void _showLoadingDialog(BuildContext context) { showDialog( diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index e734309a9..d9668ebb6 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -1504,5 +1504,10 @@ }, "cameraPermissionDenied": "Camera permission is required to scan QR codes", "toggleTorch": "Toggle flashlight", - "switchCamera": "Switch camera" -} + "switchCamera": "Switch camera", + "deepLinkDifferentMostroTitle": "Different Mostro Instance", + "deepLinkDifferentMostroBody": "This order was created on a different Mostro node. Do you want to switch to this Mostro and view the order?", + "deepLinkDifferentMostroFrom": "Order from:", + "deepLinkDifferentMostroCurrent": "Currently connected to:", + "deepLinkSwitchAndView": "Switch & View" +} \ No newline at end of file diff --git a/lib/l10n/intl_es.arb b/lib/l10n/intl_es.arb index 54e11537c..93479ae10 100644 --- a/lib/l10n/intl_es.arb +++ b/lib/l10n/intl_es.arb @@ -1479,5 +1479,10 @@ }, "cameraPermissionDenied": "Se requiere permiso de cámara para escanear códigos QR", "toggleTorch": "Activar/desactivar linterna", - "switchCamera": "Cambiar cámara" -} + "switchCamera": "Cambiar cámara", + "deepLinkDifferentMostroTitle": "Instancia de Mostro diferente", + "deepLinkDifferentMostroBody": "Esta orden fue creada en un nodo Mostro diferente. ¿Deseas cambiar a este Mostro y ver la orden?", + "deepLinkDifferentMostroFrom": "Orden de:", + "deepLinkDifferentMostroCurrent": "Actualmente conectado a:", + "deepLinkSwitchAndView": "Cambiar y ver" +} \ No newline at end of file diff --git a/lib/services/deep_link_service.dart b/lib/services/deep_link_service.dart index 5d309503b..d55ca535a 100644 --- a/lib/services/deep_link_service.dart +++ b/lib/services/deep_link_service.dart @@ -15,14 +15,17 @@ class OrderInfo { final String orderId; final OrderType orderType; + /// Mostro instance pubkey from the deep link, if present. + final String? mostroPubkey; + const OrderInfo({ required this.orderId, required this.orderType, + this.mostroPubkey, }); } class DeepLinkService { - final AppLinks _appLinks = AppLinks(); // Stream controller for deep link events @@ -51,7 +54,7 @@ class DeepLinkService { // NOTE: We don't process the initial link here to avoid GoRouter conflicts // The initial link will be handled by the app initialization in app.dart - + _isInitialized = true; logger.i('DeepLinkService initialized successfully'); } catch (e) { @@ -87,6 +90,7 @@ class DeepLinkService { final orderId = orderInfo['orderId'] as String; final relays = orderInfo['relays'] as List; + final mostroPubkey = orderInfo['mostroPubkey'] as String?; // Validate order ID format (UUID-like string) if (orderId.isEmpty || orderId.length < 10) { @@ -105,6 +109,7 @@ class DeepLinkService { orderId, relays, nostrService, + mostroPubkey: mostroPubkey, ); if (fetchedOrderInfo == null) { @@ -126,14 +131,15 @@ class DeepLinkService { Future _fetchOrderInfoById( String orderId, List relays, - NostrService nostrService, - ) async { + NostrService nostrService, { + String? mostroPubkey, + }) async { try { // Create a filter to search for NIP-69 order events with the specific order ID final filter = NostrFilter( kinds: [38383], // NIP-69 order events additionalFilters: { - '#d': [orderId] + '#d': [orderId], }, // Order ID is stored in 'd' tag ); @@ -142,7 +148,10 @@ class DeepLinkService { // First try to fetch from specified relays if (relays.isNotEmpty) { // Use the specific relays from the deep link URL - final orderEvents = await nostrService.fetchEvents(filter, specificRelays: relays); + final orderEvents = await nostrService.fetchEvents( + filter, + specificRelays: relays, + ); events.addAll(orderEvents); } @@ -165,12 +174,14 @@ class DeepLinkService { if (kTag != null && kTag.length > 1) { final orderTypeValue = kTag[1]; - final orderType = - orderTypeValue == 'sell' ? OrderType.sell : OrderType.buy; + final orderType = orderTypeValue == 'sell' + ? OrderType.sell + : OrderType.buy; return OrderInfo( orderId: orderId, orderType: orderType, + mostroPubkey: mostroPubkey, ); } } @@ -197,8 +208,9 @@ class DeepLinkService { void navigateToOrder(GoRouter router, OrderInfo orderInfo) { final route = getNavigationRoute(orderInfo); logger.i( - 'Navigating to: $route (Order: ${orderInfo.orderId}, Type: ${orderInfo.orderType.value})'); - + 'Navigating to: $route (Order: ${orderInfo.orderId}, Type: ${orderInfo.orderType.value})', + ); + // Use post-frame callback to ensure navigation happens after the current frame // This prevents GoRouter assertion failures during app lifecycle transitions WidgetsBinding.instance.addPostFrameCallback((_) { @@ -235,24 +247,14 @@ class DeepLinkResult { final OrderInfo? orderInfo; final String? error; - const DeepLinkResult._({ - required this.isSuccess, - this.orderInfo, - this.error, - }); + const DeepLinkResult._({required this.isSuccess, this.orderInfo, this.error}); factory DeepLinkResult.success(OrderInfo orderInfo) { - return DeepLinkResult._( - isSuccess: true, - orderInfo: orderInfo, - ); + return DeepLinkResult._(isSuccess: true, orderInfo: orderInfo); } factory DeepLinkResult.error(String error) { - return DeepLinkResult._( - isSuccess: false, - error: error, - ); + return DeepLinkResult._(isSuccess: false, error: error); } } diff --git a/lib/shared/utils/nostr_utils.dart b/lib/shared/utils/nostr_utils.dart index 41d983f10..75f9afa27 100644 --- a/lib/shared/utils/nostr_utils.dart +++ b/lib/shared/utils/nostr_utils.dart @@ -153,8 +153,8 @@ class NostrUtils { } /// Parses a mostro: URL and returns order information - /// Format: mostro:order-id&relays=wss://relay1,wss://relay2 - /// Returns a map with 'orderId' and 'relays' keys + /// Format: mostro:order-id?relays=wss://relay1,wss://relay2&mostro= + /// Returns a map with 'orderId', 'relays', and optionally 'mostroPubkey' keys static Map? parseMostroUrl(String url) { if (!isValidMostroUrl(url)) return null; @@ -172,7 +172,15 @@ class NostrUtils { .where((relay) => relay.isNotEmpty) .toList(); - return {'orderId': orderId, 'relays': relays}; + final result = {'orderId': orderId, 'relays': relays}; + + // Extract optional Mostro instance pubkey + final mostroPubkey = uri.queryParameters['mostro']; + if (mostroPubkey != null && mostroPubkey.isNotEmpty) { + result['mostroPubkey'] = mostroPubkey; + } + + return result; } catch (e) { return null; } diff --git a/test/shared/utils/deep_link_parsing_test.dart b/test/shared/utils/deep_link_parsing_test.dart new file mode 100644 index 000000000..876541401 --- /dev/null +++ b/test/shared/utils/deep_link_parsing_test.dart @@ -0,0 +1,89 @@ +import 'package:test/test.dart'; +import 'package:mostro_mobile/shared/utils/nostr_utils.dart'; + +void main() { + group('NostrUtils.parseMostroUrl — mostro pubkey extraction', () { + test('parses URL without mostro param (backward compatible)', () { + const url = + 'mostro:e215c07e-b1f9-45b0-9640-0295067ee99a?relays=wss://relay.mostro.network'; + final result = NostrUtils.parseMostroUrl(url); + + expect(result, isNotNull); + expect(result!['orderId'], 'e215c07e-b1f9-45b0-9640-0295067ee99a'); + expect(result['relays'], ['wss://relay.mostro.network']); + expect(result['mostroPubkey'], isNull); + }); + + test('parses URL with mostro pubkey param', () { + const pubkey = + '82fa8cb978b43c79b2156585bac2c011176a21d2aead6d9f7c575c005be88390'; + final url = + 'mostro:e215c07e-b1f9-45b0-9640-0295067ee99a?relays=wss://relay.mostro.network&mostro=$pubkey'; + final result = NostrUtils.parseMostroUrl(url); + + expect(result, isNotNull); + expect(result!['orderId'], 'e215c07e-b1f9-45b0-9640-0295067ee99a'); + expect(result['relays'], ['wss://relay.mostro.network']); + expect(result['mostroPubkey'], pubkey); + }); + + test('parses URL with multiple relays and mostro pubkey', () { + const pubkey = + 'abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890ab'; + final url = + 'mostro:order-id-123?relays=wss://relay1.example.com,wss://relay2.example.com&mostro=$pubkey'; + final result = NostrUtils.parseMostroUrl(url); + + expect(result, isNotNull); + expect(result!['relays'], hasLength(2)); + expect(result['mostroPubkey'], pubkey); + }); + + test('ignores empty mostro param', () { + const url = + 'mostro:e215c07e-b1f9-45b0-9640-0295067ee99a?relays=wss://relay.mostro.network&mostro='; + final result = NostrUtils.parseMostroUrl(url); + + expect(result, isNotNull); + expect(result!['mostroPubkey'], isNull); + }); + + test('isValidMostroUrl accepts URL with mostro param', () { + const url = + 'mostro:e215c07e?relays=wss://relay.mostro.network&mostro=abc123'; + expect(NostrUtils.isValidMostroUrl(url), isTrue); + }); + + test('isValidMostroUrl still rejects URL without relays', () { + const url = 'mostro:e215c07e?mostro=abc123'; + expect(NostrUtils.isValidMostroUrl(url), isFalse); + }); + }); + + group('DeepLinkService.OrderInfo — mostroPubkey field', () { + // Import is indirect since OrderInfo is in deep_link_service.dart + // which depends on Flutter. We test the parsing logic here instead. + + test('same pubkey comparison works', () { + const current = + '82fa8cb978b43c79b2156585bac2c011176a21d2aead6d9f7c575c005be88390'; + const link = + '82fa8cb978b43c79b2156585bac2c011176a21d2aead6d9f7c575c005be88390'; + expect(current == link, isTrue); + }); + + test('different pubkey comparison works', () { + const current = + '82fa8cb978b43c79b2156585bac2c011176a21d2aead6d9f7c575c005be88390'; + const link = + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + expect(current == link, isFalse); + }); + + test('null pubkey means same instance (backward compatible)', () { + const String? linkPubkey = null; + // When mostroPubkey is null, app should treat it as same instance + expect(linkPubkey == null, isTrue); + }); + }); +} From 392327f550c6b04a4c96fd5a663bb08623bca0ce Mon Sep 17 00:00:00 2001 From: "MostronatorCoder[bot]" Date: Fri, 27 Mar 2026 21:02:04 +0000 Subject: [PATCH 02/17] fix: escape angle brackets in doc comment (unintended_html_in_doc_comment) --- lib/shared/utils/nostr_utils.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/shared/utils/nostr_utils.dart b/lib/shared/utils/nostr_utils.dart index 75f9afa27..cc19f6794 100644 --- a/lib/shared/utils/nostr_utils.dart +++ b/lib/shared/utils/nostr_utils.dart @@ -153,7 +153,7 @@ class NostrUtils { } /// Parses a mostro: URL and returns order information - /// Format: mostro:order-id?relays=wss://relay1,wss://relay2&mostro= + /// Format: `mostro:order-id?relays=wss://relay1,wss://relay2&mostro=pubkey` /// Returns a map with 'orderId', 'relays', and optionally 'mostroPubkey' keys static Map? parseMostroUrl(String url) { if (!isValidMostroUrl(url)) return null; From 50007cec632facb678bbf02b070e3eabdaa0dcfa Mon Sep 17 00:00:00 2001 From: "MostronatorCoder[bot]" Date: Sat, 28 Mar 2026 17:37:40 +0000 Subject: [PATCH 03/17] =?UTF-8?q?fix:=20address=20CodeRabbit=20review=20?= =?UTF-8?q?=E2=80=94=20pubkey=20validation,=20strict=20k-tag,=20l10n,=20di?= =?UTF-8?q?alog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Validate mostro pubkey: must be 64-char hex, normalized to lowercase (rejects malformed/short/non-hex values silently) - Strict k-tag parsing: unknown order types return null instead of defaulting to OrderType.buy - Add l10n strings for IT, DE, FR (deep link switch dialog) - Dialog: accept optional targetName/currentName for node labels, fall back to truncated pubkeys. Uses post-frame callback to avoid lifecycle/build races. - Tests: pubkey validation (short, non-hex, uppercase normalization) --- lib/core/deep_link_handler.dart | 95 +++++++++++-------- lib/l10n/intl_de.arb | 23 +++-- lib/l10n/intl_fr.arb | 9 +- lib/l10n/intl_it.arb | 9 +- lib/services/deep_link_service.dart | 12 ++- lib/shared/utils/nostr_utils.dart | 15 ++- test/shared/utils/deep_link_parsing_test.dart | 30 ++++++ 7 files changed, 136 insertions(+), 57 deletions(-) diff --git a/lib/core/deep_link_handler.dart b/lib/core/deep_link_handler.dart index 0675762c6..a08c7b709 100644 --- a/lib/core/deep_link_handler.dart +++ b/lib/core/deep_link_handler.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'package:flutter/scheduler.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; @@ -153,56 +154,76 @@ class DeepLinkHandler { } /// Shows a confirmation dialog when a deep link targets a different Mostro instance. + /// + /// [targetName] and [currentName] are optional human-readable labels for the + /// Mostro instances. When empty, truncated pubkeys are shown instead. Future _showMostroSwitchDialog( BuildContext context, String linkPubkey, - String currentPubkey, - ) { + String currentPubkey, { + String targetName = '', + String currentName = '', + }) { + final completer = Completer(); final s = S.of(context)!; final truncatedLink = '${linkPubkey.substring(0, 8)}...${linkPubkey.substring(linkPubkey.length - 8)}'; final truncatedCurrent = '${currentPubkey.substring(0, 8)}...${currentPubkey.substring(currentPubkey.length - 8)}'; - return showDialog( - context: context, - builder: (dialogContext) => AlertDialog( - title: Row( - children: [ - const Icon(Icons.warning_amber_rounded, color: Colors.orange), - const SizedBox(width: 8), - Expanded(child: Text(s.deepLinkDifferentMostroTitle)), - ], - ), - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(s.deepLinkDifferentMostroBody), - const SizedBox(height: 12), - Text( - '${s.deepLinkDifferentMostroFrom}\n$truncatedLink', - style: const TextStyle(fontFamily: 'monospace', fontSize: 12), + final targetLabel = targetName.isNotEmpty ? targetName : truncatedLink; + final currentLabel = currentName.isNotEmpty + ? currentName + : truncatedCurrent; + + WidgetsBinding.instance.addPostFrameCallback((_) async { + if (!context.mounted) { + completer.complete(null); + return; + } + final result = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Row( + children: [ + const Icon(Icons.warning_amber_rounded, color: Colors.orange), + const SizedBox(width: 8), + Expanded(child: Text(s.deepLinkDifferentMostroTitle)), + ], + ), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(s.deepLinkDifferentMostroBody), + const SizedBox(height: 12), + Text( + '${s.deepLinkDifferentMostroFrom}\n$targetLabel', + style: const TextStyle(fontFamily: 'monospace', fontSize: 12), + ), + const SizedBox(height: 8), + Text( + '${s.deepLinkDifferentMostroCurrent}\n$currentLabel', + style: const TextStyle(fontFamily: 'monospace', fontSize: 12), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: Text(s.cancel), ), - const SizedBox(height: 8), - Text( - '${s.deepLinkDifferentMostroCurrent}\n$truncatedCurrent', - style: const TextStyle(fontFamily: 'monospace', fontSize: 12), + ElevatedButton( + onPressed: () => Navigator.of(dialogContext).pop(true), + child: Text(s.deepLinkSwitchAndView), ), ], ), - actions: [ - TextButton( - onPressed: () => Navigator.of(dialogContext).pop(false), - child: Text(s.cancel), - ), - ElevatedButton( - onPressed: () => Navigator.of(dialogContext).pop(true), - child: Text(s.deepLinkSwitchAndView), - ), - ], - ), - ); + ); + completer.complete(result); + }); + + return completer.future; } /// Shows a loading dialog diff --git a/lib/l10n/intl_de.arb b/lib/l10n/intl_de.arb index bcace0a2d..2c126c9a7 100644 --- a/lib/l10n/intl_de.arb +++ b/lib/l10n/intl_de.arb @@ -101,7 +101,7 @@ } } }, - "buyerInvoiceAccepted": "Die Rechnung wurde erfolgreich gespeichert.", + "buyerInvoiceAccepted": "Die Rechnung wurde erfolgreich gespeichert.", "holdInvoicePaymentAccepted": "Kontaktiere den Verkäufer {seller_name}, um zu vereinbaren, wie du {fiat_code} {fiat_amount} mit {payment_method} sendest. Sobald du das Fiat-Geld gesendet hast, benachrichtige mich durch Drücken der Schaltfläche 'Fiat gesendet'.", "buyerTookOrder": "Kontaktiere den Käufer {buyer_name}, um ihm mitzuteilen, wie er {fiat_code} {fiat_amount} über {payment_method} senden soll. Du wirst benachrichtigt, wenn der Käufer die Fiat-Zahlung bestätigt. Überprüfe danach, ob das Geld angekommen ist. Wenn der Käufer nicht antwortet, kannst du eine Stornierung oder einen Streitfall (Dispute) einleiten. Denke daran: Ein Administrator wird dich NIEMALS von sich aus kontaktieren, um deine Order zu klären, es sei denn, du eröffnest zuerst einen Streitfall.", "fiatSentOkBuyer": "Ich habe {seller_name} darüber informiert, dass du das Fiat-Geld gesendet hast. Wenn der Verkäufer den Erhalt bestätigt, wird er die Beträge freigeben. Wenn er sich weigert, kannst du einen Streitfall eröffnen.", @@ -198,7 +198,7 @@ "buyBtc": "BTC KAUFEN", "sellBtc": "BTC VERKAUFEN", "filter": "FILTER", - "statusFilter": "Status", + "statusFilter": "Status", "allStatuses": "Alle", "all": "Alle", "statusPending": "Ausstehend", @@ -298,7 +298,7 @@ "cancelPendingButton": "AUSSTEHENDE STORNIEREN", "acceptCancelButton": "ABBRECHEN", "disputeButton": "STREITFALL", - "fiatSentButton": "FIAT GESENDET", + "fiatSentButton": "FIAT GESENDET", "completePurchaseButton": "KAUF ABSCHLIESSEN", "paymentMethodLabel": "Zahlungsmethode", "createdOnLabel": "Erstellt am", @@ -498,7 +498,7 @@ "generateNewUser": "Neuen Nutzer generieren", "importMostroUser": "Nutzer importieren", "refreshUser": "Nutzer aktualisieren", - "keyImportedSuccessfully": "Schlüssel erfolgreich importiert", + "keyImportedSuccessfully": "Schlüssel erfolgreich importiert", "importFailed": "Import fehlgeschlagen: {error}", "@importFailed": { "placeholders": { @@ -698,7 +698,7 @@ } }, "failedToGenerateQR": "QR-Code konnte nicht generiert werden", - "invoiceCopiedToClipboard": "Rechnung in die Zwischenablage kopiert", + "invoiceCopiedToClipboard": "Rechnung in die Zwischenablage kopiert", "copiedToClipboard": "In die Zwischenablage kopiert", "copy": "Kopieren", "share": "Teilen", @@ -899,7 +899,7 @@ "usersDocumentationSpanish": "Benutzerdokumentation (Spanisch)", "technicalDocumentation": "Technische Dokumentation", "read": "Lesen", - "technicalDetails": "Technische Details", + "technicalDetails": "Technische Details", "mostroDaemonVersion": "Mostro-Version", "mostroCommitId": "Mostro Commit-ID", "orderExpiration": "Order-Ablaufzeit", @@ -1099,7 +1099,7 @@ "discount": "Rabatt", "premium": "Aufschlag", "clear": "Löschen", - "min": "Min", + "min": "Min", "max": "Max", "@_comment_timeout_messages": "Nachrichten für Zeitüberschreitungen", "orderTimeoutTaker": "Du hast nicht rechtzeitig geantwortet. Die Order wird neu veröffentlicht.", @@ -1299,7 +1299,7 @@ "@_comment_logging_ui": "Logging-UI Texte", "logCapture": "Log-Erfassung", "capturingLogs": "Logs werden erfasst", - "captureDisabled": "Erfassung deaktiviert", + "captureDisabled": "Erfassung deaktiviert", "performanceWarning": "Leistungswarnung", "performanceWarningMessage": "Das Aktivieren der Log-Erfassung kann die Anwendungsleistung beeinträchtigen. Aktiviere diese Funktion nur zum Debuggen oder zur Fehlersuche.", "enable": "Aktivieren", @@ -1504,5 +1504,10 @@ }, "cameraPermissionDenied": "Kamerazugriff wird benötigt, um QR-Codes zu scannen", "toggleTorch": "Taschenlampe umschalten", - "switchCamera": "Kamera wechseln" + "switchCamera": "Kamera wechseln", + "deepLinkDifferentMostroTitle": "Andere Mostro-Instanz", + "deepLinkDifferentMostroBody": "Diese Bestellung wurde auf einem anderen Mostro-Knoten erstellt. Möchten Sie zu diesem Mostro wechseln und die Bestellung ansehen?", + "deepLinkDifferentMostroFrom": "Bestellung von:", + "deepLinkDifferentMostroCurrent": "Derzeit verbunden mit:", + "deepLinkSwitchAndView": "Wechseln und ansehen" } \ No newline at end of file diff --git a/lib/l10n/intl_fr.arb b/lib/l10n/intl_fr.arb index 9b90b63e3..b498eaeb1 100644 --- a/lib/l10n/intl_fr.arb +++ b/lib/l10n/intl_fr.arb @@ -1504,5 +1504,10 @@ }, "cameraPermissionDenied": "L'autorisation de la caméra est requise pour scanner les codes QR", "toggleTorch": "Activer/désactiver la lampe", - "switchCamera": "Changer de caméra" -} + "switchCamera": "Changer de caméra", + "deepLinkDifferentMostroTitle": "Instance Mostro différente", + "deepLinkDifferentMostroBody": "Cette commande a été créée sur un nœud Mostro différent. Voulez-vous basculer vers ce Mostro et voir la commande ?", + "deepLinkDifferentMostroFrom": "Commande de :", + "deepLinkDifferentMostroCurrent": "Actuellement connecté à :", + "deepLinkSwitchAndView": "Basculer et voir" +} \ No newline at end of file diff --git a/lib/l10n/intl_it.arb b/lib/l10n/intl_it.arb index fa44782b8..ff66e801f 100644 --- a/lib/l10n/intl_it.arb +++ b/lib/l10n/intl_it.arb @@ -1538,5 +1538,10 @@ }, "cameraPermissionDenied": "È necessaria l'autorizzazione della fotocamera per scansionare i codici QR", "toggleTorch": "Attiva/disattiva torcia", - "switchCamera": "Cambia fotocamera" -} + "switchCamera": "Cambia fotocamera", + "deepLinkDifferentMostroTitle": "Istanza Mostro diversa", + "deepLinkDifferentMostroBody": "Questo ordine è stato creato su un nodo Mostro diverso. Vuoi passare a questo Mostro e visualizzare l'ordine?", + "deepLinkDifferentMostroFrom": "Ordine da:", + "deepLinkDifferentMostroCurrent": "Attualmente connesso a:", + "deepLinkSwitchAndView": "Cambia e visualizza" +} \ No newline at end of file diff --git a/lib/services/deep_link_service.dart b/lib/services/deep_link_service.dart index d55ca535a..45f7e8691 100644 --- a/lib/services/deep_link_service.dart +++ b/lib/services/deep_link_service.dart @@ -174,9 +174,15 @@ class DeepLinkService { if (kTag != null && kTag.length > 1) { final orderTypeValue = kTag[1]; - final orderType = orderTypeValue == 'sell' - ? OrderType.sell - : OrderType.buy; + final OrderType? orderType; + if (orderTypeValue == 'sell') { + orderType = OrderType.sell; + } else if (orderTypeValue == 'buy') { + orderType = OrderType.buy; + } else { + logger.w('Unknown order type in k tag: $orderTypeValue'); + return null; + } return OrderInfo( orderId: orderId, diff --git a/lib/shared/utils/nostr_utils.dart b/lib/shared/utils/nostr_utils.dart index cc19f6794..ca9c0f5e2 100644 --- a/lib/shared/utils/nostr_utils.dart +++ b/lib/shared/utils/nostr_utils.dart @@ -174,10 +174,17 @@ class NostrUtils { final result = {'orderId': orderId, 'relays': relays}; - // Extract optional Mostro instance pubkey - final mostroPubkey = uri.queryParameters['mostro']; - if (mostroPubkey != null && mostroPubkey.isNotEmpty) { - result['mostroPubkey'] = mostroPubkey; + // Extract and validate optional Mostro instance pubkey (must be 64-char hex) + final rawMostroPubkey = uri.queryParameters['mostro']; + if (rawMostroPubkey != null && rawMostroPubkey.isNotEmpty) { + final normalized = rawMostroPubkey.trim().toLowerCase().replaceFirst( + '0x', + '', + ); + if (normalized.length == 64 && + RegExp(r'^[0-9a-f]{64}$').hasMatch(normalized)) { + result['mostroPubkey'] = normalized; + } } return result; diff --git a/test/shared/utils/deep_link_parsing_test.dart b/test/shared/utils/deep_link_parsing_test.dart index 876541401..653ed8eae 100644 --- a/test/shared/utils/deep_link_parsing_test.dart +++ b/test/shared/utils/deep_link_parsing_test.dart @@ -48,6 +48,36 @@ void main() { expect(result!['mostroPubkey'], isNull); }); + test('rejects malformed pubkey (too short)', () { + const url = + 'mostro:e215c07e-b1f9-45b0-9640-0295067ee99a?relays=wss://relay.mostro.network&mostro=abc123'; + final result = NostrUtils.parseMostroUrl(url); + + expect(result, isNotNull); + expect(result!['mostroPubkey'], isNull); + }); + + test('rejects pubkey with non-hex characters', () { + const url = + 'mostro:e215c07e-b1f9-45b0-9640-0295067ee99a?relays=wss://relay.mostro.network&mostro=zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz'; + final result = NostrUtils.parseMostroUrl(url); + + expect(result, isNotNull); + expect(result!['mostroPubkey'], isNull); + }); + + test('normalizes uppercase pubkey to lowercase', () { + const url = + 'mostro:e215c07e-b1f9-45b0-9640-0295067ee99a?relays=wss://relay.mostro.network&mostro=82FA8CB978B43C79B2156585BAC2C011176A21D2AEAD6D9F7C575C005BE88390'; + final result = NostrUtils.parseMostroUrl(url); + + expect(result, isNotNull); + expect( + result!['mostroPubkey'], + '82fa8cb978b43c79b2156585bac2c011176a21d2aead6d9f7c575c005be88390', + ); + }); + test('isValidMostroUrl accepts URL with mostro param', () { const url = 'mostro:e215c07e?relays=wss://relay.mostro.network&mostro=abc123'; From 1902b9726fa758bd092815cff4a030f53bc0082b Mon Sep 17 00:00:00 2001 From: "MostronatorCoder[bot]" Date: Sat, 28 Mar 2026 17:38:37 +0000 Subject: [PATCH 04/17] fix: replace trivial equality tests with real parser-based comparisons Tests now exercise NostrUtils.parseMostroUrl for instance comparison: - Same pubkey matches current instance - Different pubkey does not match - Mixed-case normalized and matches lowercase - Absent mostro param treated as same instance - Malformed pubkey silently dropped (treated as same instance) --- test/shared/utils/deep_link_parsing_test.dart | 62 +++++++++++++------ 1 file changed, 44 insertions(+), 18 deletions(-) diff --git a/test/shared/utils/deep_link_parsing_test.dart b/test/shared/utils/deep_link_parsing_test.dart index 653ed8eae..f97897ece 100644 --- a/test/shared/utils/deep_link_parsing_test.dart +++ b/test/shared/utils/deep_link_parsing_test.dart @@ -90,30 +90,56 @@ void main() { }); }); - group('DeepLinkService.OrderInfo — mostroPubkey field', () { - // Import is indirect since OrderInfo is in deep_link_service.dart - // which depends on Flutter. We test the parsing logic here instead. + group('Mostro instance comparison via parseMostroUrl', () { + const currentPubkey = + '82fa8cb978b43c79b2156585bac2c011176a21d2aead6d9f7c575c005be88390'; - test('same pubkey comparison works', () { - const current = - '82fa8cb978b43c79b2156585bac2c011176a21d2aead6d9f7c575c005be88390'; - const link = - '82fa8cb978b43c79b2156585bac2c011176a21d2aead6d9f7c575c005be88390'; - expect(current == link, isTrue); + test('same pubkey in link matches current instance', () { + final url = + 'mostro:order-123?relays=wss://relay.mostro.network&mostro=$currentPubkey'; + final result = NostrUtils.parseMostroUrl(url); + + expect(result, isNotNull); + expect(result!['mostroPubkey'] == currentPubkey, isTrue); }); - test('different pubkey comparison works', () { - const current = - '82fa8cb978b43c79b2156585bac2c011176a21d2aead6d9f7c575c005be88390'; - const link = + test('different pubkey in link does not match current instance', () { + const otherPubkey = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; - expect(current == link, isFalse); + final url = + 'mostro:order-123?relays=wss://relay.mostro.network&mostro=$otherPubkey'; + final result = NostrUtils.parseMostroUrl(url); + + expect(result, isNotNull); + expect(result!['mostroPubkey'] == currentPubkey, isFalse); + expect(result['mostroPubkey'], otherPubkey); + }); + + test('mixed-case pubkey is normalized and matches lowercase', () { + final url = + 'mostro:order-123?relays=wss://relay.mostro.network&mostro=82FA8CB978B43C79B2156585BAC2C011176A21D2AEAD6D9F7C575C005BE88390'; + final result = NostrUtils.parseMostroUrl(url); + + expect(result, isNotNull); + expect(result!['mostroPubkey'] == currentPubkey, isTrue); + }); + + test('absent mostro param means same instance (backward compatible)', () { + const url = 'mostro:order-123?relays=wss://relay.mostro.network'; + final result = NostrUtils.parseMostroUrl(url); + + expect(result, isNotNull); + // null mostroPubkey → app treats as same instance (no switch dialog) + expect(result!['mostroPubkey'], isNull); }); - test('null pubkey means same instance (backward compatible)', () { - const String? linkPubkey = null; - // When mostroPubkey is null, app should treat it as same instance - expect(linkPubkey == null, isTrue); + test('malformed pubkey is silently dropped (treated as same instance)', () { + const url = + 'mostro:order-123?relays=wss://relay.mostro.network&mostro=not-a-valid-key'; + final result = NostrUtils.parseMostroUrl(url); + + expect(result, isNotNull); + expect(result!['mostroPubkey'], isNull); }); }); } From 38019e4156cfbc942f199779569c099c59486d04 Mon Sep 17 00:00:00 2001 From: "MostronatorCoder[bot]" Date: Sat, 28 Mar 2026 17:51:17 +0000 Subject: [PATCH 05/17] fix: remove unused scheduler.dart import --- lib/core/deep_link_handler.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/core/deep_link_handler.dart b/lib/core/deep_link_handler.dart index a08c7b709..09569bab0 100644 --- a/lib/core/deep_link_handler.dart +++ b/lib/core/deep_link_handler.dart @@ -1,5 +1,4 @@ import 'dart:async'; -import 'package:flutter/scheduler.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; From 014b69cc35ae4bf617b5d76c18d9718f0615af98 Mon Sep 17 00:00:00 2001 From: "MostronatorCoder[bot]" Date: Sat, 28 Mar 2026 18:12:20 +0000 Subject: [PATCH 06/17] fix: test pubkey was 66 chars instead of required 64 --- test/shared/utils/deep_link_parsing_test.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/shared/utils/deep_link_parsing_test.dart b/test/shared/utils/deep_link_parsing_test.dart index f97897ece..cc381545b 100644 --- a/test/shared/utils/deep_link_parsing_test.dart +++ b/test/shared/utils/deep_link_parsing_test.dart @@ -29,7 +29,7 @@ void main() { test('parses URL with multiple relays and mostro pubkey', () { const pubkey = - 'abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890ab'; + 'abcdef1234567890abcdef1234567890abcdef1234567890abcdef12345678ab'; final url = 'mostro:order-id-123?relays=wss://relay1.example.com,wss://relay2.example.com&mostro=$pubkey'; final result = NostrUtils.parseMostroUrl(url); From 327b7107308f99542ca1813375ac80bfde3bb2af Mon Sep 17 00:00:00 2001 From: Mostronator Date: Mon, 30 Mar 2026 21:01:22 +0000 Subject: [PATCH 07/17] fix: prevent deep-link duplicate handling and null crash in take order --- lib/core/deep_link_handler.dart | 89 ++- .../order/screens/take_order_screen.dart | 513 ++++++++++-------- 2 files changed, 343 insertions(+), 259 deletions(-) diff --git a/lib/core/deep_link_handler.dart b/lib/core/deep_link_handler.dart index 09569bab0..6f2b2011c 100644 --- a/lib/core/deep_link_handler.dart +++ b/lib/core/deep_link_handler.dart @@ -13,6 +13,13 @@ class DeepLinkHandler { final Ref _ref; StreamSubscription? _subscription; + bool _isHandlingMostroDeepLink = false; + String? _lastHandledDeepLinkUrl; + DateTime? _lastHandledDeepLinkAt; + + BuildContext? _loadingDialogContext; + bool _isLoadingDialogVisible = false; + DeepLinkHandler(this._ref); /// Initializes deep link handling for the app @@ -63,6 +70,21 @@ class DeepLinkHandler { /// Handles mostro: scheme deep links Future _handleMostroDeepLink(String url, GoRouter router) async { + final now = DateTime.now(); + final isDuplicateRecent = + _lastHandledDeepLinkUrl == url && + _lastHandledDeepLinkAt != null && + now.difference(_lastHandledDeepLinkAt!) < const Duration(seconds: 2); + + if (_isHandlingMostroDeepLink || isDuplicateRecent) { + logger.i('Ignoring duplicate/concurrent deep link handling for: $url'); + return; + } + + _isHandlingMostroDeepLink = true; + _lastHandledDeepLinkUrl = url; + _lastHandledDeepLinkAt = now; + BuildContext? context; try { // Show loading indicator @@ -90,13 +112,7 @@ class DeepLinkHandler { processingContext, ); - // Get fresh context after async operation - final currentContext = router.routerDelegate.navigatorKey.currentContext; - - // Hide loading indicator - if (currentContext != null && currentContext.mounted) { - Navigator.of(currentContext).pop(); - } + _hideLoadingDialog(); if (result.isSuccess && result.orderInfo != null) { final orderInfo = result.orderInfo!; @@ -144,11 +160,14 @@ class DeepLinkHandler { } } catch (e) { logger.e('Error processing mostro deep link: $e'); + _hideLoadingDialog(); + final errorContext = router.routerDelegate.navigatorKey.currentContext; if (errorContext != null && errorContext.mounted) { - Navigator.of(errorContext).pop(); // Hide loading if still showing _showErrorSnackBar(errorContext, S.of(errorContext)!.failedToOpenOrder); } + } finally { + _isHandlingMostroDeepLink = false; } } @@ -227,25 +246,50 @@ class DeepLinkHandler { /// Shows a loading dialog void _showLoadingDialog(BuildContext context) { + if (_isLoadingDialogVisible) { + return; + } + + _isLoadingDialogVisible = true; showDialog( context: context, barrierDismissible: false, - builder: (dialogContext) => Center( - child: Card( - child: Padding( - padding: const EdgeInsets.all(20.0), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const CircularProgressIndicator(), - const SizedBox(height: 16), - Text(S.of(dialogContext)!.loadingOrder), - ], + builder: (dialogContext) { + _loadingDialogContext = dialogContext; + return Center( + child: Card( + child: Padding( + padding: const EdgeInsets.all(20.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const CircularProgressIndicator(), + const SizedBox(height: 16), + Text(S.of(dialogContext)!.loadingOrder), + ], + ), ), ), - ), - ), - ); + ); + }, + ).whenComplete(() { + _isLoadingDialogVisible = false; + _loadingDialogContext = null; + }); + } + + void _hideLoadingDialog() { + if (!_isLoadingDialogVisible) { + return; + } + + final dialogContext = _loadingDialogContext; + if (dialogContext != null && dialogContext.mounted) { + Navigator.of(dialogContext).pop(); + } + + _isLoadingDialogVisible = false; + _loadingDialogContext = null; } /// Shows an error snack bar @@ -264,6 +308,7 @@ class DeepLinkHandler { void dispose() { _subscription?.cancel(); _subscription = null; + _hideLoadingDialog(); // DeepLinkService disposal is handled by Riverpod provider } } diff --git a/lib/features/order/screens/take_order_screen.dart b/lib/features/order/screens/take_order_screen.dart index 065bd6ce6..5c942ce26 100644 --- a/lib/features/order/screens/take_order_screen.dart +++ b/lib/features/order/screens/take_order_screen.dart @@ -12,7 +12,6 @@ import 'package:mostro_mobile/features/order/widgets/order_app_bar.dart'; import 'package:mostro_mobile/shared/providers.dart'; import 'package:mostro_mobile/shared/widgets/order_cards.dart'; - import 'package:mostro_mobile/shared/providers/exchange_service_provider.dart'; import 'package:mostro_mobile/shared/utils/currency_utils.dart'; @@ -43,74 +42,74 @@ class _TakeOrderScreenState extends ConsumerState { final order = ref.watch(eventProvider(widget.orderId)); // Listen for messages to reset loading state on CantDo - ref.listen( - mostroMessageStreamProvider(widget.orderId), - (_, next) { - next.whenData((msg) { - if (msg == null || msg.action == _lastSeenAction) return; - _lastSeenAction = msg.action; - - // Reset loading state only on CantDo message - if (msg.action == actions.Action.cantDo && _isSubmitting) { - setState(() { - _isSubmitting = false; - }); - } - }); - }, - ); + ref.listen(mostroMessageStreamProvider(widget.orderId), (_, next) { + next.whenData((msg) { + if (msg == null || msg.action == _lastSeenAction) return; + _lastSeenAction = msg.action; + + // Reset loading state only on CantDo message + if (msg.action == actions.Action.cantDo && _isSubmitting) { + setState(() { + _isSubmitting = false; + }); + } + }); + }); return Scaffold( backgroundColor: AppTheme.backgroundDark, appBar: OrderAppBar( - title: widget.orderType == OrderType.buy - ? S.of(context)!.buyOrderDetailsTitle - : S.of(context)!.sellOrderDetailsTitle), - body: SingleChildScrollView( - padding: EdgeInsets.fromLTRB( - 16.0, - 16.0, - 16.0, - 16.0 + MediaQuery.of(context).viewPadding.bottom, - ), - child: Column( - children: [ - const SizedBox(height: 16), - _buildSellerAmount(ref, order!), - const SizedBox(height: 16), - _buildPaymentMethod(context, order), - const SizedBox(height: 16), - _buildCreatedOn(order), - const SizedBox(height: 16), - _buildOrderId(context), - const SizedBox(height: 16), - _buildCreatorReputation(order), - const SizedBox(height: 24), - _CountdownWidget( - order: order, - ), - const SizedBox(height: 36), - _buildActionButtons(context, ref, order), - ], - ), + title: widget.orderType == OrderType.buy + ? S.of(context)!.buyOrderDetailsTitle + : S.of(context)!.sellOrderDetailsTitle, ), + body: order == null + ? const Center(child: CircularProgressIndicator()) + : SingleChildScrollView( + padding: EdgeInsets.fromLTRB( + 16.0, + 16.0, + 16.0, + 16.0 + MediaQuery.of(context).viewPadding.bottom, + ), + child: Column( + children: [ + const SizedBox(height: 16), + _buildSellerAmount(ref, order), + const SizedBox(height: 16), + _buildPaymentMethod(context, order), + const SizedBox(height: 16), + _buildCreatedOn(order), + const SizedBox(height: 16), + _buildOrderId(context), + const SizedBox(height: 16), + _buildCreatorReputation(order), + const SizedBox(height: 24), + _CountdownWidget(order: order), + const SizedBox(height: 36), + _buildActionButtons(context, ref, order), + ], + ), + ), ); } Widget _buildSellerAmount(WidgetRef ref, NostrEvent order) { return Builder( builder: (context) { - final currencyData = ref.watch(currencyCodesProvider).asData?.value; final currencyFlag = CurrencyUtils.getFlagFromCurrencyData( - order.currency!, currencyData); + order.currency!, + currencyData, + ); final amountString = '${order.fiatAmount} ${order.currency} $currencyFlag'; String priceText = ''; if (order.amount == '0') { final premium = order.premium; - final premiumValue = - premium != null ? double.tryParse(premium) ?? 0.0 : 0.0; + final premiumValue = premium != null + ? double.tryParse(premium) ?? 0.0 + : 0.0; if (premiumValue == 0) { // No premium - show only market price @@ -118,16 +117,15 @@ class _TakeOrderScreenState extends ConsumerState { } else { // Has premium/discount - show market price with percentage final isPremiumPositive = premiumValue >= 0; - final premiumDisplay = - isPremiumPositive ? '(+$premiumValue%)' : '($premiumValue%)'; + final premiumDisplay = isPremiumPositive + ? '(+$premiumValue%)' + : '($premiumValue%)'; priceText = '${S.of(context)!.atMarketPrice} $premiumDisplay'; } } - final hasFixedSatsAmount = order.amount != '0'; - return CustomCard( padding: const EdgeInsets.all(16), child: Column( @@ -136,11 +134,11 @@ class _TakeOrderScreenState extends ConsumerState { Text( hasFixedSatsAmount ? (widget.orderType == OrderType.sell - ? "${S.of(context)!.someoneIsSellingTitle.replaceAll(' Sats', '')} ${order.amount} Sats" - : "${S.of(context)!.someoneIsBuyingTitle.replaceAll(' Sats', '')} ${order.amount} Sats") + ? "${S.of(context)!.someoneIsSellingTitle.replaceAll(' Sats', '')} ${order.amount} Sats" + : "${S.of(context)!.someoneIsBuyingTitle.replaceAll(' Sats', '')} ${order.amount} Sats") : (widget.orderType == OrderType.sell - ? S.of(context)!.someoneIsSellingTitle - : S.of(context)!.someoneIsBuyingTitle), + ? S.of(context)!.someoneIsSellingTitle + : S.of(context)!.someoneIsBuyingTitle), style: const TextStyle( color: Colors.white, fontSize: 18, @@ -150,11 +148,15 @@ class _TakeOrderScreenState extends ConsumerState { const SizedBox(height: 8), Row( children: [ - Flexible( child: RichText( text: TextSpan( - text: S.of(context)!.forAmountWithCurrency(amountString, order.currency ?? ''), + text: S + .of(context)! + .forAmountWithCurrency( + amountString, + order.currency ?? '', + ), style: const TextStyle( color: Colors.white70, fontSize: 16, @@ -169,7 +171,6 @@ class _TakeOrderScreenState extends ConsumerState { ), ), ], - ), softWrap: true, maxLines: 2, @@ -186,9 +187,7 @@ class _TakeOrderScreenState extends ConsumerState { } Widget _buildOrderId(BuildContext context) { - return OrderIdCard( - orderId: widget.orderId, - ); + return OrderIdCard(orderId: widget.orderId); } Widget _buildPaymentMethod(BuildContext context, NostrEvent order) { @@ -196,9 +195,7 @@ class _TakeOrderScreenState extends ConsumerState { ? order.paymentMethods.join(', ') : S.of(context)!.noPaymentMethod; - return PaymentMethodCard( - paymentMethod: methods, - ); + return PaymentMethodCard(paymentMethod: methods); } Widget _buildCreatedOn(NostrEvent order) { @@ -218,20 +215,21 @@ class _TakeOrderScreenState extends ConsumerState { final reviews = ratingInfo?.totalReviews ?? 0; final days = ratingInfo?.days ?? 0; - return CreatorReputationCard( - rating: rating, - reviews: reviews, - days: days, - ); + return CreatorReputationCard(rating: rating, reviews: reviews, days: days); } Widget _buildActionButtons( - BuildContext context, WidgetRef ref, NostrEvent order) { - final orderDetailsNotifier = - ref.read(orderNotifierProvider(widget.orderId).notifier); + BuildContext context, + WidgetRef ref, + NostrEvent order, + ) { + final orderDetailsNotifier = ref.read( + orderNotifierProvider(widget.orderId).notifier, + ); - final buttonText = - widget.orderType == OrderType.buy ? S.of(context)!.sell : S.of(context)!.buy; + final buttonText = widget.orderType == OrderType.buy + ? S.of(context)!.sell + : S.of(context)!.buy; return Row( mainAxisAlignment: MainAxisAlignment.center, @@ -246,157 +244,196 @@ class _TakeOrderScreenState extends ConsumerState { const SizedBox(width: 16), Expanded( child: ElevatedButton( - onPressed: _isSubmitting ? null : () async { - setState(() { - _isSubmitting = true; - }); - // Check if this is a range order - if (order.fiatAmount.maximum != null && - order.fiatAmount.minimum != order.fiatAmount.maximum) { - // Show dialog to get the amount - String? errorText; - final enteredAmount = await showDialog( - context: context, - builder: (context) { - return StatefulBuilder( - builder: (context, setState) { - 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)!.enterAmount, - style: const TextStyle( - color: AppTheme.textPrimary, - fontSize: 18, - fontWeight: FontWeight.w600, - ), - ), - content: Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: BoxDecoration( - color: AppTheme.backgroundInput, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Colors.white.withValues(alpha: 0.1)), - ), - child: TextField( - controller: widget._fiatAmountController, - keyboardType: TextInputType.number, - style: const TextStyle(color: AppTheme.textPrimary), - decoration: InputDecoration( - hintText: S.of(context)!.enterAmountBetween( - order.fiatAmount.minimum.toString(), - order.fiatAmount.maximum.toString(), - order.currency ?? ''), - hintStyle: const TextStyle(color: AppTheme.textSecondary), - errorText: errorText, - errorStyle: const TextStyle(color: AppTheme.statusError), - border: InputBorder.none, - contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - ), - ), - ), - actions: [ - TextButton( - onPressed: () => context.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( - key: const Key('submitAmountButton'), - onPressed: () { - final inputAmount = int.tryParse( - widget._fiatAmountController.text.trim()); - if (inputAmount == null) { - setState(() { - errorText = - S.of(context)!.pleaseEnterValidNumber; - }); - } else if (inputAmount < - order.fiatAmount.minimum || - (order.fiatAmount.maximum != null && - inputAmount > - order.fiatAmount.maximum!)) { - setState(() { - errorText = S - .of(context)! - .amountMustBeBetween( - order.fiatAmount.minimum.toString(), - order.fiatAmount.maximum - .toString()); - }); - } else { - context.pop(inputAmount); - } - }, - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.activeColor, - foregroundColor: Colors.black, + onPressed: _isSubmitting + ? null + : () async { + setState(() { + _isSubmitting = true; + }); + // Check if this is a range order + if (order.fiatAmount.maximum != null && + order.fiatAmount.minimum != order.fiatAmount.maximum) { + // Show dialog to get the amount + String? errorText; + final enteredAmount = await showDialog( + context: context, + builder: (context) { + return StatefulBuilder( + builder: (context, setState) { + return AlertDialog( + backgroundColor: AppTheme.backgroundCard, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), + borderRadius: BorderRadius.circular(16), + side: BorderSide( + color: Colors.white.withValues(alpha: 0.1), + ), ), - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), - ), - child: Text( - S.of(context)!.submit, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w500, + title: Text( + S.of(context)!.enterAmount, + style: const TextStyle( + color: AppTheme.textPrimary, + fontSize: 18, + fontWeight: FontWeight.w600, + ), ), - ), - ), - ], + content: Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + decoration: BoxDecoration( + color: AppTheme.backgroundInput, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Colors.white.withValues( + alpha: 0.1, + ), + ), + ), + child: TextField( + controller: widget._fiatAmountController, + keyboardType: TextInputType.number, + style: const TextStyle( + color: AppTheme.textPrimary, + ), + decoration: InputDecoration( + hintText: S + .of(context)! + .enterAmountBetween( + order.fiatAmount.minimum.toString(), + order.fiatAmount.maximum.toString(), + order.currency ?? '', + ), + hintStyle: const TextStyle( + color: AppTheme.textSecondary, + ), + errorText: errorText, + errorStyle: const TextStyle( + color: AppTheme.statusError, + ), + border: InputBorder.none, + contentPadding: + const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + ), + ), + ), + actions: [ + TextButton( + onPressed: () => context.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( + key: const Key('submitAmountButton'), + onPressed: () { + final inputAmount = int.tryParse( + widget._fiatAmountController.text + .trim(), + ); + if (inputAmount == null) { + setState(() { + errorText = S + .of(context)! + .pleaseEnterValidNumber; + }); + } else if (inputAmount < + order.fiatAmount.minimum || + (order.fiatAmount.maximum != null && + inputAmount > + order.fiatAmount.maximum!)) { + setState(() { + errorText = S + .of(context)! + .amountMustBeBetween( + order.fiatAmount.minimum + .toString(), + order.fiatAmount.maximum + .toString(), + ); + }); + } else { + context.pop(inputAmount); + } + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.activeColor, + foregroundColor: Colors.black, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 12, + ), + ), + child: Text( + S.of(context)!.submit, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ); + }, + ); + }, + ); + + if (enteredAmount != null) { + if (widget.orderType == OrderType.buy) { + await orderDetailsNotifier.takeBuyOrder( + order.orderId!, + enteredAmount, + ); + } else { + final lndAddress = widget._lndAddressController.text + .trim(); + await orderDetailsNotifier.takeSellOrder( + order.orderId!, + enteredAmount, + lndAddress.isEmpty ? null : lndAddress, + ); + } + } else { + // Dialog was dismissed without entering amount, reset loading state + setState(() { + _isSubmitting = false; + }); + } + } else { + // Not a range order – use the existing logic. + final fiatAmount = int.tryParse( + widget._fiatAmountController.text.trim(), + ); + if (widget.orderType == OrderType.buy) { + await orderDetailsNotifier.takeBuyOrder( + order.orderId!, + fiatAmount, + ); + } else { + final lndAddress = widget._lndAddressController.text + .trim(); + await orderDetailsNotifier.takeSellOrder( + order.orderId!, + fiatAmount, + lndAddress.isEmpty ? null : lndAddress, ); - }, - ); + } + } }, - ); - - if (enteredAmount != null) { - if (widget.orderType == OrderType.buy) { - await orderDetailsNotifier.takeBuyOrder( - order.orderId!, enteredAmount); - } else { - final lndAddress = widget._lndAddressController.text.trim(); - await orderDetailsNotifier.takeSellOrder( - order.orderId!, - enteredAmount, - lndAddress.isEmpty ? null : lndAddress, - ); - } - } else { - // Dialog was dismissed without entering amount, reset loading state - setState(() { - _isSubmitting = false; - }); - } - } else { - // Not a range order – use the existing logic. - final fiatAmount = - int.tryParse(widget._fiatAmountController.text.trim()); - if (widget.orderType == OrderType.buy) { - await orderDetailsNotifier.takeBuyOrder( - order.orderId!, fiatAmount); - } else { - final lndAddress = widget._lndAddressController.text.trim(); - await orderDetailsNotifier.takeSellOrder( - order.orderId!, - fiatAmount, - lndAddress.isEmpty ? null : lndAddress, - ); - } - } - }, style: ElevatedButton.styleFrom( backgroundColor: AppTheme.mostroGreen, ), @@ -419,10 +456,12 @@ class _TakeOrderScreenState extends ConsumerState { String formatDateTime(DateTime dt, [BuildContext? context]) { if (context != null) { // Use internationalized date format - final dateFormatter = - DateFormat.yMMMd(Localizations.localeOf(context).languageCode); - final timeFormatter = - DateFormat.Hm(Localizations.localeOf(context).languageCode); + final dateFormatter = DateFormat.yMMMd( + Localizations.localeOf(context).languageCode, + ); + final timeFormatter = DateFormat.Hm( + Localizations.localeOf(context).languageCode, + ); final formattedDate = dateFormatter.format(dt); final formattedTime = timeFormatter.format(dt); @@ -444,9 +483,7 @@ class _TakeOrderScreenState extends ConsumerState { class _CountdownWidget extends ConsumerWidget { final NostrEvent order; - const _CountdownWidget({ - required this.order, - }); + const _CountdownWidget({required this.order}); @override Widget build(BuildContext context, WidgetRef ref) { @@ -463,7 +500,10 @@ class _CountdownWidget extends ConsumerWidget { } Widget _buildCountDownTime( - BuildContext context, WidgetRef ref, NostrEvent order) { + BuildContext context, + WidgetRef ref, + NostrEvent order, + ) { // Use exact timestamps from expires_at if (order.expiresAt == null) { // No valid expiration timestamp available @@ -474,15 +514,14 @@ class _CountdownWidget extends ConsumerWidget { if (expiresAtSeconds == null || expiresAtSeconds <= 0) { return const SizedBox.shrink(); } - final expiration = DateTime.fromMillisecondsSinceEpoch(expiresAtSeconds * 1000); + final expiration = DateTime.fromMillisecondsSinceEpoch( + expiresAtSeconds * 1000, + ); final createdAt = order.createdAt; if (createdAt == null) { return const SizedBox.shrink(); } - return DynamicCountdownWidget( - expiration: expiration, - createdAt: createdAt, - ); + return DynamicCountdownWidget(expiration: expiration, createdAt: createdAt); } } From 7cf8f31a9eb0bc9e61ee1e0a59354cc100b704cb Mon Sep 17 00:00:00 2001 From: Mostronator Date: Mon, 30 Mar 2026 23:00:32 +0000 Subject: [PATCH 08/17] fix: address CodeRabbit review issues from PR #552 - Distinguish loading vs error/not-found state when order == null (show spinner only while loading, icon when not found) - Guard _CountdownWidget to only render for pending orders - Add mounted check before setState after awaited showDialog - Fix German locale to use informal du/dich and 'Order' instead of 'Bestellung' to match existing intl_de.arb tone and terminology --- .../order/screens/take_order_screen.dart | 16 ++++++++++++++-- lib/l10n/intl_de.arb | 6 +++--- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/lib/features/order/screens/take_order_screen.dart b/lib/features/order/screens/take_order_screen.dart index 5c942ce26..9bc665831 100644 --- a/lib/features/order/screens/take_order_screen.dart +++ b/lib/features/order/screens/take_order_screen.dart @@ -5,6 +5,7 @@ import 'package:go_router/go_router.dart'; import 'package:intl/intl.dart'; import 'package:mostro_mobile/core/app_theme.dart'; import 'package:mostro_mobile/data/models/enums/action.dart' as actions; +import 'package:mostro_mobile/data/models/enums/status.dart'; import 'package:mostro_mobile/data/models/enums/order_type.dart'; import 'package:mostro_mobile/data/models/nostr_event.dart'; import 'package:mostro_mobile/features/order/providers/order_notifier_provider.dart'; @@ -40,6 +41,7 @@ class _TakeOrderScreenState extends ConsumerState { @override Widget build(BuildContext context) { final order = ref.watch(eventProvider(widget.orderId)); + final orderEventsAsync = ref.watch(orderEventsProvider); // Listen for messages to reset loading state on CantDo ref.listen(mostroMessageStreamProvider(widget.orderId), (_, next) { @@ -64,7 +66,15 @@ class _TakeOrderScreenState extends ConsumerState { : S.of(context)!.sellOrderDetailsTitle, ), body: order == null - ? const Center(child: CircularProgressIndicator()) + ? Center( + child: orderEventsAsync.isLoading + ? const CircularProgressIndicator() + : const Icon( + Icons.search_off, + size: 48, + color: Colors.white38, + ), + ) : SingleChildScrollView( padding: EdgeInsets.fromLTRB( 16.0, @@ -85,7 +95,8 @@ class _TakeOrderScreenState extends ConsumerState { const SizedBox(height: 16), _buildCreatorReputation(order), const SizedBox(height: 24), - _CountdownWidget(order: order), + if (order.status == Status.pending) + _CountdownWidget(order: order), const SizedBox(height: 36), _buildActionButtons(context, ref, order), ], @@ -409,6 +420,7 @@ class _TakeOrderScreenState extends ConsumerState { } } else { // Dialog was dismissed without entering amount, reset loading state + if (!mounted) return; setState(() { _isSubmitting = false; }); diff --git a/lib/l10n/intl_de.arb b/lib/l10n/intl_de.arb index 2c126c9a7..d1aed7963 100644 --- a/lib/l10n/intl_de.arb +++ b/lib/l10n/intl_de.arb @@ -1506,8 +1506,8 @@ "toggleTorch": "Taschenlampe umschalten", "switchCamera": "Kamera wechseln", "deepLinkDifferentMostroTitle": "Andere Mostro-Instanz", - "deepLinkDifferentMostroBody": "Diese Bestellung wurde auf einem anderen Mostro-Knoten erstellt. Möchten Sie zu diesem Mostro wechseln und die Bestellung ansehen?", - "deepLinkDifferentMostroFrom": "Bestellung von:", - "deepLinkDifferentMostroCurrent": "Derzeit verbunden mit:", + "deepLinkDifferentMostroBody": "Diese Order wurde auf einem anderen Mostro-Knoten erstellt. Möchtest du zu diesem Mostro wechseln und die Order ansehen?", + "deepLinkDifferentMostroFrom": "Order von:", + "deepLinkDifferentMostroCurrent": "Aktuell verbunden mit:", "deepLinkSwitchAndView": "Wechseln und ansehen" } \ No newline at end of file From 79fbe7258e47d1dc276fda8fbb201f9ba962d06c Mon Sep 17 00:00:00 2001 From: Mostronator Date: Mon, 30 Mar 2026 23:13:54 +0000 Subject: [PATCH 09/17] fix: validate mostroPubkey against event author in deep link resolution - Filter fetched NIP-69 events by event.pubkey when mostroPubkey is present in the deep link - Reject the link if no event from that specific Mostro node is found - Log a warning with event count from other nodes for easier debugging - Prevents a crafted link from switching the app to a fraudulent Mostro instance by exploiting a legitimate order published by a different node - Falls back to existing behavior (events.first) when mostroPubkey is absent from the link --- lib/services/deep_link_service.dart | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/lib/services/deep_link_service.dart b/lib/services/deep_link_service.dart index 45f7e8691..98bbae539 100644 --- a/lib/services/deep_link_service.dart +++ b/lib/services/deep_link_service.dart @@ -162,9 +162,24 @@ class DeepLinkService { events.addAll(defaultEvents); } + // When mostroPubkey is specified, only accept events authored by that node. + // This prevents a crafted link from switching the app to a fraudulent node + // by resolving a legitimate order published by a different Mostro instance. + final candidateEvents = mostroPubkey != null + ? events.where((e) => e.pubkey == mostroPubkey).toList() + : events; + + if (candidateEvents.isEmpty && mostroPubkey != null) { + logger.w( + 'Order $orderId not found for Mostro pubkey $mostroPubkey ' + '(found \${events.length} event(s) from other nodes)', + ); + return null; + } + // Process the first matching event - if (events.isNotEmpty) { - final event = events.first; + if (candidateEvents.isNotEmpty) { + final event = candidateEvents.first; // Extract order type from 'k' tag final kTag = event.tags?.firstWhere( From bb3aeb7a2bdd1a22205ba1c1bf233bd31959e3d5 Mon Sep 17 00:00:00 2001 From: Mostronator Date: Tue, 31 Mar 2026 05:09:54 +0000 Subject: [PATCH 10/17] fix: verify event signatures, fix log interpolation, and reset submit state - Add isVerified() check per event before accepting it as a candidate in _fetchOrderInfoById; log a warning on failure but do not reject the event, consistent with how mostro_nodes_notifier handles kind-0 events (known dart_nostr limitation) - Fix escaped string interpolation in logger.w so ${events.length} now correctly prints the actual event count instead of the literal text - Expand the terminal-action set in mostroMessageStreamProvider listener to include canceled, paymentFailed and holdInvoicePaymentCanceled alongside cantDo so _isSubmitting is cleared for every terminal outcome - Wrap takeBuyOrder and takeSellOrder call sites in try/catch blocks to ensure _isSubmitting is reset when those futures throw, preventing the CTA button from being permanently disabled after an error --- .../order/screens/take_order_screen.dart | 71 ++++++++++++------- lib/services/deep_link_service.dart | 15 +++- 2 files changed, 59 insertions(+), 27 deletions(-) diff --git a/lib/features/order/screens/take_order_screen.dart b/lib/features/order/screens/take_order_screen.dart index 9bc665831..e0551839f 100644 --- a/lib/features/order/screens/take_order_screen.dart +++ b/lib/features/order/screens/take_order_screen.dart @@ -49,8 +49,15 @@ class _TakeOrderScreenState extends ConsumerState { if (msg == null || msg.action == _lastSeenAction) return; _lastSeenAction = msg.action; - // Reset loading state only on CantDo message - if (msg.action == actions.Action.cantDo && _isSubmitting) { + // Reset loading state for every terminal outcome so the CTA + // is never left permanently disabled after a submit attempt. + const terminalActions = { + actions.Action.cantDo, + actions.Action.canceled, + actions.Action.paymentFailed, + actions.Action.holdInvoicePaymentCanceled, + }; + if (terminalActions.contains(msg.action) && _isSubmitting) { setState(() { _isSubmitting = false; }); @@ -404,46 +411,60 @@ class _TakeOrderScreenState extends ConsumerState { ); if (enteredAmount != null) { + try { + if (widget.orderType == OrderType.buy) { + await orderDetailsNotifier.takeBuyOrder( + order.orderId!, + enteredAmount, + ); + } else { + final lndAddress = widget._lndAddressController.text + .trim(); + await orderDetailsNotifier.takeSellOrder( + order.orderId!, + enteredAmount, + lndAddress.isEmpty ? null : lndAddress, + ); + } + } catch (e) { + if (!mounted) return; + setState(() { + _isSubmitting = false; + }); + } + } else { + // Dialog was dismissed without entering amount, reset loading state + if (!mounted) return; + setState(() { + _isSubmitting = false; + }); + } + } else { + // Not a range order – use the existing logic. + final fiatAmount = int.tryParse( + widget._fiatAmountController.text.trim(), + ); + try { if (widget.orderType == OrderType.buy) { await orderDetailsNotifier.takeBuyOrder( order.orderId!, - enteredAmount, + fiatAmount, ); } else { final lndAddress = widget._lndAddressController.text .trim(); await orderDetailsNotifier.takeSellOrder( order.orderId!, - enteredAmount, + fiatAmount, lndAddress.isEmpty ? null : lndAddress, ); } - } else { - // Dialog was dismissed without entering amount, reset loading state + } catch (e) { if (!mounted) return; setState(() { _isSubmitting = false; }); } - } else { - // Not a range order – use the existing logic. - final fiatAmount = int.tryParse( - widget._fiatAmountController.text.trim(), - ); - if (widget.orderType == OrderType.buy) { - await orderDetailsNotifier.takeBuyOrder( - order.orderId!, - fiatAmount, - ); - } else { - final lndAddress = widget._lndAddressController.text - .trim(); - await orderDetailsNotifier.takeSellOrder( - order.orderId!, - fiatAmount, - lndAddress.isEmpty ? null : lndAddress, - ); - } } }, style: ElevatedButton.styleFrom( diff --git a/lib/services/deep_link_service.dart b/lib/services/deep_link_service.dart index 98bbae539..17767fce7 100644 --- a/lib/services/deep_link_service.dart +++ b/lib/services/deep_link_service.dart @@ -165,14 +165,25 @@ class DeepLinkService { // When mostroPubkey is specified, only accept events authored by that node. // This prevents a crafted link from switching the app to a fraudulent node // by resolving a legitimate order published by a different Mostro instance. + // Note: isVerified() may return false for valid events due to a known + // dart_nostr limitation; log a warning but do not reject — consistent with + // how mostro_nodes_notifier.dart handles metadata events. final candidateEvents = mostroPubkey != null - ? events.where((e) => e.pubkey == mostroPubkey).toList() + ? events.where((e) { + if (!e.isVerified()) { + logger.w( + 'Event ${e.id} from pubkey ${e.pubkey} failed signature ' + 'verification (possible dart_nostr limitation).', + ); + } + return e.pubkey == mostroPubkey; + }).toList() : events; if (candidateEvents.isEmpty && mostroPubkey != null) { logger.w( 'Order $orderId not found for Mostro pubkey $mostroPubkey ' - '(found \${events.length} event(s) from other nodes)', + '(found ${events.length} event(s) from other nodes)', ); return null; } From 022ea7efad3cf181fae8c5566df2867bf00b779e Mon Sep 17 00:00:00 2001 From: Mostronator Date: Tue, 31 Mar 2026 05:59:17 +0000 Subject: [PATCH 11/17] fix: centralize terminal actions, fix relay fallback, and fix premature empty-state - Add Action.isTerminal getter to the Action enum covering all terminal outcomes (cantDo, canceled, adminCanceled, adminSettled, cooperativeCancelAccepted, released, rateReceived, paymentFailed, holdInvoicePaymentCanceled, holdInvoicePaymentSettled, purchaseCompleted) so the UI never needs to maintain a partial hard-coded set - Replace inline terminalActions set in TakeOrderScreen listener with msg.action.isTerminal for automatic coverage of future terminal actions - Fix relay fallback logic in _fetchOrderInfoById: retry with default relays when candidateEvents is empty (not just when events is empty), which previously skipped the fallback when link relays returned events from other Mostro nodes that did not match the requested mostroPubkey - Fix premature empty-state icon after Mostro switch by checking !orderEventsAsync.hasValue instead of orderEventsAsync.isLoading; the StreamProvider does not re-enter loading when the repository updates in place via updateMostroInstance so isLoading was unreliable --- lib/data/models/enums/action.dart | 19 ++++++- .../order/screens/take_order_screen.dart | 18 ++++--- lib/services/deep_link_service.dart | 51 +++++++++++-------- 3 files changed, 57 insertions(+), 31 deletions(-) diff --git a/lib/data/models/enums/action.dart b/lib/data/models/enums/action.dart index 38418e82b..5a564f4c1 100644 --- a/lib/data/models/enums/action.dart +++ b/lib/data/models/enums/action.dart @@ -51,7 +51,7 @@ enum Action { /// /// Throws an ArgumentError if the string doesn't match any Action value. static final _valueMap = { - for (var action in Action.values) action.value: action + for (var action in Action.values) action.value: action, }; static Action fromString(String value) { @@ -62,6 +62,23 @@ enum Action { return action; } + /// Returns true for every action that terminates a take-order submit flow. + /// Used in TakeOrderScreen to reliably reset the submitting state without + /// maintaining a partial hard-coded set in the UI layer. + bool get isTerminal => const { + cantDo, + canceled, + adminCanceled, + adminSettled, + cooperativeCancelAccepted, + released, + rateReceived, + paymentFailed, + holdInvoicePaymentCanceled, + holdInvoicePaymentSettled, + purchaseCompleted, + }.contains(this); + @override String toString() { return value; diff --git a/lib/features/order/screens/take_order_screen.dart b/lib/features/order/screens/take_order_screen.dart index e0551839f..63dcc5bc5 100644 --- a/lib/features/order/screens/take_order_screen.dart +++ b/lib/features/order/screens/take_order_screen.dart @@ -51,13 +51,8 @@ class _TakeOrderScreenState extends ConsumerState { // Reset loading state for every terminal outcome so the CTA // is never left permanently disabled after a submit attempt. - const terminalActions = { - actions.Action.cantDo, - actions.Action.canceled, - actions.Action.paymentFailed, - actions.Action.holdInvoicePaymentCanceled, - }; - if (terminalActions.contains(msg.action) && _isSubmitting) { + // isTerminal is defined centrally in Action enum. + if (msg.action.isTerminal && _isSubmitting) { setState(() { _isSubmitting = false; }); @@ -74,7 +69,14 @@ class _TakeOrderScreenState extends ConsumerState { ), body: order == null ? Center( - child: orderEventsAsync.isLoading + // Show spinner until the stream has emitted at least once. + // isLoading is not reliable here because orderEventsProvider + // is a StreamProvider backed by a repository that updates in + // place on settings changes (e.g. updateMostroInstance), + // so the stream never re-enters loading after a Mostro switch. + // Using !hasValue ensures we wait for the first emission + // before showing the empty-state icon. + child: !orderEventsAsync.hasValue ? const CircularProgressIndicator() : const Icon( Icons.search_off, diff --git a/lib/services/deep_link_service.dart b/lib/services/deep_link_service.dart index 17767fce7..0efd55e74 100644 --- a/lib/services/deep_link_service.dart +++ b/lib/services/deep_link_service.dart @@ -147,7 +147,6 @@ class DeepLinkService { // First try to fetch from specified relays if (relays.isNotEmpty) { - // Use the specific relays from the deep link URL final orderEvents = await nostrService.fetchEvents( filter, specificRelays: relays, @@ -155,31 +154,39 @@ class DeepLinkService { events.addAll(orderEvents); } - // If no events found and we have default relays, try those - if (events.isEmpty) { - logger.i('Order not found in specified relays, trying default relays'); + // Helper to build the candidate list from a set of raw events. + // When mostroPubkey is present only events authored by that node are + // accepted. isVerified() failures are logged but not treated as hard + // rejections due to a known dart_nostr limitation (consistent with + // how mostro_nodes_notifier.dart handles kind-0 events). + List buildCandidates(List raw) { + if (mostroPubkey == null) return raw; + return raw.where((e) { + if (!e.isVerified()) { + logger.w( + 'Event ${e.id} from pubkey ${e.pubkey} failed signature ' + 'verification (possible dart_nostr limitation).', + ); + } + return e.pubkey == mostroPubkey; + }).toList(); + } + + var candidateEvents = buildCandidates(events); + + // If no matching candidate was found in the link relays, retry with the + // app's default relays. This covers the case where events from other + // Mostro nodes were returned by the link relays (events non-empty but + // candidateEvents empty), which previously skipped the fallback entirely. + if (candidateEvents.isEmpty) { + logger.i( + 'No matching event in specified relays, trying default relays', + ); final defaultEvents = await nostrService.fetchEvents(filter); events.addAll(defaultEvents); + candidateEvents = buildCandidates(events); } - // When mostroPubkey is specified, only accept events authored by that node. - // This prevents a crafted link from switching the app to a fraudulent node - // by resolving a legitimate order published by a different Mostro instance. - // Note: isVerified() may return false for valid events due to a known - // dart_nostr limitation; log a warning but do not reject — consistent with - // how mostro_nodes_notifier.dart handles metadata events. - final candidateEvents = mostroPubkey != null - ? events.where((e) { - if (!e.isVerified()) { - logger.w( - 'Event ${e.id} from pubkey ${e.pubkey} failed signature ' - 'verification (possible dart_nostr limitation).', - ); - } - return e.pubkey == mostroPubkey; - }).toList() - : events; - if (candidateEvents.isEmpty && mostroPubkey != null) { logger.w( 'Order $orderId not found for Mostro pubkey $mostroPubkey ' From b68cac02bc87d8a88595e55456728efa2e5842ea Mon Sep 17 00:00:00 2001 From: Mostronator Date: Tue, 31 Mar 2026 06:38:22 +0000 Subject: [PATCH 12/17] fix: remove unused action.dart import after isTerminal refactor --- lib/features/order/screens/take_order_screen.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/features/order/screens/take_order_screen.dart b/lib/features/order/screens/take_order_screen.dart index 63dcc5bc5..339e94087 100644 --- a/lib/features/order/screens/take_order_screen.dart +++ b/lib/features/order/screens/take_order_screen.dart @@ -4,7 +4,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:intl/intl.dart'; import 'package:mostro_mobile/core/app_theme.dart'; -import 'package:mostro_mobile/data/models/enums/action.dart' as actions; import 'package:mostro_mobile/data/models/enums/status.dart'; import 'package:mostro_mobile/data/models/enums/order_type.dart'; import 'package:mostro_mobile/data/models/nostr_event.dart'; From 88191737efec963fc4df77faa856843cc8071171 Mon Sep 17 00:00:00 2001 From: Mostronator Date: Tue, 31 Mar 2026 16:50:46 +0000 Subject: [PATCH 13/17] fix: restore action.dart import and log exceptions in catch blocks - Re-add import for action.dart (without alias) so Action.isTerminal resolves correctly; the previous commit removed it when dropping the unused 'as actions' prefix but isTerminal still requires the import - Add logger import from logger_service.dart - Capture exception and stack trace in both catch blocks around takeBuyOrder and takeSellOrder so errors are logged via logger.e before clearing _isSubmitting, aiding future debugging --- .../order/screens/take_order_screen.dart | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/lib/features/order/screens/take_order_screen.dart b/lib/features/order/screens/take_order_screen.dart index 339e94087..174afa07f 100644 --- a/lib/features/order/screens/take_order_screen.dart +++ b/lib/features/order/screens/take_order_screen.dart @@ -4,6 +4,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:intl/intl.dart'; import 'package:mostro_mobile/core/app_theme.dart'; +import 'package:mostro_mobile/data/models/enums/action.dart'; +import 'package:mostro_mobile/services/logger_service.dart'; import 'package:mostro_mobile/data/models/enums/status.dart'; import 'package:mostro_mobile/data/models/enums/order_type.dart'; import 'package:mostro_mobile/data/models/nostr_event.dart'; @@ -427,7 +429,12 @@ class _TakeOrderScreenState extends ConsumerState { lndAddress.isEmpty ? null : lndAddress, ); } - } catch (e) { + } catch (e, stackTrace) { + logger.e( + 'Failed to take order', + error: e, + stackTrace: stackTrace, + ); if (!mounted) return; setState(() { _isSubmitting = false; @@ -460,7 +467,12 @@ class _TakeOrderScreenState extends ConsumerState { lndAddress.isEmpty ? null : lndAddress, ); } - } catch (e) { + } catch (e, stackTrace) { + logger.e( + 'Failed to take order', + error: e, + stackTrace: stackTrace, + ); if (!mounted) return; setState(() { _isSubmitting = false; From b8ee090ef369d6a4ba8eefef977ea8c3116e6470 Mon Sep 17 00:00:00 2001 From: Mostronator Date: Tue, 31 Mar 2026 17:13:48 +0000 Subject: [PATCH 14/17] fix: remove spurious action.dart import The Action type is resolved transitively through MostroMessage, which already imports action.dart. A direct import in take_order_screen.dart is unnecessary and triggers an unused_import warning that fails CI. --- lib/features/order/screens/take_order_screen.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/features/order/screens/take_order_screen.dart b/lib/features/order/screens/take_order_screen.dart index 174afa07f..b9cd3ff1e 100644 --- a/lib/features/order/screens/take_order_screen.dart +++ b/lib/features/order/screens/take_order_screen.dart @@ -4,7 +4,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:intl/intl.dart'; import 'package:mostro_mobile/core/app_theme.dart'; -import 'package:mostro_mobile/data/models/enums/action.dart'; import 'package:mostro_mobile/services/logger_service.dart'; import 'package:mostro_mobile/data/models/enums/status.dart'; import 'package:mostro_mobile/data/models/enums/order_type.dart'; From 818a5f41356ce9ef610041f4f8a7b76e7cc75181 Mon Sep 17 00:00:00 2001 From: Mostronator Date: Tue, 31 Mar 2026 20:03:49 +0000 Subject: [PATCH 15/17] fix: remove isTerminal from Action enum and scope submit reset correctly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove isTerminal getter from Action enum: it contained actions that cannot occur for an order in pending status (released, paymentFailed, holdInvoicePaymentCanceled, adminCanceled, cooperativeCancelAccepted, etc.), making it semantically incorrect and leaking UI logic into the domain enum - Per the Mostro protocol, only cantDo and canceled can arrive in TakeOrderScreen — cooperative cancel, disputes and admin actions require the order to be active/in-progress, not pending - Replace msg.action.isTerminal with an explicit two-value check (Action.cantDo || Action.canceled) that reflects the actual protocol - Add direct import for action.dart since Action values are now referenced explicitly instead of via a getter on an inferred type --- lib/data/models/enums/action.dart | 17 ----------------- .../order/screens/take_order_screen.dart | 11 +++++++---- 2 files changed, 7 insertions(+), 21 deletions(-) diff --git a/lib/data/models/enums/action.dart b/lib/data/models/enums/action.dart index 5a564f4c1..a153bf014 100644 --- a/lib/data/models/enums/action.dart +++ b/lib/data/models/enums/action.dart @@ -62,23 +62,6 @@ enum Action { return action; } - /// Returns true for every action that terminates a take-order submit flow. - /// Used in TakeOrderScreen to reliably reset the submitting state without - /// maintaining a partial hard-coded set in the UI layer. - bool get isTerminal => const { - cantDo, - canceled, - adminCanceled, - adminSettled, - cooperativeCancelAccepted, - released, - rateReceived, - paymentFailed, - holdInvoicePaymentCanceled, - holdInvoicePaymentSettled, - purchaseCompleted, - }.contains(this); - @override String toString() { return value; diff --git a/lib/features/order/screens/take_order_screen.dart b/lib/features/order/screens/take_order_screen.dart index b9cd3ff1e..89a017ef9 100644 --- a/lib/features/order/screens/take_order_screen.dart +++ b/lib/features/order/screens/take_order_screen.dart @@ -5,6 +5,7 @@ import 'package:go_router/go_router.dart'; import 'package:intl/intl.dart'; import 'package:mostro_mobile/core/app_theme.dart'; import 'package:mostro_mobile/services/logger_service.dart'; +import 'package:mostro_mobile/data/models/enums/action.dart'; import 'package:mostro_mobile/data/models/enums/status.dart'; import 'package:mostro_mobile/data/models/enums/order_type.dart'; import 'package:mostro_mobile/data/models/nostr_event.dart'; @@ -49,10 +50,12 @@ class _TakeOrderScreenState extends ConsumerState { if (msg == null || msg.action == _lastSeenAction) return; _lastSeenAction = msg.action; - // Reset loading state for every terminal outcome so the CTA - // is never left permanently disabled after a submit attempt. - // isTerminal is defined centrally in Action enum. - if (msg.action.isTerminal && _isSubmitting) { + // Reset loading state when Mostro rejects or cancels the take attempt. + // Only cantDo and canceled can arrive for an order in pending status + // per the Mostro protocol — cooperative/admin actions and later-stage + // outcomes (released, paymentFailed, etc.) cannot occur at this point. + if ((msg.action == Action.cantDo || msg.action == Action.canceled) && + _isSubmitting) { setState(() { _isSubmitting = false; }); From d259c328f477cb1724ca9f2b8544709308231159 Mon Sep 17 00:00:00 2001 From: Mostronator Date: Tue, 31 Mar 2026 20:31:53 +0000 Subject: [PATCH 16/17] fix: use alias for action.dart import to avoid ambiguity with flutter Action flutter/material.dart also exports an Action class (keyboard shortcuts). Import mostro action.dart with 'as mostro_action' alias so the compiler resolves Action.cantDo and Action.canceled unambiguously. --- lib/features/order/screens/take_order_screen.dart | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/features/order/screens/take_order_screen.dart b/lib/features/order/screens/take_order_screen.dart index 89a017ef9..a09b5fc92 100644 --- a/lib/features/order/screens/take_order_screen.dart +++ b/lib/features/order/screens/take_order_screen.dart @@ -5,7 +5,7 @@ import 'package:go_router/go_router.dart'; import 'package:intl/intl.dart'; import 'package:mostro_mobile/core/app_theme.dart'; import 'package:mostro_mobile/services/logger_service.dart'; -import 'package:mostro_mobile/data/models/enums/action.dart'; +import 'package:mostro_mobile/data/models/enums/action.dart' as mostro_action; import 'package:mostro_mobile/data/models/enums/status.dart'; import 'package:mostro_mobile/data/models/enums/order_type.dart'; import 'package:mostro_mobile/data/models/nostr_event.dart'; @@ -54,7 +54,8 @@ class _TakeOrderScreenState extends ConsumerState { // Only cantDo and canceled can arrive for an order in pending status // per the Mostro protocol — cooperative/admin actions and later-stage // outcomes (released, paymentFailed, etc.) cannot occur at this point. - if ((msg.action == Action.cantDo || msg.action == Action.canceled) && + if ((msg.action == mostro_action.Action.cantDo || + msg.action == mostro_action.Action.canceled) && _isSubmitting) { setState(() { _isSubmitting = false; From 831930bd468e7bee786846abced273d13b336d8b Mon Sep 17 00:00:00 2001 From: "MostronatorCoder[bot]" Date: Wed, 1 Apr 2026 18:19:10 +0000 Subject: [PATCH 17/17] fix(deep-link): reject unverified Nostr events to prevent pubkey spoofing Events that fail isVerified() are now rejected (return false) instead of being accepted when pubkey matches. This prevents an attacker from publishing a fake event with the target Mostro pubkey and bypassing the signature check. --- lib/services/deep_link_service.dart | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/services/deep_link_service.dart b/lib/services/deep_link_service.dart index 0efd55e74..e9a8e690c 100644 --- a/lib/services/deep_link_service.dart +++ b/lib/services/deep_link_service.dart @@ -164,9 +164,10 @@ class DeepLinkService { return raw.where((e) { if (!e.isVerified()) { logger.w( - 'Event ${e.id} from pubkey ${e.pubkey} failed signature ' - 'verification (possible dart_nostr limitation).', + 'Event \${e.id} from pubkey \${e.pubkey} failed signature ' + 'verification — rejecting to prevent spoofing.', ); + return false; } return e.pubkey == mostroPubkey; }).toList();