diff --git a/lib/core/app_routes.dart b/lib/core/app_routes.dart index 3bb156678..bcd0647e6 100644 --- a/lib/core/app_routes.dart +++ b/lib/core/app_routes.dart @@ -16,6 +16,7 @@ import 'package:mostro_mobile/features/trades/screens/trade_detail_screen.dart'; import 'package:mostro_mobile/features/trades/screens/trades_screen.dart'; import 'package:mostro_mobile/features/relays/relays_screen.dart'; import 'package:mostro_mobile/features/order/screens/add_lightning_invoice_screen.dart'; +import 'package:mostro_mobile/features/order/screens/pay_bond_invoice_screen.dart'; import 'package:mostro_mobile/features/order/screens/pay_lightning_invoice_screen.dart'; import 'package:mostro_mobile/features/order/screens/take_order_screen.dart'; import 'package:mostro_mobile/features/walkthrough/screens/walkthrough_screen.dart'; @@ -285,6 +286,17 @@ GoRouter createRouter(WidgetRef ref) { ), ), ), + GoRoute( + path: '/pay_bond/:orderId', + pageBuilder: (context, state) => + buildPageWithDefaultTransition( + context: context, + state: state, + child: PayBondInvoiceScreen( + orderId: state.pathParameters['orderId']!, + ), + ), + ), GoRoute( path: '/add_invoice/:orderId', pageBuilder: (context, state) => diff --git a/lib/data/models/enums/action.dart b/lib/data/models/enums/action.dart index e83df4438..3fda3e1fe 100644 --- a/lib/data/models/enums/action.dart +++ b/lib/data/models/enums/action.dart @@ -3,6 +3,7 @@ enum Action { takeSell('take-sell'), takeBuy('take-buy'), payInvoice('pay-invoice'), + payBondInvoice('pay-bond-invoice'), fiatSent('fiat-sent'), fiatSentOk('fiat-sent-ok'), release('release'), diff --git a/lib/data/models/enums/status.dart b/lib/data/models/enums/status.dart index c87f8ff01..db657a235 100644 --- a/lib/data/models/enums/status.dart +++ b/lib/data/models/enums/status.dart @@ -12,6 +12,7 @@ enum Status { success('success'), waitingBuyerInvoice('waiting-buyer-invoice'), waitingPayment('waiting-payment'), + waitingTakerBond('waiting-taker-bond'), paymentFailed('payment-failed'), cooperativelyCanceled('cooperatively-canceled'), inProgress('in-progress'); diff --git a/lib/features/notifications/utils/notification_message_mapper.dart b/lib/features/notifications/utils/notification_message_mapper.dart index b0878e48a..4e54ae4ef 100644 --- a/lib/features/notifications/utils/notification_message_mapper.dart +++ b/lib/features/notifications/utils/notification_message_mapper.dart @@ -17,6 +17,7 @@ class NotificationMessageMapper { case mostro.Action.takeSell: return 'notification_order_taken_title'; case mostro.Action.payInvoice: + case mostro.Action.payBondInvoice: return 'notification_payment_required_title'; case mostro.Action.fiatSent: return 'notification_fiat_sent_title'; @@ -119,6 +120,7 @@ class NotificationMessageMapper { case mostro.Action.takeSell: return 'notification_buy_order_taken_message'; case mostro.Action.payInvoice: + case mostro.Action.payBondInvoice: return 'notification_payment_required_message'; case mostro.Action.fiatSent: return 'notification_fiat_sent_message'; diff --git a/lib/features/notifications/widgets/notification_item.dart b/lib/features/notifications/widgets/notification_item.dart index c23391847..4707ed891 100644 --- a/lib/features/notifications/widgets/notification_item.dart +++ b/lib/features/notifications/widgets/notification_item.dart @@ -76,6 +76,9 @@ class NotificationItem extends ConsumerWidget { case mostro_action.Action.addInvoice: context.push('/add_invoice/${notification.orderId}'); break; + case mostro_action.Action.payBondInvoice: + context.push('/pay_bond/${notification.orderId}'); + break; case mostro_action.Action.canceled: case mostro_action.Action.adminCanceled: context.push('/order_book'); diff --git a/lib/features/order/models/order_state.dart b/lib/features/order/models/order_state.dart index e06a8e2da..d9076b123 100644 --- a/lib/features/order/models/order_state.dart +++ b/lib/features/order/models/order_state.dart @@ -271,6 +271,10 @@ class OrderState { case Action.payInvoice: return Status.waitingPayment; + // Action that should set status to waiting-taker-bond (Phase 1.5 anti-abuse bond) + case Action.payBondInvoice: + return Status.waitingTakerBond; + // Actions that should set status to waiting-buyer-invoice case Action.waitingBuyerInvoice: return Status.waitingBuyerInvoice; @@ -385,6 +389,12 @@ class OrderState { Action.cancel, ], }, + Status.waitingTakerBond: { + Action.payBondInvoice: [ + Action.payBondInvoice, + Action.cancel, + ], + }, Status.waitingPayment: { Action.payInvoice: [ Action.payInvoice, @@ -529,6 +539,12 @@ class OrderState { Action.cancel, ], }, + Status.waitingTakerBond: { + Action.payBondInvoice: [ + Action.payBondInvoice, + Action.cancel, + ], + }, Status.waitingPayment: { Action.waitingSellerToPay: [ Action.cancel, diff --git a/lib/features/order/notifiers/abstract_mostro_notifier.dart b/lib/features/order/notifiers/abstract_mostro_notifier.dart index 3ad498b69..d0e80d606 100644 --- a/lib/features/order/notifiers/abstract_mostro_notifier.dart +++ b/lib/features/order/notifiers/abstract_mostro_notifier.dart @@ -252,6 +252,13 @@ class AbstractMostroNotifier extends StateNotifier { ref.read(sessionNotifierProvider.notifier).saveSession(session); break; + case Action.payBondInvoice: + if (event.payload is PaymentRequest) { + navProvider.go('/pay_bond/${event.id!}'); + } + ref.read(sessionNotifierProvider.notifier).saveSession(session); + break; + case Action.addInvoice: final sessionNotifier = ref.read(sessionNotifierProvider.notifier); sessionNotifier.saveSession(session); diff --git a/lib/features/order/screens/pay_bond_invoice_screen.dart b/lib/features/order/screens/pay_bond_invoice_screen.dart new file mode 100644 index 000000000..b1a500c2f --- /dev/null +++ b/lib/features/order/screens/pay_bond_invoice_screen.dart @@ -0,0 +1,235 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:qr_flutter/qr_flutter.dart'; +import 'package:share_plus/share_plus.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:mostro_mobile/core/app_theme.dart'; +import 'package:mostro_mobile/features/order/providers/order_notifier_provider.dart'; +import 'package:mostro_mobile/features/order/widgets/order_app_bar.dart'; +import 'package:mostro_mobile/generated/l10n.dart'; +import 'package:mostro_mobile/services/logger_service.dart'; +import 'package:mostro_mobile/shared/utils/snack_bar_helper.dart'; + +class PayBondInvoiceScreen extends ConsumerWidget { + final String orderId; + + const PayBondInvoiceScreen({super.key, required this.orderId}); + + Future _confirmAndCancel( + BuildContext context, + WidgetRef ref, + ) async { + final s = S.of(context)!; + final confirmed = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + backgroundColor: AppTheme.backgroundCard, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: BorderSide(color: Colors.white.withValues(alpha: 0.1)), + ), + title: Text( + s.cancelTradeDialogTitle, + style: const TextStyle( + color: AppTheme.textPrimary, + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + content: Text( + s.areYouSureCancel, + style: const TextStyle( + color: AppTheme.textSecondary, + fontSize: 14, + height: 1.5, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: Text( + s.no, + style: const TextStyle( + color: AppTheme.textSecondary, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + textAlign: TextAlign.center, + ), + ), + const SizedBox(width: 12), + ElevatedButton( + onPressed: () => Navigator.of(dialogContext).pop(true), + 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.yes, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + ), + textAlign: TextAlign.center, + ), + ), + ], + ), + ); + + if (confirmed != true) return; + if (!context.mounted) return; + + final orderNotifier = ref.read(orderNotifierProvider(orderId).notifier); + context.go('/'); + await orderNotifier.cancelOrder(); + } + + Future _shareInvoice(BuildContext context, String lnInvoice) async { + final messenger = ScaffoldMessenger.of(context); + final mediaQuery = MediaQuery.of(context); + final errorMessage = S.of(context)!.failedToShareInvoice; + + try { + final uri = Uri.parse('lightning:$lnInvoice'); + if (await canLaunchUrl(uri)) { + await launchUrl(uri); + logger.i('Launched Lightning wallet with bond invoice'); + } else { + await Share.share(lnInvoice); + logger.i('Shared bond invoice via share sheet'); + } + } catch (e) { + logger.e('Failed to share bond invoice: $e'); + SnackBarHelper.showTopSnackBarAsync( + messenger: messenger, + screenHeight: mediaQuery.size.height, + statusBarHeight: mediaQuery.padding.top, + message: errorMessage, + duration: const Duration(seconds: 3), + ); + } + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final s = S.of(context)!; + final orderState = ref.watch(orderNotifierProvider(orderId)); + final lnInvoice = orderState.paymentRequest?.lnInvoice ?? ''; + final bondAmount = orderState.paymentRequest?.order?.amount; + + return Scaffold( + backgroundColor: AppTheme.dark1, + appBar: OrderAppBar(title: s.bondScreenTitle), + body: SingleChildScrollView( + padding: EdgeInsets.fromLTRB( + 16, + 16, + 16, + 16 + MediaQuery.of(context).viewPadding.bottom, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + s.bondExplanation, + style: const TextStyle( + color: AppTheme.cream1, + fontSize: 15, + height: 1.4, + ), + ), + if (bondAmount != null && bondAmount > 0) ...[ + const SizedBox(height: 16), + Text( + s.bondPayInvoicePrompt(bondAmount), + style: const TextStyle( + color: AppTheme.cream1, + fontSize: 15, + height: 1.4, + fontWeight: FontWeight.w500, + ), + ), + ], + const SizedBox(height: 20), + Center( + child: Container( + padding: const EdgeInsets.all(8.0), + color: AppTheme.cream1, + child: QrImageView( + data: lnInvoice, + version: QrVersions.auto, + size: 250.0, + backgroundColor: AppTheme.cream1, + errorStateBuilder: (cxt, err) { + return Center( + child: Text( + s.failedToGenerateQR, + textAlign: TextAlign.center, + ), + ); + }, + ), + ), + ), + const SizedBox(height: 20), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + ElevatedButton.icon( + onPressed: lnInvoice.isEmpty + ? null + : () { + Clipboard.setData(ClipboardData(text: lnInvoice)); + logger.i('Copied bond invoice to clipboard'); + SnackBarHelper.showTopSnackBar( + context, + s.invoiceCopiedToClipboard, + duration: const Duration(seconds: 2), + ); + }, + icon: const Icon(Icons.copy), + label: Text(s.copy), + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.mostroGreen, + ), + ), + ElevatedButton.icon( + onPressed: lnInvoice.isEmpty + ? null + : () => _shareInvoice(context, lnInvoice), + icon: const Icon(Icons.share), + label: Text(s.share), + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.mostroGreen, + ), + ), + ], + ), + const SizedBox(height: 20), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ElevatedButton( + onPressed: () => _confirmAndCancel(context, ref), + style: ElevatedButton.styleFrom( + foregroundColor: Colors.white, + backgroundColor: Colors.red, + ), + child: Text(s.cancel), + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/restore/restore_manager.dart b/lib/features/restore/restore_manager.dart index 2c95478fe..5e815fbb6 100644 --- a/lib/features/restore/restore_manager.dart +++ b/lib/features/restore/restore_manager.dart @@ -489,6 +489,9 @@ class RestoreService { return userRole == Role.seller ? Action.payInvoice : Action.waitingSellerToPay; + case Status.waitingTakerBond: + // Order is in the anti-abuse bond payment window; taker pays the bond + return Action.payBondInvoice; case Status.active: // If user is buyer, they need to confirm fiat sent // If user is seller, buyer took the order and seller waits diff --git a/lib/features/trades/screens/trade_detail_screen.dart b/lib/features/trades/screens/trade_detail_screen.dart index 9a164727c..9d4eb6e8c 100644 --- a/lib/features/trades/screens/trade_detail_screen.dart +++ b/lib/features/trades/screens/trade_detail_screen.dart @@ -332,6 +332,18 @@ class TradeDetailScreen extends ConsumerWidget { } break; + case actions.Action.payBondInvoice: + final hasPaymentRequest = tradeState.paymentRequest != null; + if (hasPaymentRequest) { + widgets.add(_buildNostrButton( + S.of(context)!.payBondButton, + action: actions.Action.payBondInvoice, + backgroundColor: AppTheme.mostroGreen, + onPressed: () => context.push('/pay_bond/$orderId'), + )); + } + break; + case actions.Action.addInvoice: if (userRole == Role.buyer) { widgets.add(_buildNostrButton( diff --git a/lib/features/trades/widgets/mostro_message_detail_widget.dart b/lib/features/trades/widgets/mostro_message_detail_widget.dart index 992ecb320..0f7302a66 100644 --- a/lib/features/trades/widgets/mostro_message_detail_widget.dart +++ b/lib/features/trades/widgets/mostro_message_detail_widget.dart @@ -84,6 +84,8 @@ class MostroMessageDetail extends ConsumerWidget { orderPayload?.fiatAmount.toString() ?? '', orderPayload?.fiatCode ?? '', ); + case actions.Action.payBondInvoice: + return S.of(context)!.payBondMessage; case actions.Action.addInvoice: final expSecs = ref .read(orderRepositoryProvider) @@ -290,6 +292,8 @@ class MostroMessageDetail extends ConsumerWidget { return S.of(context)!.statusDetailPending; case Status.waitingPayment: return S.of(context)!.statusDetailWaitingPayment; + case Status.waitingTakerBond: + return S.of(context)!.statusWaitingTakerBond; case Status.waitingBuyerInvoice: return S.of(context)!.statusDetailWaitingInvoice; case Status.paymentFailed: diff --git a/lib/features/trades/widgets/trades_list_item.dart b/lib/features/trades/widgets/trades_list_item.dart index f41946b61..d1f6b9f1d 100644 --- a/lib/features/trades/widgets/trades_list_item.dart +++ b/lib/features/trades/widgets/trades_list_item.dart @@ -206,6 +206,12 @@ class TradesListItem extends ConsumerWidget { textColor = AppTheme.statusWaitingText; label = S.of(context)!.waitingPayment; break; + case Status.waitingTakerBond: + backgroundColor = + AppTheme.statusWaitingBackground.withValues(alpha: 0.3); + textColor = AppTheme.statusWaitingText; + label = S.of(context)!.statusWaitingTakerBond; + break; case Status.waitingBuyerInvoice: backgroundColor = AppTheme.statusWaitingBackground.withValues(alpha: 0.3); diff --git a/lib/l10n/intl_de.arb b/lib/l10n/intl_de.arb index bd73d6ad9..c2cb221c5 100644 --- a/lib/l10n/intl_de.arb +++ b/lib/l10n/intl_de.arb @@ -704,6 +704,12 @@ "copy": "Kopieren", "share": "Teilen", "failedToShareInvoice": "Teilen der Rechnung fehlgeschlagen. Bitte versuche stattdessen, sie zu kopieren.", + "bondScreenTitle": "Anti-abuse deposit", + "bondExplanation": "To take this order you must pay an anti-abuse deposit. Here's how it works:\n\n• Your sats are held in your wallet, they are not spent.\n• If the exchange completes successfully, you get your deposit back automatically.\n• If you have a dispute on this order and lose it, you will lose the deposit.\n• This mechanism protects all users against scammers.", + "bondPayInvoicePrompt": "Pay the following invoice for {amount} sats to continue, or cancel if you don't agree.", + "statusWaitingTakerBond": "Awaiting deposit payment", + "payBondMessage": "Please pay the anti-abuse deposit to continue this order.", + "payBondButton": "Pay deposit", "failedToCancelOrder": "Fehler beim Abbrechen der Order: {error}", "@failedToCancelOrder": { "placeholders": { diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index 220266fc2..8acc81ca8 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -704,6 +704,12 @@ "copy": "Copy", "share": "Share", "failedToShareInvoice": "Failed to share invoice. Please try copying instead.", + "bondScreenTitle": "Anti-abuse deposit", + "bondExplanation": "To take this order you must pay an anti-abuse deposit. Here's how it works:\n\n• Your sats are held in your wallet, they are not spent.\n• If the exchange completes successfully, you get your deposit back automatically.\n• If you have a dispute on this order and lose it, you will lose the deposit.\n• This mechanism protects all users against scammers.", + "bondPayInvoicePrompt": "Pay the following invoice for {amount} sats to continue, or cancel if you don't agree.", + "statusWaitingTakerBond": "Awaiting deposit payment", + "payBondMessage": "Please pay the anti-abuse deposit to continue this order.", + "payBondButton": "Pay deposit", "failedToCancelOrder": "Failed to cancel order: {error}", "@failedToCancelOrder": { "placeholders": { diff --git a/lib/l10n/intl_es.arb b/lib/l10n/intl_es.arb index 1491ccb6b..0322c51bf 100644 --- a/lib/l10n/intl_es.arb +++ b/lib/l10n/intl_es.arb @@ -617,6 +617,12 @@ "copy": "Copiar", "share": "Compartir", "failedToShareInvoice": "Error al compartir factura. Por favor intenta copiarla en su lugar.", + "bondScreenTitle": "Depósito anti-abuso", + "bondExplanation": "Para tomar esta orden debes pagar un depósito anti-abuso. Funciona así:\n\n• Tus sats quedan retenidos en tu wallet, no se gastan.\n• Si el intercambio finaliza correctamente, recuperas el depósito automáticamente.\n• Si tienes una disputa en esta orden y la pierdes, perderás el depósito.\n• Este mecanismo protege a todos los usuarios contra estafadores.", + "bondPayInvoicePrompt": "Paga la siguiente factura de {amount} sats para continuar, o cancela si no estás de acuerdo.", + "statusWaitingTakerBond": "Esperando pago del depósito", + "payBondMessage": "Por favor paga el depósito anti-abuso para continuar con esta orden.", + "payBondButton": "Pagar depósito", "failedToCancelOrder": "Error al cancelar orden: {error}", "@failedToCancelOrder": { "placeholders": { diff --git a/lib/l10n/intl_fr.arb b/lib/l10n/intl_fr.arb index 8970a47d6..8a42dc8cc 100644 --- a/lib/l10n/intl_fr.arb +++ b/lib/l10n/intl_fr.arb @@ -704,6 +704,12 @@ "copy": "Copier", "share": "Partager", "failedToShareInvoice": "Échec du partage de la facture. Veuillez essayer de copier à la place.", + "bondScreenTitle": "Anti-abuse deposit", + "bondExplanation": "To take this order you must pay an anti-abuse deposit. Here's how it works:\n\n• Your sats are held in your wallet, they are not spent.\n• If the exchange completes successfully, you get your deposit back automatically.\n• If you have a dispute on this order and lose it, you will lose the deposit.\n• This mechanism protects all users against scammers.", + "bondPayInvoicePrompt": "Pay the following invoice for {amount} sats to continue, or cancel if you don't agree.", + "statusWaitingTakerBond": "Awaiting deposit payment", + "payBondMessage": "Please pay the anti-abuse deposit to continue this order.", + "payBondButton": "Pay deposit", "failedToCancelOrder": "Échec de l'annulation de la commande : {error}", "@failedToCancelOrder": { "placeholders": { diff --git a/lib/l10n/intl_it.arb b/lib/l10n/intl_it.arb index 9a77b72c5..89442802e 100644 --- a/lib/l10n/intl_it.arb +++ b/lib/l10n/intl_it.arb @@ -647,6 +647,12 @@ "copy": "Copia", "share": "Condividi", "failedToShareInvoice": "Errore nel condividere la fattura. Per favore prova a copiarla invece.", + "bondScreenTitle": "Deposito anti-abuso", + "bondExplanation": "Per prendere questo ordine devi pagare un deposito anti-abuso. Funziona così:\n\n• I tuoi sats vengono trattenuti nel tuo wallet, non vengono spesi.\n• Se lo scambio si conclude correttamente, recuperi il deposito automaticamente.\n• Se hai una disputa su questo ordine e la perdi, perderai il deposito.\n• Questo meccanismo protegge tutti gli utenti dai truffatori.", + "bondPayInvoicePrompt": "Paga la seguente fattura di {amount} sats per continuare, oppure annulla se non sei d'accordo.", + "statusWaitingTakerBond": "In attesa del pagamento del deposito", + "payBondMessage": "Per favore paga il deposito anti-abuso per continuare questo ordine.", + "payBondButton": "Paga deposito", "failedToCancelOrder": "Impossibile annullare l'ordine: {error}", "@failedToCancelOrder": { "placeholders": {