diff --git a/lib/features/home/providers/home_order_providers.dart b/lib/features/home/providers/home_order_providers.dart index f93ae29a..278477dc 100644 --- a/lib/features/home/providers/home_order_providers.dart +++ b/lib/features/home/providers/home_order_providers.dart @@ -3,6 +3,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:mostro/src/rust/api/orders.dart' as orders_api; import 'package:mostro/src/rust/api/types.dart'; +export 'package:mostro/src/rust/api/types.dart' show OrderStatus; + // ── Order type ──────────────────────────────────────────────────────────────── enum OrderType { buy, sell } @@ -60,6 +62,8 @@ class OrderItem { this.rating = 0.0, this.tradeCount = 0, this.daysActive = 0, + this.status = OrderStatus.pending, + this.amountSats, }) { final isFixed = fiatAmount != null && fiatAmountMin == null && @@ -89,6 +93,10 @@ class OrderItem { final double rating; final int tradeCount; final int daysActive; + /// Current order status from the Mostro protocol. + final OrderStatus status; + /// Sats amount resolved by Mostro (non-null once Mostro accepts the take). + final BigInt? amountSats; bool get isRange => fiatAmountMin != null && fiatAmountMax != null; @@ -118,6 +126,8 @@ class OrderItem { expiresAt: info.expiresAt != null ? DateTime.fromMillisecondsSinceEpoch(info.expiresAt! * 1000) : null, + status: info.status, + amountSats: info.amountSats, ); } diff --git a/lib/features/order/providers/trade_state_provider.dart b/lib/features/order/providers/trade_state_provider.dart new file mode 100644 index 00000000..045d1918 --- /dev/null +++ b/lib/features/order/providers/trade_state_provider.dart @@ -0,0 +1,38 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:mostro/src/rust/api/orders.dart' as orders_api; +import 'package:mostro/src/rust/api/types.dart'; + +/// Maps `orderId` → whether the local user is the buyer in that trade. +/// +/// Set this before navigating to [AddLightningInvoiceScreen] or +/// [TradeDetailScreen] so those screens know the user's role. +final tradeRoleProvider = + StateProvider>((ref) => const {}); + +/// Poll `getOrder()` every 2 s until `amountSats` is non-null, then stop. +/// +/// Returns `null` while waiting. Useful for the add-invoice screen which +/// needs the sats amount before it can submit a Lightning invoice. +final tradeAmountProvider = + StreamProvider.family.autoDispose((ref, orderId) async* { + while (true) { + final info = await orders_api.getOrder(orderId: orderId); + final sats = info?.amountSats; + yield sats; + if (sats != null) return; // done — no need to keep polling + await Future.delayed(const Duration(seconds: 2)); + } +}); + +/// Live order status for a single trade, polled from the order book every 2 s. +/// +/// Returns [OrderStatus.pending] as the initial / fallback value while loading. +final tradeStatusProvider = + StreamProvider.family.autoDispose((ref, orderId) async* { + yield OrderStatus.pending; // immediate first emission so UI doesn't hang + while (true) { + await Future.delayed(const Duration(seconds: 2)); + final info = await orders_api.getOrder(orderId: orderId); + if (info != null) yield info.status; + } +}); diff --git a/lib/features/order/screens/add_lightning_invoice_screen.dart b/lib/features/order/screens/add_lightning_invoice_screen.dart index 20c606f3..79253173 100644 --- a/lib/features/order/screens/add_lightning_invoice_screen.dart +++ b/lib/features/order/screens/add_lightning_invoice_screen.dart @@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart'; import 'package:mostro/core/app_routes.dart'; import 'package:mostro/core/app_theme.dart'; +import 'package:mostro/features/order/providers/trade_state_provider.dart'; import 'package:mostro/features/settings/providers/nwc_provider.dart'; import 'package:mostro/shared/widgets/nwc_invoice_widget.dart'; import 'package:mostro/src/rust/api/orders.dart' as orders_api; @@ -41,20 +42,44 @@ class _AddLightningInvoiceScreenState super.dispose(); } - bool get _isValid => - _invoiceController.text.trim().isNotEmpty && - widget.amountSats != null && - widget.amountSats! > 0; + BigInt? _resolvedSats(WidgetRef ref) { + final fromProvider = ref.watch(tradeAmountProvider(widget.orderId)).valueOrNull; + if (fromProvider != null) return fromProvider; + final fallback = widget.amountSats; + return fallback != null ? BigInt.from(fallback) : null; + } + + bool _isLnAddress(String text) => text.contains('@'); - Future _submit() async { - if (_submitting || !_isValid) return; + bool _isValid(WidgetRef ref) { + final text = _invoiceController.text.trim(); + if (text.isEmpty) return false; + // Lightning Address requires a known sats amount before submission. + if (_isLnAddress(text) && _resolvedSats(ref) == null) return false; + return true; + } + + Future _submit(WidgetRef ref) async { + if (_submitting) return; + final input = _invoiceController.text.trim(); + // For Lightning Addresses, the sats amount must be resolved before sending — + // the Rust side uses it to resolve the address. Bolt11 invoices encode + // their own amount so BigInt.one is an acceptable non-zero placeholder. + final resolvedSats = _resolvedSats(ref); + if (_isLnAddress(input) && resolvedSats == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Waiting for trade amount — please try again shortly.')), + ); + return; + } + final sats = resolvedSats ?? BigInt.one; setState(() => _submitting = true); try { await orders_api.sendInvoice( orderId: widget.orderId, invoiceOrAddress: _invoiceController.text.trim(), - amountSats: BigInt.from(widget.amountSats!), + amountSats: sats, ); if (!mounted) return; @@ -79,10 +104,12 @@ class _AddLightningInvoiceScreenState final isWalletConnected = ref.watch(isWalletConnectedProvider); - // Amount not yet resolved and user hasn't explicitly chosen manual mode: - // show a loading indicator while waiting for the trade provider. - final sats = widget.amountSats; - if (sats == null && !_manualMode) { + // Resolve sats: provider first (live polling), fall back to constructor param. + final sats = _resolvedSats(ref); + + // When NWC is connected, we need the sats amount to auto-generate an invoice. + // Show a loading indicator only in that case. Manual entry is always available. + if (isWalletConnected && sats == null && !_manualMode) { return Scaffold( appBar: AppBar(title: const Text('Add Invoice')), body: Center( @@ -95,6 +122,11 @@ class _AddLightningInvoiceScreenState 'Fetching trade amount…', style: TextStyle(color: Theme.of(context).extension()?.textSecondary), ), + const SizedBox(height: AppSpacing.md), + TextButton( + onPressed: () => setState(() => _manualMode = true), + child: const Text('Enter invoice manually'), + ), ], ), ), @@ -103,17 +135,17 @@ class _AddLightningInvoiceScreenState // If NWC wallet is connected, amount is known, and we haven't fallen back // to manual, show the auto-invoice widget instead of the manual form. - if (isWalletConnected && !_manualMode && sats != null && sats > 0) { + if (isWalletConnected && !_manualMode && sats != null && sats > BigInt.zero) { return Scaffold( appBar: AppBar(title: const Text('Add Invoice')), body: Padding( padding: const EdgeInsets.all(AppSpacing.lg), child: Center( child: NwcInvoiceWidget( - amountSats: sats, + amountSats: sats.toInt(), onInvoiceConfirmed: (invoice) { _invoiceController.text = invoice; - _submit(); + _submit(ref); }, onFallbackToManual: () => setState(() => _manualMode = true), ), @@ -196,7 +228,7 @@ class _AddLightningInvoiceScreenState const SizedBox(width: AppSpacing.md), Expanded( child: FilledButton( - onPressed: _isValid ? _submit : null, + onPressed: _isValid(ref) ? () => _submit(ref) : null, style: FilledButton.styleFrom( backgroundColor: green, foregroundColor: Colors.black, diff --git a/lib/features/order/screens/take_order_screen.dart b/lib/features/order/screens/take_order_screen.dart index 4d20f5c4..7ceeb2cf 100644 --- a/lib/features/order/screens/take_order_screen.dart +++ b/lib/features/order/screens/take_order_screen.dart @@ -9,8 +9,12 @@ import 'package:mostro/core/app_routes.dart'; import 'package:mostro/core/app_theme.dart'; import 'package:mostro/features/account/providers/privacy_mode_provider.dart'; import 'package:mostro/features/home/providers/home_order_providers.dart'; +import 'package:mostro/features/order/providers/trade_state_provider.dart'; import 'package:mostro/features/order/widgets/range_amount_modal.dart'; import 'package:mostro/shared/utils/fiat_currencies.dart'; +import 'package:mostro/src/rust/api/orders.dart' as orders_api; +import 'package:mostro/src/rust/api/settings.dart' as settings_api; +import 'package:mostro/src/rust/api/types.dart'; /// Take order screen — displays order details and allows the user /// to take (buy or sell) the order. @@ -37,7 +41,6 @@ class _TakeOrderScreenState extends ConsumerState { Timer? _countdownTimer; Duration _remaining = Duration.zero; bool _submitting = false; - // ignore: unused_field — used when Rust bridge take_order() is wired (Phase 8+). double? _selectedAmount; @override @@ -110,15 +113,31 @@ class _TakeOrderScreenState extends ConsumerState { setState(() => _submitting = true); try { - // TODO (Phase 8+): Call take_order(orderId, _selectedAmount) via Rust bridge. - await Future.delayed(const Duration(milliseconds: 500)); + // Dispatch take-order to Mostro via the Rust bridge. + await orders_api.takeOrder( + orderId: widget.orderId, + role: widget.isBuying ? TradeRole.buyer : TradeRole.seller, + fiatAmount: _selectedAmount, + ); if (!mounted) return; - // Navigate based on role: - // Buyer → add invoice screen; Seller → pay invoice screen. + // Record the user's role so TradeDetailScreen can read it. + ref.read(tradeRoleProvider.notifier).update( + (map) => {...map, widget.orderId: widget.isBuying}, + ); + if (widget.isBuying) { - context.push(AppRoute.addInvoicePath(widget.orderId)); + // Check whether a default LN address is configured. If yes, Mostro + // will pay it directly and the buyer can skip the add-invoice step. + final settings = await settings_api.getSettings(); + if (!mounted) return; + if (settings.defaultLightningAddress != null) { + // LN address was included in take-sell payload — go straight to trade. + context.go(AppRoute.tradeDetailPath(widget.orderId)); + } else { + context.push(AppRoute.addInvoicePath(widget.orderId)); + } } else { context.push(AppRoute.payInvoicePath(widget.orderId)); } diff --git a/lib/features/trades/screens/trade_detail_screen.dart b/lib/features/trades/screens/trade_detail_screen.dart index f1e7ad94..60ba1163 100644 --- a/lib/features/trades/screens/trade_detail_screen.dart +++ b/lib/features/trades/screens/trade_detail_screen.dart @@ -9,6 +9,8 @@ import 'package:mostro/core/app_routes.dart'; import 'package:mostro/core/app_theme.dart'; import 'package:mostro/src/rust/api/orders.dart' as orders_api; import 'package:mostro/features/disputes/providers/disputes_providers.dart'; +import 'package:mostro/features/home/providers/home_order_providers.dart'; +import 'package:mostro/features/order/providers/trade_state_provider.dart'; import 'package:mostro/features/trades/widgets/release_confirmation_dialog.dart'; import 'package:mostro/features/trades/widgets/trade_info_cards.dart'; import 'package:mostro/shared/widgets/mostro_reactive_button.dart'; @@ -32,6 +34,8 @@ const _kCountdownSeconds = 900; // 15 minutes /// Type-safe trade status for the detail screen. /// Will map to/from Rust bridge TradeStep when wired. enum TradeStatus { + /// Status not yet resolved (initial loading state — no actions shown). + loading('Loading'), active('Active'), fiatSent('Fiat Sent'), completed('Completed'), @@ -52,14 +56,6 @@ class _TradeDetailScreenState extends ConsumerState { Timer? _countdownTimer; Duration _remaining = const Duration(seconds: _kCountdownSeconds); - // TODO(bridge): Replace with real state from a TradeInfo Riverpod - // provider backed by the Rust bridge once FFI bindings expose - // TradeInfo for widget.orderId. Map TradeInfo.current_step to - // TradeStatus and TradeInfo.role to _isBuyer. - TradeStatus _status = TradeStatus.active; - // ignore: prefer_final_fields - bool _isBuyer = true; - @override void initState() { super.initState(); @@ -87,32 +83,95 @@ class _TradeDetailScreenState extends ConsumerState { }); } - String _getInstructionText() { - if (_isBuyer) { - if (_status == TradeStatus.active) { + String _formatDate(DateTime dt) => + '${dt.year}-${dt.month.toString().padLeft(2, '0')}-' + '${dt.day.toString().padLeft(2, '0')} ' + '${dt.hour.toString().padLeft(2, '0')}:' + '${dt.minute.toString().padLeft(2, '0')}'; + + static TradeStatus _mapOrderStatus(OrderStatus s) { + switch (s) { + case OrderStatus.active: + return TradeStatus.active; + case OrderStatus.fiatSent: + return TradeStatus.fiatSent; + case OrderStatus.settledHoldInvoice: + case OrderStatus.success: + case OrderStatus.completedByAdmin: + case OrderStatus.settledByAdmin: + return TradeStatus.pendingRating; + case OrderStatus.canceled: + case OrderStatus.canceledByAdmin: + case OrderStatus.expired: + return TradeStatus.cancelled; + case OrderStatus.dispute: + return TradeStatus.disputed; + default: + return TradeStatus.loading; + } + } + + Future _cancelOrder() async { + final l10n = AppLocalizations.of(context); + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(l10n.cancelTradeDialogTitle), + content: Text(l10n.cancelTradeDialogContent), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: Text(l10n.noButtonLabel), + ), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + child: Text(l10n.yesCancelButtonLabel), + ), + ], + ), + ); + if (confirmed != true || !mounted) return; + try { + await orders_api.cancelOrder(orderId: widget.orderId); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(l10n.cancelRequestSent)), + ); + } catch (e, st) { + debugPrint('[TradeDetailScreen] cancelOrder error: $e\n$st'); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(l10n.cancelRequestFailed)), + ); + } + } + + String _getInstructionText(bool isBuyer, TradeStatus status) { + if (isBuyer) { + if (status == TradeStatus.active) { return 'Send the fiat payment to the seller, then tap "Fiat Sent".'; - } else if (_status == TradeStatus.fiatSent) { + } else if (status == TradeStatus.fiatSent) { return 'Fiat payment marked as sent. Waiting for the seller ' 'to confirm receipt and release your sats.'; } } else { // Seller - if (_status == TradeStatus.active) { + if (status == TradeStatus.active) { return 'Contact the buyer with payment instructions.'; - } else if (_status == TradeStatus.fiatSent) { + } else if (status == TradeStatus.fiatSent) { return 'The buyer has confirmed they sent the fiat payment. ' 'Once you verify receipt, release the sats.'; } } - if (_status == TradeStatus.disputed) { + if (status == TradeStatus.disputed) { return 'A dispute resolver has been assigned. ' 'They will contact you through the app.'; } - if (_status == TradeStatus.pendingRating) { + if (status == TradeStatus.pendingRating) { return 'The trade completed successfully. ' 'Rate your counterpart to help build trust in the community.'; } - if (_status == TradeStatus.rated) { + if (status == TradeStatus.rated) { return 'Thank you for your rating!'; } return 'Trade in progress.'; @@ -126,6 +185,49 @@ class _TradeDetailScreenState extends ConsumerState { return '$h:$m:$s'; } + /// Shared style for destructive (cancel / dispute) outlined buttons. + ButtonStyle _destructiveOutlineStyle(Color destructiveRed) => + OutlinedButton.styleFrom( + foregroundColor: destructiveRed, + side: BorderSide(color: destructiveRed), + minimumSize: const Size(0, 40), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.button), + ), + ); + + /// RELEASE button — shared between the Disputed and Fiat-Sent seller flows. + Widget _buildReleaseButton(Color green) { + return MostroReactiveButton( + label: 'RELEASE', + backgroundColor: green, + icon: Icons.lock_open, + onPressed: () async { + final confirmed = await showReleaseConfirmationDialog(context); + if (confirmed != true || !context.mounted) return; + try { + await orders_api.releaseOrder(orderId: widget.orderId); + if (context.mounted) { + context.push(AppRoute.rateUserPath(widget.orderId)); + } + } catch (e, st) { + debugPrint('[TradeDetailScreen] releaseOrder error: $e\n$st'); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(AppLocalizations.of(context).releaseFailed)), + ); + } + }, + onError: (e) { + debugPrint('[TradeDetailScreen] releaseOrder onError: $e'); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(AppLocalizations.of(context).releaseFailed)), + ); + }, + ); + } + @override Widget build(BuildContext context) { final theme = Theme.of(context); @@ -133,6 +235,19 @@ class _TradeDetailScreenState extends ConsumerState { final green = colors?.mostroGreen ?? const Color(0xFF8CC63F); final textSec = colors?.textSecondary ?? const Color(0xFFB0B3C6); + // Derive role from provider (set by TakeOrderScreen before navigation). + final roleMap = ref.watch(tradeRoleProvider); + final isBuyer = roleMap[widget.orderId] ?? true; + + // Derive trade status from the polled order status. + final orderStatus = ref.watch(tradeStatusProvider(widget.orderId)).valueOrNull + ?? OrderStatus.pending; + final status = _mapOrderStatus(orderStatus); + + // Look up order details from the live order book. + final allOrders = ref.watch(orderBookProvider).valueOrNull ?? []; + final order = allOrders.where((o) => o.id == widget.orderId).firstOrNull; + return Scaffold( appBar: AppBar(title: const Text('ORDER DETAILS')), body: ListView( @@ -144,11 +259,18 @@ class _TradeDetailScreenState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - _isBuyer + isBuyer ? 'You are buying sats' : 'You are selling sats', style: theme.textTheme.headlineSmall, ), + if (order != null) ...[ + const SizedBox(height: AppSpacing.xs), + Text( + '${order.displayAmount} ${order.fiatCode}', + style: TextStyle(color: green, fontSize: 14, fontWeight: FontWeight.w600), + ), + ], const SizedBox(height: AppSpacing.xs), Text( 'Order ${widget.orderId}', @@ -165,7 +287,7 @@ class _TradeDetailScreenState extends ConsumerState { children: [ Icon(Icons.payment_outlined, size: 18, color: textSec), const SizedBox(width: AppSpacing.sm), - Text('Mercado Pago', style: theme.textTheme.bodyMedium), + Text(order?.paymentMethod ?? '—', style: theme.textTheme.bodyMedium), ], ), ), @@ -177,7 +299,10 @@ class _TradeDetailScreenState extends ConsumerState { children: [ Icon(Icons.calendar_today_outlined, size: 18, color: textSec), const SizedBox(width: AppSpacing.sm), - Text('2024-01-15 14:30', style: theme.textTheme.bodyMedium), + Text( + order != null ? _formatDate(order.createdAt) : '—', + style: theme.textTheme.bodyMedium, + ), ], ), ), @@ -189,8 +314,8 @@ class _TradeDetailScreenState extends ConsumerState { // Card 5: Instructions + status InstructionsCard( - text: _getInstructionText(), - statusLabel: _status.label, + text: _getInstructionText(isBuyer, status), + statusLabel: status.label, ), const SizedBox(height: AppSpacing.xl), @@ -225,21 +350,19 @@ class _TradeDetailScreenState extends ConsumerState { ], // Action buttons (buyer flow — T060) - if (_isBuyer && _status == TradeStatus.active) ...[ + if (isBuyer && status == TradeStatus.active) ...[ MostroReactiveButton( label: 'FIAT SENT', backgroundColor: green, icon: Icons.send, onPressed: () async { await orders_api.sendFiatSent(orderId: widget.orderId); - if (mounted) { - setState(() => _status = TradeStatus.fiatSent); - } }, onError: (e) { + debugPrint('[TradeDetailScreen] sendFiatSent onError: $e'); if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Failed to mark fiat sent: $e')), + SnackBar(content: Text(AppLocalizations.of(context).fiatSentFailed)), ); }, ), @@ -248,24 +371,11 @@ class _TradeDetailScreenState extends ConsumerState { children: [ Expanded( child: OutlinedButton.icon( - onPressed: () { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Coming soon')), - ); - }, + onPressed: _cancelOrder, icon: const Icon(Icons.cancel_outlined, size: 16), label: const Text('CANCEL'), - style: OutlinedButton.styleFrom( - foregroundColor: - colors?.destructiveRed ?? const Color(0xFFD84D4D), - side: BorderSide( - color: - colors?.destructiveRed ?? const Color(0xFFD84D4D), - ), - minimumSize: const Size(0, 40), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(AppRadius.button), - ), + style: _destructiveOutlineStyle( + colors?.destructiveRed ?? const Color(0xFFD84D4D), ), ), ), @@ -279,17 +389,8 @@ class _TradeDetailScreenState extends ConsumerState { }, icon: const Icon(Icons.gavel, size: 16), label: const Text('DISPUTE'), - style: OutlinedButton.styleFrom( - foregroundColor: - colors?.destructiveRed ?? const Color(0xFFD84D4D), - side: BorderSide( - color: - colors?.destructiveRed ?? const Color(0xFFD84D4D), - ), - minimumSize: const Size(0, 40), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(AppRadius.button), - ), + style: _destructiveOutlineStyle( + colors?.destructiveRed ?? const Color(0xFFD84D4D), ), ), ), @@ -313,7 +414,7 @@ class _TradeDetailScreenState extends ConsumerState { ], // ── Seller: Active — CLOSE + CANCEL + DISPUTE + CONTACT ── - if (!_isBuyer && _status == TradeStatus.active) ...[ + if (!isBuyer && status == TradeStatus.active) ...[ Row( children: [ Expanded( @@ -333,24 +434,11 @@ class _TradeDetailScreenState extends ConsumerState { const SizedBox(width: AppSpacing.sm), Expanded( child: OutlinedButton.icon( - onPressed: () { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Coming soon')), - ); - }, + onPressed: _cancelOrder, icon: const Icon(Icons.cancel_outlined, size: 16), label: const Text('CANCEL'), - style: OutlinedButton.styleFrom( - foregroundColor: - colors?.destructiveRed ?? const Color(0xFFD84D4D), - side: BorderSide( - color: - colors?.destructiveRed ?? const Color(0xFFD84D4D), - ), - minimumSize: const Size(0, 40), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(AppRadius.button), - ), + style: _destructiveOutlineStyle( + colors?.destructiveRed ?? const Color(0xFFD84D4D), ), ), ), @@ -364,17 +452,8 @@ class _TradeDetailScreenState extends ConsumerState { }, icon: const Icon(Icons.gavel, size: 16), label: const Text('DISPUTE'), - style: OutlinedButton.styleFrom( - foregroundColor: - colors?.destructiveRed ?? const Color(0xFFD84D4D), - side: BorderSide( - color: - colors?.destructiveRed ?? const Color(0xFFD84D4D), - ), - minimumSize: const Size(0, 40), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(AppRadius.button), - ), + style: _destructiveOutlineStyle( + colors?.destructiveRed ?? const Color(0xFFD84D4D), ), ), ), @@ -398,7 +477,7 @@ class _TradeDetailScreenState extends ConsumerState { ], // ── Disputed — CLOSE + CONTACT + CANCEL + RELEASE + VIEW DISPUTE ── - if (_status == TradeStatus.disputed) ...[ + if (status == TradeStatus.disputed) ...[ Row( children: [ Expanded( @@ -435,68 +514,25 @@ class _TradeDetailScreenState extends ConsumerState { ], ), // CANCEL + RELEASE only available to the seller during a dispute. - if (!_isBuyer) ...[ + if (!isBuyer) ...[ const SizedBox(height: AppSpacing.sm), Row( children: [ Expanded( child: OutlinedButton.icon( - onPressed: () { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Coming soon')), - ); - }, + onPressed: _cancelOrder, icon: const Icon(Icons.cancel_outlined, size: 16), label: const Text('CANCEL'), - style: OutlinedButton.styleFrom( - foregroundColor: - colors?.destructiveRed ?? const Color(0xFFD84D4D), - side: BorderSide( - color: - colors?.destructiveRed ?? const Color(0xFFD84D4D), - ), - minimumSize: const Size(0, 40), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(AppRadius.button), - ), + style: _destructiveOutlineStyle( + colors?.destructiveRed ?? const Color(0xFFD84D4D), ), ), ), const SizedBox(width: AppSpacing.sm), - Expanded( - child: MostroReactiveButton( - label: 'RELEASE', - backgroundColor: green, - icon: Icons.lock_open, - onPressed: () async { - final confirmed = - await showReleaseConfirmationDialog(context); - if (confirmed != true || !context.mounted) return; - try { - await orders_api.releaseOrder(orderId: widget.orderId); - if (context.mounted) { - context.push( - AppRoute.rateUserPath(widget.orderId), - ); - } - } catch (e) { - if (!context.mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Release failed: $e')), - ); - } - }, - onError: (e) { - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Release failed: $e')), - ); - }, - ), - ), + Expanded(child: _buildReleaseButton(green)), ], ), - ], // end if (!_isBuyer) + ], // end if (!isBuyer) const SizedBox(height: AppSpacing.sm), FilledButton.icon( onPressed: () { @@ -529,7 +565,7 @@ class _TradeDetailScreenState extends ConsumerState { ], // ── Seller: Fiat Sent — CLOSE + RELEASE + CANCEL + DISPUTE + CONTACT ── - if (!_isBuyer && _status == TradeStatus.fiatSent) ...[ + if (!isBuyer && status == TradeStatus.fiatSent) ...[ Row( children: [ Expanded( @@ -547,37 +583,7 @@ class _TradeDetailScreenState extends ConsumerState { ), ), const SizedBox(width: AppSpacing.sm), - Expanded( - child: MostroReactiveButton( - label: 'RELEASE', - backgroundColor: green, - icon: Icons.lock_open, - onPressed: () async { - final confirmed = - await showReleaseConfirmationDialog(context); - if (confirmed != true || !context.mounted) return; - try { - await orders_api.releaseOrder(orderId: widget.orderId); - if (context.mounted) { - context.push( - AppRoute.rateUserPath(widget.orderId), - ); - } - } catch (e) { - if (!context.mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Release failed: $e')), - ); - } - }, - onError: (e) { - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Release failed: $e')), - ); - }, - ), - ), + Expanded(child: _buildReleaseButton(green)), ], ), const SizedBox(height: AppSpacing.sm), @@ -585,24 +591,11 @@ class _TradeDetailScreenState extends ConsumerState { children: [ Expanded( child: OutlinedButton.icon( - onPressed: () { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Coming soon')), - ); - }, + onPressed: _cancelOrder, icon: const Icon(Icons.cancel_outlined, size: 16), label: const Text('CANCEL'), - style: OutlinedButton.styleFrom( - foregroundColor: - colors?.destructiveRed ?? const Color(0xFFD84D4D), - side: BorderSide( - color: - colors?.destructiveRed ?? const Color(0xFFD84D4D), - ), - minimumSize: const Size(0, 40), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(AppRadius.button), - ), + style: _destructiveOutlineStyle( + colors?.destructiveRed ?? const Color(0xFFD84D4D), ), ), ), @@ -616,17 +609,8 @@ class _TradeDetailScreenState extends ConsumerState { }, icon: const Icon(Icons.gavel, size: 16), label: const Text('DISPUTE'), - style: OutlinedButton.styleFrom( - foregroundColor: - colors?.destructiveRed ?? const Color(0xFFD84D4D), - side: BorderSide( - color: - colors?.destructiveRed ?? const Color(0xFFD84D4D), - ), - minimumSize: const Size(0, 40), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(AppRadius.button), - ), + style: _destructiveOutlineStyle( + colors?.destructiveRed ?? const Color(0xFFD84D4D), ), ), ), @@ -650,7 +634,7 @@ class _TradeDetailScreenState extends ConsumerState { ], // ── Pending rating — RATE + CLOSE ───────────────────────────── - if (_status == TradeStatus.pendingRating) ...[ + if (status == TradeStatus.pendingRating) ...[ FilledButton.icon( onPressed: () => context.push(AppRoute.rateUserPath(widget.orderId)), @@ -681,7 +665,7 @@ class _TradeDetailScreenState extends ConsumerState { ], // ── Rated — CLOSE only (no further actions) ─────────────────── - if (_status == TradeStatus.rated) ...[ + if (status == TradeStatus.rated) ...[ OutlinedButton( onPressed: () => context.pop(), style: OutlinedButton.styleFrom( diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index ad40133b..6ffa8283 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -196,5 +196,14 @@ "disableRelayLabel": "Relay {url} deaktivieren", "enableRelayLabel": "Relay {url} aktivieren", "removeRelayTooltip": "Relay entfernen", - "backupConfirmCheckbox": "Ich habe meine Wörter aufgeschrieben und sicher gespeichert" + "backupConfirmCheckbox": "Ich habe meine Wörter aufgeschrieben und sicher gespeichert", + + "cancelTradeDialogTitle": "Handel abbrechen?", + "cancelTradeDialogContent": "Kooperativen Abbruch angefragt. Die andere Partei muss ebenfalls zustimmen, damit der Handel vollständig abgebrochen wird.", + "noButtonLabel": "Nein", + "yesCancelButtonLabel": "Ja, abbrechen", + "cancelRequestSent": "Abbruchanfrage gesendet", + "cancelRequestFailed": "Abbrechen fehlgeschlagen. Bitte erneut versuchen.", + "fiatSentFailed": "Fiat-Zahlung konnte nicht bestätigt werden. Bitte erneut versuchen.", + "releaseFailed": "Freigabe fehlgeschlagen. Bitte erneut versuchen." } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index d9469216..786eb9a6 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -411,5 +411,22 @@ "removeRelayTooltip": "Remove relay", "@removeRelayTooltip": {"description": "Tooltip for the remove-relay icon button"}, "backupConfirmCheckbox": "I have written down my words and backed them up securely", - "@backupConfirmCheckbox": {"description": "Label for the backup confirmation checkbox on the Account screen"} + "@backupConfirmCheckbox": {"description": "Label for the backup confirmation checkbox on the Account screen"}, + + "cancelTradeDialogTitle": "Cancel trade?", + "@cancelTradeDialogTitle": {"description": "Title for the cancel-trade confirmation dialog"}, + "cancelTradeDialogContent": "Requesting a cooperative cancel. The other party must also agree for the trade to be fully cancelled.", + "@cancelTradeDialogContent": {"description": "Body text for the cancel-trade confirmation dialog"}, + "noButtonLabel": "No", + "@noButtonLabel": {"description": "Negative button label in a confirmation dialog"}, + "yesCancelButtonLabel": "Yes, cancel", + "@yesCancelButtonLabel": {"description": "Affirmative cancel button label in the cancel-trade dialog"}, + "cancelRequestSent": "Cancel request sent", + "@cancelRequestSent": {"description": "Snackbar shown after a cooperative cancel request is sent"}, + "cancelRequestFailed": "Failed to cancel. Please try again.", + "@cancelRequestFailed": {"description": "Snackbar shown when the cancel request fails"}, + "fiatSentFailed": "Failed to mark fiat as sent. Please try again.", + "@fiatSentFailed": {"description": "Snackbar shown when the fiat-sent action fails"}, + "releaseFailed": "Failed to release. Please try again.", + "@releaseFailed": {"description": "Snackbar shown when the release-sats action fails"} } diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 01b20864..39d85400 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -196,5 +196,14 @@ "disableRelayLabel": "Desactivar relay {url}", "enableRelayLabel": "Activar relay {url}", "removeRelayTooltip": "Eliminar relay", - "backupConfirmCheckbox": "He anotado mis palabras y las he guardado de forma segura" + "backupConfirmCheckbox": "He anotado mis palabras y las he guardado de forma segura", + + "cancelTradeDialogTitle": "¿Cancelar intercambio?", + "cancelTradeDialogContent": "Se solicita una cancelación cooperativa. La otra parte también debe aceptar para que el intercambio quede cancelado.", + "noButtonLabel": "No", + "yesCancelButtonLabel": "Sí, cancelar", + "cancelRequestSent": "Solicitud de cancelación enviada", + "cancelRequestFailed": "No se pudo cancelar. Por favor, inténtelo de nuevo.", + "fiatSentFailed": "Error al marcar el fiat como enviado. Por favor, inténtelo de nuevo.", + "releaseFailed": "Error al liberar. Por favor, inténtelo de nuevo." } diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index b88a151e..9551a2a9 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -196,5 +196,14 @@ "disableRelayLabel": "Désactiver le relais {url}", "enableRelayLabel": "Activer le relais {url}", "removeRelayTooltip": "Supprimer le relais", - "backupConfirmCheckbox": "J'ai noté mes mots et les ai sauvegardés en lieu sûr" + "backupConfirmCheckbox": "J'ai noté mes mots et les ai sauvegardés en lieu sûr", + + "cancelTradeDialogTitle": "Annuler l'échange ?", + "cancelTradeDialogContent": "Annulation coopérative demandée. L'autre partie doit également accepter pour que l'échange soit entièrement annulé.", + "noButtonLabel": "Non", + "yesCancelButtonLabel": "Oui, annuler", + "cancelRequestSent": "Demande d'annulation envoyée", + "cancelRequestFailed": "Échec de l'annulation. Veuillez réessayer.", + "fiatSentFailed": "Échec de la confirmation du paiement fiat. Veuillez réessayer.", + "releaseFailed": "Échec de la libération. Veuillez réessayer." } diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 188c2c05..20c48d2c 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -196,5 +196,14 @@ "disableRelayLabel": "Disabilita relay {url}", "enableRelayLabel": "Abilita relay {url}", "removeRelayTooltip": "Rimuovi relay", - "backupConfirmCheckbox": "Ho annotato le mie parole e le ho salvate in modo sicuro" + "backupConfirmCheckbox": "Ho annotato le mie parole e le ho salvate in modo sicuro", + + "cancelTradeDialogTitle": "Annullare lo scambio?", + "cancelTradeDialogContent": "Annullamento cooperativo richiesto. Anche l'altra parte deve accettare affinché lo scambio venga annullato.", + "noButtonLabel": "No", + "yesCancelButtonLabel": "Sì, annulla", + "cancelRequestSent": "Richiesta di annullamento inviata", + "cancelRequestFailed": "Annullamento fallito. Riprovare.", + "fiatSentFailed": "Impossibile contrassegnare il fiat come inviato. Riprovare.", + "releaseFailed": "Rilascio fallito. Riprovare." } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 382e9b3b..bb19b280 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1195,6 +1195,54 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'I have written down my words and backed them up securely'** String get backupConfirmCheckbox; + + /// Title for the cancel-trade confirmation dialog + /// + /// In en, this message translates to: + /// **'Cancel trade?'** + String get cancelTradeDialogTitle; + + /// Body text for the cancel-trade confirmation dialog + /// + /// In en, this message translates to: + /// **'Requesting a cooperative cancel. The other party must also agree for the trade to be fully cancelled.'** + String get cancelTradeDialogContent; + + /// Negative button label in a confirmation dialog + /// + /// In en, this message translates to: + /// **'No'** + String get noButtonLabel; + + /// Affirmative cancel button label in the cancel-trade dialog + /// + /// In en, this message translates to: + /// **'Yes, cancel'** + String get yesCancelButtonLabel; + + /// Snackbar shown after a cooperative cancel request is sent + /// + /// In en, this message translates to: + /// **'Cancel request sent'** + String get cancelRequestSent; + + /// Snackbar shown when the cancel request fails + /// + /// In en, this message translates to: + /// **'Failed to cancel. Please try again.'** + String get cancelRequestFailed; + + /// Snackbar shown when the fiat-sent action fails + /// + /// In en, this message translates to: + /// **'Failed to mark fiat as sent. Please try again.'** + String get fiatSentFailed; + + /// Snackbar shown when the release-sats action fails + /// + /// In en, this message translates to: + /// **'Failed to release. Please try again.'** + String get releaseFailed; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 9fdc1194..2454a239 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -603,4 +603,30 @@ class AppLocalizationsDe extends AppLocalizations { @override String get backupConfirmCheckbox => 'Ich habe meine Wörter aufgeschrieben und sicher gespeichert'; + + @override + String get cancelTradeDialogTitle => 'Handel abbrechen?'; + + @override + String get cancelTradeDialogContent => + 'Kooperativen Abbruch angefragt. Die andere Partei muss ebenfalls zustimmen, damit der Handel vollständig abgebrochen wird.'; + + @override + String get noButtonLabel => 'Nein'; + + @override + String get yesCancelButtonLabel => 'Ja, abbrechen'; + + @override + String get cancelRequestSent => 'Abbruchanfrage gesendet'; + + @override + String get cancelRequestFailed => 'Abbrechen fehlgeschlagen. Bitte erneut versuchen.'; + + @override + String get fiatSentFailed => + 'Fiat-Zahlung konnte nicht bestätigt werden. Bitte erneut versuchen.'; + + @override + String get releaseFailed => 'Freigabe fehlgeschlagen. Bitte erneut versuchen.'; } diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index dba52773..7b38aeb8 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -595,4 +595,29 @@ class AppLocalizationsEn extends AppLocalizations { @override String get backupConfirmCheckbox => 'I have written down my words and backed them up securely'; + + @override + String get cancelTradeDialogTitle => 'Cancel trade?'; + + @override + String get cancelTradeDialogContent => + 'Requesting a cooperative cancel. The other party must also agree for the trade to be fully cancelled.'; + + @override + String get noButtonLabel => 'No'; + + @override + String get yesCancelButtonLabel => 'Yes, cancel'; + + @override + String get cancelRequestSent => 'Cancel request sent'; + + @override + String get cancelRequestFailed => 'Failed to cancel. Please try again.'; + + @override + String get fiatSentFailed => 'Failed to mark fiat as sent. Please try again.'; + + @override + String get releaseFailed => 'Failed to release. Please try again.'; } diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index 24be6d92..5cc2d71c 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -603,4 +603,30 @@ class AppLocalizationsEs extends AppLocalizations { @override String get backupConfirmCheckbox => 'He anotado mis palabras y las he guardado de forma segura'; + + @override + String get cancelTradeDialogTitle => '¿Cancelar intercambio?'; + + @override + String get cancelTradeDialogContent => + 'Se solicita una cancelación cooperativa. La otra parte también debe aceptar para que el intercambio quede cancelado.'; + + @override + String get noButtonLabel => 'No'; + + @override + String get yesCancelButtonLabel => 'Sí, cancelar'; + + @override + String get cancelRequestSent => 'Solicitud de cancelación enviada'; + + @override + String get cancelRequestFailed => 'No se pudo cancelar. Por favor, inténtelo de nuevo.'; + + @override + String get fiatSentFailed => + 'Error al marcar el fiat como enviado. Por favor, inténtelo de nuevo.'; + + @override + String get releaseFailed => 'Error al liberar. Por favor, inténtelo de nuevo.'; } diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 3583d776..83658dc5 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -607,4 +607,30 @@ class AppLocalizationsFr extends AppLocalizations { @override String get backupConfirmCheckbox => 'J\'ai noté mes mots et les ai sauvegardés en lieu sûr'; + + @override + String get cancelTradeDialogTitle => 'Annuler l\'échange ?'; + + @override + String get cancelTradeDialogContent => + 'Annulation coopérative demandée. L\'autre partie doit également accepter pour que l\'échange soit entièrement annulé.'; + + @override + String get noButtonLabel => 'Non'; + + @override + String get yesCancelButtonLabel => 'Oui, annuler'; + + @override + String get cancelRequestSent => 'Demande d\'annulation envoyée'; + + @override + String get cancelRequestFailed => 'Échec de l\'annulation. Veuillez réessayer.'; + + @override + String get fiatSentFailed => + 'Échec de la confirmation du paiement fiat. Veuillez réessayer.'; + + @override + String get releaseFailed => 'Échec de la libération. Veuillez réessayer.'; } diff --git a/lib/l10n/app_localizations_it.dart b/lib/l10n/app_localizations_it.dart index d25d9e3c..1acb39e5 100644 --- a/lib/l10n/app_localizations_it.dart +++ b/lib/l10n/app_localizations_it.dart @@ -603,4 +603,29 @@ class AppLocalizationsIt extends AppLocalizations { @override String get backupConfirmCheckbox => 'Ho annotato le mie parole e le ho salvate in modo sicuro'; + + @override + String get cancelTradeDialogTitle => 'Annullare lo scambio?'; + + @override + String get cancelTradeDialogContent => + 'Annullamento cooperativo richiesto. Anche l\'altra parte deve accettare affinché lo scambio venga annullato.'; + + @override + String get noButtonLabel => 'No'; + + @override + String get yesCancelButtonLabel => 'Sì, annulla'; + + @override + String get cancelRequestSent => 'Richiesta di annullamento inviata'; + + @override + String get cancelRequestFailed => 'Annullamento fallito. Riprovare.'; + + @override + String get fiatSentFailed => 'Impossibile contrassegnare il fiat come inviato. Riprovare.'; + + @override + String get releaseFailed => 'Rilascio fallito. Riprovare.'; } diff --git a/rust/src/api/orders.rs b/rust/src/api/orders.rs index df53ad82..193f7fe5 100644 --- a/rust/src/api/orders.rs +++ b/rust/src/api/orders.rs @@ -3,6 +3,7 @@ /// Subscribes to Kind 38383 events from the relay pool, caches locally, /// applies filters, and exposes a stream for UI updates. use anyhow::Result; +use std::collections::HashMap; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use tokio::sync::{broadcast, RwLock}; @@ -12,6 +13,33 @@ use crate::config::DEFAULT_MOSTRO_PUBKEY; use crate::mostro::actions; use crate::nostr::order_events::parse_order_event; +// ── Per-trade key index map ─────────────────────────────────────────────────── + +/// Maps `order_id` → `trade_key_index` for trades initiated in this session. +/// Allows subsequent actions (add-invoice, fiat-sent, release) to sign with the +/// same trade key that was used when taking the order. +use std::sync::OnceLock; + +static TRADE_KEY_MAP: OnceLock>> = OnceLock::new(); + +fn trade_key_map() -> &'static std::sync::RwLock> { + TRADE_KEY_MAP.get_or_init(|| std::sync::RwLock::new(HashMap::new())) +} + +fn store_trade_key_index(order_id: &str, index: u32) { + if let Ok(mut map) = trade_key_map().write() { + map.insert(order_id.to_string(), index); + } +} + +fn get_trade_key_index(order_id: &str) -> u32 { + trade_key_map() + .read() + .ok() + .and_then(|m| m.get(order_id).copied()) + .unwrap_or(0) +} + /// Filter parameters for the order list. #[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)] pub struct OrderFilters { @@ -221,9 +249,8 @@ pub async fn create_order(params: NewOrderParams) -> Result { // Cache locally (optimistic — daemon will publish the real Kind 38383). order_book().upsert_order(order.clone()).await; - // Dispatch new_order to Mostro (fire-and-forget; daemon publishes Kind 38383 - // when it accepts the order, which arrives via the subscription loop). - if let Ok(sender_keys) = crate::api::identity::get_active_keys().await { + // Dispatch new_order to Mostro using the identity key (index 0). + if let Ok(sender_keys) = crate::api::identity::get_active_trade_keys(0).await { if let Ok(mostro_pubkey) = nostr_sdk::PublicKey::from_hex(DEFAULT_MOSTRO_PUBKEY) { match actions::new_order(&sender_keys, &mostro_pubkey, ¶ms_for_dispatch).await { Ok(event_json) => { @@ -243,11 +270,10 @@ pub async fn create_order(params: NewOrderParams) -> Result { /// Take an existing order, starting a trade. /// -/// Sends a `take-buy` or `take-sell` MostroMessage via NIP-59. +/// Sends a `take-buy` or `take-sell` MostroMessage via NIP-59 using a freshly +/// derived trade key. Automatically includes the user's default Lightning +/// Address in the payload when taking a sell order (take-sell-ln-address flow). /// Returns a `TradeInfo` with the initial trade state. -/// -/// TODO: Wire to actual Rust bridge identity + relay pool in Phase 8+. -/// Currently validates params and returns a mock TradeInfo. pub async fn take_order( order_id: String, role: crate::api::types::TradeRole, @@ -266,7 +292,7 @@ pub async fn take_order( return Err(anyhow::anyhow!("OrderAlreadyTaken")); } - // Validate range amount. + // Validate range amount when order has a range. let is_range = order.fiat_amount_min.is_some() && order.fiat_amount_max.is_some(); if is_range { let amt = fiat_amount.ok_or_else(|| anyhow::anyhow!("FiatAmountRequired"))?; @@ -282,7 +308,7 @@ pub async fn take_order( use crate::api::types::*; - // Validate role matches order kind. + // Role must match order kind: buyers take sell orders; sellers take buy orders. let expected_role = match order.kind { OrderKind::Buy => TradeRole::Seller, OrderKind::Sell => TradeRole::Buyer, @@ -302,6 +328,12 @@ pub async fn take_order( TradeRole::Seller => TradeStep::Seller(SellerStep::TakerFound), }; + // Derive a fresh trade key so each take uses a unique Nostr identity. + let trade_key_info = crate::api::identity::derive_trade_key().await?; + let trade_index = trade_key_info.index; + // Do NOT persist the mapping here — store only after the take event is + // successfully published so a publish failure doesn't leave a stale entry. + let trade = TradeInfo { id: uuid::Uuid::new_v4().to_string(), order: order.clone(), @@ -310,37 +342,73 @@ pub async fn take_order( current_step: initial_step, hold_invoice: None, buyer_invoice: None, - trade_key_index: 0, + trade_key_index: trade_index, cooperative_cancel_state: None, - timeout_at: Some(now + 900), // 15 min default + timeout_at: Some(now + 900), started_at: now, completed_at: None, outcome: None, }; - // Dispatch the take action to Mostro (fire-and-forget; the daemon responds - // asynchronously via NIP-59 gift wrap with the next trade step). - if let Ok(sender_keys) = crate::api::identity::get_active_keys().await { - if let Ok(mostro_pubkey) = nostr_sdk::PublicKey::from_hex(DEFAULT_MOSTRO_PUBKEY) { - let action_result = match dispatch_role { - TradeRole::Buyer => { - actions::take_sell(&sender_keys, &mostro_pubkey, &order_id, fiat_amount).await - } - TradeRole::Seller => { - actions::take_buy(&sender_keys, &mostro_pubkey, &order_id, fiat_amount).await - } - }; - match action_result { - Ok(event_json) => { - if let Err(e) = publish_event_json(&event_json).await { - log::warn!("[orders] take_order publish failed: {e}"); - } else { - log::info!("[orders] take_order dispatched for order={order_id}"); + // Dispatch the take action to Mostro using the trade key for signing. + match crate::api::identity::get_active_trade_keys(trade_index).await { + Ok(sender_keys) => { + if let Ok(mostro_pubkey) = nostr_sdk::PublicKey::from_hex(DEFAULT_MOSTRO_PUBKEY) { + // Read default LN address from settings (take-sell-ln-address flow). + let ln_address: Option = + crate::api::settings::get_settings() + .await + .ok() + .and_then(|s| s.default_lightning_address); + let ln_address_ref = ln_address.as_deref(); + + let action_result = match dispatch_role { + TradeRole::Buyer => { + actions::take_sell( + &sender_keys, + &mostro_pubkey, + &order_id, + trade_index, + fiat_amount, + ln_address_ref, + ) + .await } + TradeRole::Seller => { + actions::take_buy( + &sender_keys, + &mostro_pubkey, + &order_id, + trade_index, + fiat_amount, + ) + .await + } + }; + + match action_result { + Ok(event_json) => { + if let Err(e) = publish_event_json(&event_json).await { + log::warn!("[orders] take_order publish failed: {e}"); + } else { + // Persist the trade-key mapping only after a successful publish + // so a publish failure doesn't leave a stale entry. + store_trade_key_index(&order_id, trade_index); + log::info!( + "[orders] take_order dispatched order={order_id} \ + trade_index={trade_index} ln_address={}", + if ln_address_ref.is_some() { "present" } else { "none" } + ); + // Subscribe to d-tag K38383 updates for this specific order so we + // receive status changes (pending → in-progress → waiting-payment …). + subscribe_single_order(&order_id).await; + } + } + Err(e) => log::warn!("[orders] take_order action build failed: {e}"), } - Err(e) => log::warn!("[orders] take_order action build failed: {e}"), } } + Err(e) => log::warn!("[orders] take_order: could not get trade keys: {e}"), } Ok(trade) @@ -348,9 +416,8 @@ pub async fn take_order( /// Submit buyer's Lightning invoice for a trade. /// -/// Sends an `AddInvoice` MostroMessage to the daemon. -/// -/// TODO: Wire to actual NIP-59 message dispatch in Phase 9+. +/// Sends an `AddInvoice` MostroMessage to the daemon signed with the trade key +/// that was used when taking the order. pub async fn send_invoice( order_id: String, invoice_or_address: String, @@ -359,82 +426,153 @@ pub async fn send_invoice( if invoice_or_address.trim().is_empty() { return Err(anyhow::anyhow!("Invoice or address must not be empty")); } - if amount_sats == 0 { - return Err(anyhow::anyhow!("Amount must be greater than zero")); - } - - let order = order_book() - .get_order(&order_id) - .await - .ok_or_else(|| anyhow::anyhow!("OrderNotFound"))?; - if order.status != OrderStatus::WaitingBuyerInvoice - && order.status != OrderStatus::Pending - { - return Err(anyhow::anyhow!("WrongTradeState")); - } + // For bolt11 invoices the amount is encoded in the invoice; pass None. + // For Lightning Addresses Mostro needs the amount to resolve the address. + let amount_opt = if invoice_or_address.contains('@') && amount_sats > 0 { + Some(amount_sats) + } else { + None + }; - let sender_keys = crate::api::identity::get_active_keys().await?; + let trade_index = get_trade_key_index(&order_id); + let sender_keys = crate::api::identity::get_active_trade_keys(trade_index).await?; let mostro_pubkey = nostr_sdk::PublicKey::from_hex(DEFAULT_MOSTRO_PUBKEY)?; let event_json = actions::add_invoice( &sender_keys, &mostro_pubkey, &order_id, + trade_index, &invoice_or_address, + amount_opt, ) .await?; publish_event_json(&event_json).await?; - log::info!("[orders] add_invoice published for order={order_id}"); + log::info!( + "[orders] add_invoice published for order={order_id} trade_index={trade_index} \ + ln_address={} amount={:?}", + invoice_or_address.contains('@'), + amount_opt + ); Ok(()) } /// Mark fiat payment as sent by the buyer. /// -/// Sends a `FiatSent` MostroMessage to the Mostro daemon. -/// -/// Not yet implemented — requires NIP-59 message dispatch. +/// Sends a `FiatSent` MostroMessage to the Mostro daemon signed with the trade +/// key that was used when taking the order. pub async fn send_fiat_sent(order_id: String) -> Result<()> { - let order = order_book() - .get_order(&order_id) - .await - .ok_or_else(|| anyhow::anyhow!("OrderNotFound"))?; - - if order.status != OrderStatus::Active { - return Err(anyhow::anyhow!("WrongTradeState")); - } - - let sender_keys = crate::api::identity::get_active_keys().await?; + let trade_index = get_trade_key_index(&order_id); + let sender_keys = crate::api::identity::get_active_trade_keys(trade_index).await?; let mostro_pubkey = nostr_sdk::PublicKey::from_hex(DEFAULT_MOSTRO_PUBKEY)?; - let event_json = actions::fiat_sent(&sender_keys, &mostro_pubkey, &order_id).await?; + let event_json = actions::fiat_sent(&sender_keys, &mostro_pubkey, &order_id, trade_index).await?; publish_event_json(&event_json).await?; - log::info!("[orders] fiat_sent published for order={order_id}"); + log::info!("[orders] fiat_sent published for order={order_id} trade_index={trade_index}"); Ok(()) } /// Seller confirms fiat received and releases escrowed sats. /// -/// Sends a `Release` MostroMessage to the Mostro daemon. -/// Transitions trade status: FiatSent → SettledHoldInvoice → Success. -/// -/// Not yet implemented — requires NIP-59 message dispatch. +/// Sends a `Release` MostroMessage to the Mostro daemon signed with the trade +/// key that was used when taking the order. pub async fn release_order(order_id: String) -> Result<()> { - let order = order_book() - .get_order(&order_id) - .await - .ok_or_else(|| anyhow::anyhow!("OrderNotFound"))?; - - if order.status != OrderStatus::FiatSent { - return Err(anyhow::anyhow!("WrongTradeState")); - } + let trade_index = get_trade_key_index(&order_id); + let sender_keys = crate::api::identity::get_active_trade_keys(trade_index).await?; + let mostro_pubkey = nostr_sdk::PublicKey::from_hex(DEFAULT_MOSTRO_PUBKEY)?; + let event_json = actions::release(&sender_keys, &mostro_pubkey, &order_id, trade_index).await?; + publish_event_json(&event_json).await?; + log::info!("[orders] release published for order={order_id} trade_index={trade_index}"); + Ok(()) +} - let sender_keys = crate::api::identity::get_active_keys().await?; +/// Cancel an active trade cooperatively. +/// +/// Sends a `Cancel` MostroMessage signed with the trade key used when the order +/// was taken. Both parties must cancel for it to take effect; the Mostro daemon +/// handles the cooperative-cancel state machine. +pub async fn cancel_order(order_id: String) -> Result<()> { + let trade_index = get_trade_key_index(&order_id); + let sender_keys = crate::api::identity::get_active_trade_keys(trade_index).await?; let mostro_pubkey = nostr_sdk::PublicKey::from_hex(DEFAULT_MOSTRO_PUBKEY)?; - let event_json = actions::release(&sender_keys, &mostro_pubkey, &order_id).await?; + let event_json = actions::cancel(&sender_keys, &mostro_pubkey, &order_id, trade_index).await?; publish_event_json(&event_json).await?; - log::info!("[orders] release published for order={order_id}"); + log::info!("[orders] cancel published for order={order_id} trade_index={trade_index}"); Ok(()) } +// ── Single-order subscription ───────────────────────────────────────────────── + +/// Subscribe to K38383 updates for a single order (by `d`-tag) so that status +/// changes after taking the order are reflected in the local order book. +/// +/// Spawns a short-lived background task that watches for Kind 38383 events with +/// `d = order_id` and upserts them. The task exits when the relay pool shuts +/// down or after a generous idle timeout (no updates for 30 minutes). +async fn subscribe_single_order(order_id: &str) { + let order_id = order_id.to_string(); + tokio::spawn(async move { + let Ok(pool) = crate::api::nostr::get_pool() else { + log::warn!("[orders] subscribe_single_order: relay pool not initialized"); + return; + }; + let client = pool.client(); + let mostro_pubkey = + match nostr_sdk::PublicKey::from_hex(crate::config::DEFAULT_MOSTRO_PUBKEY) { + Ok(pk) => pk, + Err(e) => { + log::error!("[orders] subscribe_single_order: invalid pubkey: {e}"); + return; + } + }; + + let mut rx = client.notifications(); + let filter = + crate::nostr::order_events::trade_order_filter(&mostro_pubkey, &order_id); + if let Err(e) = client.subscribe(filter, None).await { + log::warn!("[orders] subscribe_single_order subscribe failed: {e}"); + return; + } + log::info!("[orders] subscribed to d-tag updates for order={order_id}"); + + use nostr_sdk::RelayPoolNotification; + use tokio::time::{timeout, Duration}; + + // Exit after 30 minutes of inactivity (no order updates received). + // The timer resets on each relevant event so active trades stay subscribed. + const IDLE_TIMEOUT_SECS: u64 = 30 * 60; + let mut last_activity = tokio::time::Instant::now(); + + loop { + let remaining = Duration::from_secs(IDLE_TIMEOUT_SECS) + .saturating_sub(last_activity.elapsed()); + if remaining.is_zero() { + log::debug!("[orders] subscribe_single_order idle timeout for order={order_id}"); + break; + } + + match timeout(remaining, rx.recv()).await { + Ok(Ok(RelayPoolNotification::Event { event, .. })) => { + if let Some(order) = crate::nostr::order_events::parse_order_event(&event, None) { + if order.id == order_id { + log::info!( + "[orders] d-tag update: order={} status={:?}", + order_id, + order.status + ); + last_activity = tokio::time::Instant::now(); + order_book().upsert_order(order).await; + } + } + } + Ok(Ok(RelayPoolNotification::Shutdown)) => break, + Ok(Err(_)) => break, + Err(_) => break, // idle timeout + Ok(Ok(_)) => continue, + } + } + }); +} + // ── Internal helpers ───────────────────────────────────────────────────────── /// Parse and publish a serialised Nostr event JSON via the relay pool. diff --git a/rust/src/frb_generated.rs b/rust/src/frb_generated.rs index 22570ceb..f9523752 100644 --- a/rust/src/frb_generated.rs +++ b/rust/src/frb_generated.rs @@ -2806,6 +2806,42 @@ fn wire__crate__api__orders__send_fiat_sent_impl( }, ) } +fn wire__crate__api__orders__cancel_order_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "cancel_order", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_order_id = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>( + (move || async move { + let output_ok = crate::api::orders::cancel_order(api_order_id).await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} fn wire__crate__api__messages__send_file_impl( port_: flutter_rust_bridge::for_generated::MessagePort, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, @@ -4794,6 +4830,7 @@ fn pde_ffi_dispatcher_primary_impl( 79 => wire__crate__api__reputation__submit_rating_impl(port, ptr, rust_vec_len, data_len), 80 => wire__crate__api__orders__subscribe_orders_impl(port, ptr, rust_vec_len, data_len), 81 => wire__crate__api__orders__take_order_impl(port, ptr, rust_vec_len, data_len), + 82 => wire__crate__api__orders__cancel_order_impl(port, ptr, rust_vec_len, data_len), _ => unreachable!(), } } diff --git a/rust/src/mostro/actions.rs b/rust/src/mostro/actions.rs index 0b86dc3a..abf57e59 100644 --- a/rust/src/mostro/actions.rs +++ b/rust/src/mostro/actions.rs @@ -1,15 +1,23 @@ /// Mostro action dispatch — builds and wraps MostroMessages. /// -/// Each function constructs a `MostroMessage` JSON payload and wraps it -/// via NIP-59 Gift Wrap, returning the event JSON ready for publication. +/// Each function constructs a `MostroMessage` JSON payload using the +/// `mostro-core` types and wraps it via NIP-59 Gift Wrap, returning the +/// event JSON ready for publication. +/// +/// Wire format: `[{"order":{...}}, null]` — a JSON-serialised +/// `(Message, Option)` tuple where the second element is always `null` +/// (no peer info is sent by the client). use anyhow::Result; +use mostro_core::message::{Action, Message, Payload}; use nostr_sdk::prelude::*; -use serde_json::json; +use uuid::Uuid; use crate::api::types::{NewOrderParams, OrderKind}; use crate::nostr::gift_wrap; use crate::nostr::order_events::KIND_ORDER; +// ── Public action builders ──────────────────────────────────────────────────── + /// Build and wrap a NewOrder MostroMessage. /// /// Returns the NIP-59 Gift Wrap event JSON ready for publication. @@ -18,24 +26,39 @@ pub async fn new_order( mostro_pubkey: &PublicKey, params: &NewOrderParams, ) -> Result { - let order_content = build_new_order_content(params); - let payload = json!({ - "order": { - "version": 1, - "action": "new-order", - "content": { - "order": order_content, - } - } - }); - - gift_wrap::wrap( - sender_keys, - mostro_pubkey, - &payload.to_string(), - Kind::from(KIND_ORDER), - ) - .await + use mostro_core::order::{Kind, SmallOrder, Status}; + + let kind = match params.kind { + OrderKind::Buy => Kind::Buy, + OrderKind::Sell => Kind::Sell, + }; + + let fiat_amount = params.fiat_amount.unwrap_or(0.0) as i64; + let fiat_amount_min = params.fiat_amount_min.map(|v| v as i64); + let fiat_amount_max = params.fiat_amount_max.map(|v| v as i64); + let premium = params.premium as i64; + + let small_order = SmallOrder::new( + None, + Some(kind), + Some(Status::Pending), + params.amount_sats.unwrap_or(0) as i64, + params.fiat_code.clone(), + fiat_amount_min, + fiat_amount_max, + fiat_amount, + params.payment_method.clone(), + premium, + None, + None, + None, + None, + None, + ); + + let payload = Some(Payload::Order(small_order)); + let msg = Message::new_order(None, None, Some(0), Action::NewOrder, payload); + wrap_message(sender_keys, mostro_pubkey, msg).await } /// Build and wrap a TakeBuy MostroMessage. @@ -43,48 +66,41 @@ pub async fn take_buy( sender_keys: &Keys, mostro_pubkey: &PublicKey, order_id: &str, + trade_index: u32, amount: Option, ) -> Result { - take_order_impl(sender_keys, mostro_pubkey, order_id, amount, "take-buy").await + take_order_impl( + sender_keys, + mostro_pubkey, + order_id, + trade_index, + amount, + None, + Action::TakeBuy, + ) + .await } /// Build and wrap a TakeSell MostroMessage. +/// +/// If `ln_address` is `Some`, it is included in the payload so Mostro can +/// pay the buyer directly (take-sell-ln-address variant). pub async fn take_sell( sender_keys: &Keys, mostro_pubkey: &PublicKey, order_id: &str, + trade_index: u32, amount: Option, + ln_address: Option<&str>, ) -> Result { - take_order_impl(sender_keys, mostro_pubkey, order_id, amount, "take-sell").await -} - -// ── Helpers ───────────────────────────────────────────────────────────────── - -async fn take_order_impl( - sender_keys: &Keys, - mostro_pubkey: &PublicKey, - order_id: &str, - amount: Option, - action: &str, -) -> Result { - let mut content = json!({ "id": order_id }); - if let Some(amt) = amount { - content["amount"] = json!(amt); - } - - let payload = json!({ - "order": { - "version": 1, - "action": action, - "content": content, - } - }); - - gift_wrap::wrap( + take_order_impl( sender_keys, mostro_pubkey, - &payload.to_string(), - Kind::from(KIND_ORDER), + order_id, + trade_index, + amount, + ln_address, + Action::TakeSell, ) .await } @@ -94,8 +110,9 @@ pub async fn fiat_sent( sender_keys: &Keys, mostro_pubkey: &PublicKey, order_id: &str, + trade_index: u32, ) -> Result { - simple_action(sender_keys, mostro_pubkey, order_id, "fiat-sent").await + simple_action(sender_keys, mostro_pubkey, order_id, trade_index, Action::FiatSent).await } /// Build and wrap a Release MostroMessage. @@ -103,8 +120,9 @@ pub async fn release( sender_keys: &Keys, mostro_pubkey: &PublicKey, order_id: &str, + trade_index: u32, ) -> Result { - simple_action(sender_keys, mostro_pubkey, order_id, "release").await + simple_action(sender_keys, mostro_pubkey, order_id, trade_index, Action::Release).await } /// Build and wrap a Cancel MostroMessage. @@ -112,88 +130,102 @@ pub async fn cancel( sender_keys: &Keys, mostro_pubkey: &PublicKey, order_id: &str, + trade_index: u32, ) -> Result { - simple_action(sender_keys, mostro_pubkey, order_id, "cancel").await + simple_action(sender_keys, mostro_pubkey, order_id, trade_index, Action::Cancel).await } -/// Build and wrap an AddInvoice MostroMessage (buyer submits Lightning invoice). +/// Build and wrap an AddInvoice MostroMessage (buyer submits Lightning invoice +/// or LN address). +/// +/// For bolt11 invoices the amount is already encoded in the invoice itself, so +/// the third payload field is `None`. For Lightning Addresses Mostro needs the +/// sats amount in the payload so it can resolve the address and generate the +/// invoice on behalf of the buyer — pass it via `amount_sats`. pub async fn add_invoice( sender_keys: &Keys, mostro_pubkey: &PublicKey, order_id: &str, + trade_index: u32, invoice: &str, + amount_sats: Option, ) -> Result { - let payload = json!({ - "order": { - "version": 1, - "action": "add-invoice", - "content": { - "id": order_id, - "payment_request": invoice, - } - } - }); - - gift_wrap::wrap( - sender_keys, - mostro_pubkey, - &payload.to_string(), - Kind::from(KIND_ORDER), - ) - .await + let id = Uuid::parse_str(order_id)?; + // A Lightning Address contains '@'; a bolt11 invoice does not. + let is_ln_address = invoice.contains('@'); + let amount_field: Option = if is_ln_address { + amount_sats.map(|a| a as i64) + } else { + None + }; + let payload = Some(Payload::PaymentRequest(None, invoice.to_string(), amount_field)); + let msg = Message::new_order( + Some(id), + None, + Some(trade_index as i64), + Action::AddInvoice, + payload, + ); + wrap_message(sender_keys, mostro_pubkey, msg).await } -/// Helper for actions that only need an order ID (no extra fields). -async fn simple_action( +// ── Helpers ─────────────────────────────────────────────────────────────────── + +/// Internal helper for take-buy / take-sell actions. +async fn take_order_impl( sender_keys: &Keys, mostro_pubkey: &PublicKey, order_id: &str, - action: &str, + trade_index: u32, + amount: Option, + ln_address: Option<&str>, + action: Action, ) -> Result { - let payload = json!({ - "order": { - "version": 1, - "action": action, - "content": { - "id": order_id, - } - } - }); - - gift_wrap::wrap( - sender_keys, - mostro_pubkey, - &payload.to_string(), - Kind::from(KIND_ORDER), - ) - .await + let id = Uuid::parse_str(order_id)?; + + let payload = match (amount, ln_address) { + // LN address + optional range amount + (amt, Some(addr)) => Some(Payload::PaymentRequest( + None, + addr.to_string(), + amt.map(|a| a as i64), + )), + // Range amount only (no LN address) + (Some(amt), None) => Some(Payload::Amount(amt as i64)), + // Standard fixed-amount take + (None, None) => None, + }; + + let msg = Message::new_order( + Some(id), + None, + Some(trade_index as i64), + action, + payload, + ); + wrap_message(sender_keys, mostro_pubkey, msg).await } -fn build_new_order_content(params: &NewOrderParams) -> serde_json::Value { - let kind_str = match params.kind { - OrderKind::Buy => "buy", - OrderKind::Sell => "sell", - }; +/// Helper for actions that only need an order ID and no additional payload. +async fn simple_action( + sender_keys: &Keys, + mostro_pubkey: &PublicKey, + order_id: &str, + trade_index: u32, + action: Action, +) -> Result { + let id = Uuid::parse_str(order_id)?; + let msg = Message::new_order(Some(id), None, Some(trade_index as i64), action, None); + wrap_message(sender_keys, mostro_pubkey, msg).await +} - let mut order = json!({ - "kind": kind_str, - "fiat_code": params.fiat_code, - "payment_method": params.payment_method, - "premium": params.premium, - }); - - if let Some(amt) = params.fiat_amount { - order["fiat_amount"] = json!(amt); - } - if let Some(min) = params.fiat_amount_min { - order["fiat_amount_min"] = json!(min); - } - if let Some(max) = params.fiat_amount_max { - order["fiat_amount_max"] = json!(max); - } - if let Some(sats) = params.amount_sats { - order["amount"] = json!(sats); - } - - order +/// Serialise `msg` as `[message, null]` (Mostro wire format), then wrap via +/// NIP-59 Gift Wrap. +async fn wrap_message( + sender_keys: &Keys, + mostro_pubkey: &PublicKey, + msg: Message, +) -> Result { + let json = serde_json::to_string(&(msg, Option::::None))?; + gift_wrap::wrap(sender_keys, mostro_pubkey, &json, Kind::from(KIND_ORDER)).await } diff --git a/rust/src/nostr/order_events.rs b/rust/src/nostr/order_events.rs index 4f01639a..cd5c1494 100644 --- a/rust/src/nostr/order_events.rs +++ b/rust/src/nostr/order_events.rs @@ -134,7 +134,6 @@ fn parse_fiat_range(raw: &Option) -> (Option, Option) { /// orders on behalf of makers after they send a `new-order` NIP-59 message. /// Filtering by `author = mostro_pubkey` ensures we only receive orders that /// belong to the trusted Mostro instance configured in the app. -/// Build a Nostr filter for pending orders from a specific Mostro node. /// /// The `s` tag value is `"pending"` (kebab-case) — mostro-core serialises /// the `Status` enum with `#[serde(rename_all = "kebab-case")]`. @@ -144,3 +143,16 @@ pub fn pending_orders_filter(mostro_pubkey: &PublicKey) -> Filter { .author(*mostro_pubkey) .custom_tag(SingleLetterTag::lowercase(Alphabet::S), "pending") } + +/// Build a Nostr filter for a **single** Kind 38383 order by `d`-tag (order ID). +/// +/// Unlike `pending_orders_filter`, this filter has **no status restriction** — +/// it captures every K38383 update for the given order ID regardless of status. +/// Use this after taking an order to track status changes: `pending` → +/// `in-progress` → `waiting-buyer-invoice` / `waiting-payment` → `active` etc. +pub fn trade_order_filter(mostro_pubkey: &PublicKey, order_id: &str) -> Filter { + Filter::new() + .kind(Kind::from(KIND_ORDER)) + .author(*mostro_pubkey) + .custom_tag(SingleLetterTag::lowercase(Alphabet::D), order_id) +}