diff --git a/integration_test/test_helpers.dart b/integration_test/test_helpers.dart index e8921fa82..06d1d7559 100644 --- a/integration_test/test_helpers.dart +++ b/integration_test/test_helpers.dart @@ -292,6 +292,10 @@ class FakeMostroService implements MostroService { @override Future sendInvoice(String orderId, String invoice, int? amount) async {} + @override + Future sendBondInvoice( + String orderId, String invoice, int? amount) async {} + @override Future cancelOrder(String orderId) async {} diff --git a/lib/core/app_routes.dart b/lib/core/app_routes.dart index bcd0647e6..6e15fc042 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/add_bond_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'; @@ -309,6 +310,17 @@ GoRouter createRouter(WidgetRef ref) { ), ), ), + GoRoute( + path: '/add_bond_invoice/:orderId', + pageBuilder: (context, state) => + buildPageWithDefaultTransition( + context: context, + state: state, + child: AddBondInvoiceScreen( + orderId: state.pathParameters['orderId']!, + ), + ), + ), GoRoute( path: '/notifications', pageBuilder: (context, state) => diff --git a/lib/data/models.dart b/lib/data/models.dart index 95f8b00a5..86999745d 100644 --- a/lib/data/models.dart +++ b/lib/data/models.dart @@ -1,4 +1,5 @@ export 'package:mostro_mobile/data/models/amount.dart'; +export 'package:mostro_mobile/data/models/bond_payout_request.dart'; export 'package:mostro_mobile/data/models/cant_do.dart'; export 'package:mostro_mobile/data/models/dispute.dart'; export 'package:mostro_mobile/data/models/mostro_message.dart'; diff --git a/lib/data/models/bond_payout_request.dart b/lib/data/models/bond_payout_request.dart new file mode 100644 index 000000000..8ff1afc6f --- /dev/null +++ b/lib/data/models/bond_payout_request.dart @@ -0,0 +1,78 @@ +import 'package:mostro_mobile/data/models/order.dart'; +import 'package:mostro_mobile/data/models/payload.dart'; + +/// Payload carried by [Action.addBondInvoice]. mostrod asks the non-slashed +/// counterparty for a bolt11 of `order.amount` sats; `slashedAt` (Unix +/// seconds) plus `bond_payout_claim_window_days` from kind-38385 gives the +/// forfeit deadline. +class BondPayoutRequest implements Payload { + final Order order; + final int slashedAt; + + BondPayoutRequest({ + required this.order, + required this.slashedAt, + }); + + @override + String get type => 'bond_payout_request'; + + @override + Map toJson() { + // Order.toJson() wraps the fields under an 'order' key (`{order: {...}}`). + // The mostrod wire ships the order's fields directly, so unwrap to keep + // toJson/fromJson symmetric. + final orderJson = order.toJson(); + final innerOrder = + (orderJson['order'] as Map?) ?? orderJson; + return { + type: { + 'order': innerOrder, + 'slashed_at': slashedAt, + }, + }; + } + + factory BondPayoutRequest.fromJson(Map json) { + try { + final orderJson = json['order']; + if (orderJson is! Map) { + throw FormatException( + 'Invalid order type: ${orderJson.runtimeType}'); + } + + final slashedAtValue = json['slashed_at']; + final int slashedAt; + if (slashedAtValue is int) { + slashedAt = slashedAtValue; + } else if (slashedAtValue is String) { + slashedAt = int.tryParse(slashedAtValue) ?? + (throw FormatException('Invalid slashed_at format: $slashedAtValue')); + } else { + throw FormatException( + 'Invalid slashed_at type: ${slashedAtValue.runtimeType}'); + } + + return BondPayoutRequest( + order: Order.fromJson(orderJson), + slashedAt: slashedAt, + ); + } catch (e) { + throw FormatException('Failed to parse BondPayoutRequest from JSON: $e'); + } + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is BondPayoutRequest && + other.order == order && + other.slashedAt == slashedAt; + } + + @override + int get hashCode => Object.hash(order, slashedAt); + + @override + String toString() => 'BondPayoutRequest(order: $order, slashedAt: $slashedAt)'; +} diff --git a/lib/data/models/enums/action.dart b/lib/data/models/enums/action.dart index 3fda3e1fe..f727261ed 100644 --- a/lib/data/models/enums/action.dart +++ b/lib/data/models/enums/action.dart @@ -27,6 +27,7 @@ enum Action { waitingSellerToPay('waiting-seller-to-pay'), waitingBuyerInvoice('waiting-buyer-invoice'), addInvoice('add-invoice'), + addBondInvoice('add-bond-invoice'), buyerTookOrder('buyer-took-order'), rate('rate'), rateUser('rate-user'), diff --git a/lib/data/models/order.dart b/lib/data/models/order.dart index 8e086a52c..bc1658042 100644 --- a/lib/data/models/order.dart +++ b/lib/data/models/order.dart @@ -84,8 +84,9 @@ class Order implements Payload { } } - // Validate required fields - ['kind', 'status', 'fiat_code', 'fiat_amount', 'payment_method'] + // `status` is intentionally NOT required: the add-bond-invoice payload + // nulls it out while still carrying the rest of the order context. + ['kind', 'fiat_code', 'fiat_amount', 'payment_method'] .forEach(validateField); // Parse and validate integer fields with type safety @@ -160,7 +161,9 @@ class Order implements Payload { return Order( id: parseOptionalStringField('id'), kind: OrderType.fromString(parseStringField('kind')), - status: Status.fromString(parseStringField('status')), + status: json['status'] == null + ? Status.pending + : Status.fromString(parseStringField('status')), amount: amount, fiatCode: parseStringField('fiat_code'), minAmount: minAmount, diff --git a/lib/data/models/payload.dart b/lib/data/models/payload.dart index 38ddbb256..c7c73e45d 100644 --- a/lib/data/models/payload.dart +++ b/lib/data/models/payload.dart @@ -1,3 +1,4 @@ +import 'package:mostro_mobile/data/models/bond_payout_request.dart'; import 'package:mostro_mobile/data/models/cant_do.dart'; import 'package:mostro_mobile/data/models/dispute.dart'; import 'package:mostro_mobile/data/models/next_trade.dart'; @@ -17,6 +18,8 @@ abstract class Payload { // If we check 'order' first, Disputes with nested Orders will be incorrectly parsed as Orders if (json.containsKey('dispute')) { return Dispute.fromJson(json); + } else if (json.containsKey('bond_payout_request')) { + return BondPayoutRequest.fromJson(json['bond_payout_request']); } else if (json.containsKey('order')) { return Order.fromJson(json['order']); } else if (json.containsKey('payment_request')) { diff --git a/lib/features/mostro/mostro_instance.dart b/lib/features/mostro/mostro_instance.dart index b42bcae09..b3128e40e 100644 --- a/lib/features/mostro/mostro_instance.dart +++ b/lib/features/mostro/mostro_instance.dart @@ -23,6 +23,10 @@ class MostroInstance { final String fiatCurrenciesAccepted; final int maxOrdersPerResponse; + /// Forfeit window for slashed-bond payouts, in days. Null when the + /// daemon doesn't emit `bond_payout_claim_window_days`. + final int? bondPayoutClaimWindowDays; + MostroInstance( this.pubKey, this.mostroVersion, @@ -45,6 +49,7 @@ class MostroInstance { this.lndNodeUri, this.fiatCurrenciesAccepted, this.maxOrdersPerResponse, + this.bondPayoutClaimWindowDays, ); factory MostroInstance.fromEvent(NostrEvent event) { @@ -70,6 +75,7 @@ class MostroInstance { event.lndNodeUri, event.fiatCurrenciesAccepted, event.maxOrdersPerResponse, + event.bondPayoutClaimWindowDays, ); } } @@ -80,6 +86,11 @@ extension MostroInstanceExtensions on NostrEvent { return (tag != null && tag.length > 1) ? tag[1] : 'Tag: $key not found'; } + String? _getOptionalTagValue(String key) { + final tag = tags?.firstWhere((t) => t[0] == key, orElse: () => []); + return (tag != null && tag.length > 1) ? tag[1] : null; + } + String get pubKey => _getTagValue('d'); String get mostroVersion => _getTagValue('mostro_version'); String get commitHash => _getTagValue('mostro_commit_hash'); @@ -104,4 +115,9 @@ extension MostroInstanceExtensions on NostrEvent { String get lndNodeUri => _getTagValue('lnd_uris'); String get fiatCurrenciesAccepted => _getTagValue('fiat_currencies_accepted'); int get maxOrdersPerResponse => int.parse(_getTagValue('max_orders_per_response')); + + int? get bondPayoutClaimWindowDays { + final raw = _getOptionalTagValue('bond_payout_claim_window_days'); + return raw == null ? null : int.tryParse(raw); + } } diff --git a/lib/features/notifications/utils/notification_message_mapper.dart b/lib/features/notifications/utils/notification_message_mapper.dart index 4e54ae4ef..4bf4e0b81 100644 --- a/lib/features/notifications/utils/notification_message_mapper.dart +++ b/lib/features/notifications/utils/notification_message_mapper.dart @@ -42,6 +42,7 @@ class NotificationMessageMapper { case mostro.Action.waitingBuyerInvoice: return 'notification_waiting_buyer_invoice_title'; case mostro.Action.addInvoice: + case mostro.Action.addBondInvoice: return 'notification_add_invoice_title'; case mostro.Action.buyerTookOrder: return 'notification_buyer_took_order_title'; @@ -145,6 +146,7 @@ class NotificationMessageMapper { case mostro.Action.waitingBuyerInvoice: return 'notification_waiting_buyer_invoice_message'; case mostro.Action.addInvoice: + case mostro.Action.addBondInvoice: return 'notification_add_invoice_message'; case mostro.Action.buyerTookOrder: return 'notification_buyer_took_order_message'; diff --git a/lib/features/notifications/widgets/notification_item.dart b/lib/features/notifications/widgets/notification_item.dart index 4707ed891..5d6886f4d 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.addBondInvoice: + context.push('/add_bond_invoice/${notification.orderId}'); + break; case mostro_action.Action.payBondInvoice: context.push('/pay_bond/${notification.orderId}'); break; diff --git a/lib/features/order/models/order_state.dart b/lib/features/order/models/order_state.dart index d9076b123..5c6b56fff 100644 --- a/lib/features/order/models/order_state.dart +++ b/lib/features/order/models/order_state.dart @@ -356,6 +356,11 @@ class OrderState { case Action.paymentFailed: return Status.paymentFailed; + // Bond payout is orthogonal to the trade FSM. Payload status is null + // and would otherwise wipe the real trade status. + case Action.addBondInvoice: + return status; + // Informational actions that should preserve current status case Action.rateUser: case Action.invoiceUpdated: @@ -481,6 +486,14 @@ class OrderState { Action.canceled: [], Action.adminCanceled: [], Action.cooperativeCancelAccepted: [], + Action.addBondInvoice: [ + Action.addBondInvoice, + ], + }, + Status.settledByAdmin: { + Action.addBondInvoice: [ + Action.addBondInvoice, + ], }, Status.cooperativelyCanceled: { // From active: no fiat sent, so no release button for seller @@ -623,6 +636,14 @@ class OrderState { Action.canceled: [], Action.adminCanceled: [], Action.cooperativeCancelAccepted: [], + Action.addBondInvoice: [ + Action.addBondInvoice, + ], + }, + Status.settledByAdmin: { + Action.addBondInvoice: [ + Action.addBondInvoice, + ], }, Status.cooperativelyCanceled: { // From active: no fiat sent, buyer can still send fiat to complete trade diff --git a/lib/features/order/notifiers/abstract_mostro_notifier.dart b/lib/features/order/notifiers/abstract_mostro_notifier.dart index d0e80d606..d8483860b 100644 --- a/lib/features/order/notifiers/abstract_mostro_notifier.dart +++ b/lib/features/order/notifiers/abstract_mostro_notifier.dart @@ -265,6 +265,13 @@ class AbstractMostroNotifier extends StateNotifier { await _handleAddInvoiceWithAutoLightningAddress(event); break; + case Action.addBondInvoice: + if (event.payload is BondPayoutRequest) { + navProvider.go('/add_bond_invoice/${event.id!}'); + } + ref.read(sessionNotifierProvider.notifier).saveSession(session); + break; + case Action.holdInvoicePaymentAccepted: final order = event.getPayload(); if (order == null) return; diff --git a/lib/features/order/notifiers/order_notifier.dart b/lib/features/order/notifiers/order_notifier.dart index 691731f56..608750488 100644 --- a/lib/features/order/notifiers/order_notifier.dart +++ b/lib/features/order/notifiers/order_notifier.dart @@ -117,6 +117,18 @@ class OrderNotifier extends AbstractMostroNotifier { ); } + Future sendBondInvoice( + String orderId, + String invoice, + int? amount, + ) async { + await mostroService.sendBondInvoice( + orderId, + invoice, + amount, + ); + } + Future cancelOrder() async { await mostroService.cancelOrder(orderId); } diff --git a/lib/features/order/screens/add_bond_invoice_screen.dart b/lib/features/order/screens/add_bond_invoice_screen.dart new file mode 100644 index 000000000..a278658b9 --- /dev/null +++ b/lib/features/order/screens/add_bond_invoice_screen.dart @@ -0,0 +1,246 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:mostro_mobile/core/app_theme.dart'; +import 'package:mostro_mobile/data/models/bond_payout_request.dart'; +import 'package:mostro_mobile/features/mostro/mostro_instance.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/features/wallet/providers/nwc_provider.dart'; +import 'package:mostro_mobile/generated/l10n.dart'; +import 'package:mostro_mobile/services/logger_service.dart'; +import 'package:mostro_mobile/shared/providers.dart'; +import 'package:mostro_mobile/shared/utils/snack_bar_helper.dart'; +import 'package:mostro_mobile/shared/widgets/nwc_invoice_widget.dart'; + +class AddBondInvoiceScreen extends ConsumerStatefulWidget { + final String orderId; + + const AddBondInvoiceScreen({ + super.key, + required this.orderId, + }); + + @override + ConsumerState createState() => + _AddBondInvoiceScreenState(); +} + +class _AddBondInvoiceScreenState extends ConsumerState { + final TextEditingController _invoiceController = TextEditingController(); + bool _manualMode = false; + + @override + void dispose() { + _invoiceController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final orderId = widget.orderId; + final mostroOrderAsync = + ref.watch(mostroBondPayoutRequestStreamProvider(orderId)); + + return mostroOrderAsync.when( + data: (mostroMessage) { + final payload = mostroMessage?.getPayload(); + final amount = payload?.order.amount ?? 0; + final orderIdValue = payload?.order.id ?? orderId; + + // slashed_at arrives as Unix seconds in the BondPayoutRequest + // payload (mostro-core 0.11.3+). Convert to milliseconds for + // DateTime. + final slashedAtMs = + payload != null ? payload.slashedAt * 1000 : null; + final infoEvent = + ref.watch(orderRepositoryProvider).mostroInstance; + final claimWindowDays = infoEvent == null + ? null + : MostroInstance.fromEvent(infoEvent).bondPayoutClaimWindowDays; + final deadline = (slashedAtMs != null && claimWindowDays != null) + ? DateTime.fromMillisecondsSinceEpoch(slashedAtMs) + .add(Duration(days: claimWindowDays)) + : null; + + final nwcState = ref.watch(nwcProvider); + final showNwc = nwcState.status == NwcStatus.connected && + !_manualMode && + amount > 0; + + return Scaffold( + backgroundColor: AppTheme.dark1, + appBar: OrderAppBar(title: S.of(context)!.addBondInvoiceTitle), + body: SingleChildScrollView( + padding: EdgeInsets.fromLTRB( + 16, + 16, + 16, + 16 + MediaQuery.of(context).viewPadding.bottom, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildExplanation( + amount: amount, + orderIdValue: orderIdValue, + deadline: deadline, + ), + const SizedBox(height: 24), + if (showNwc) + NwcInvoiceWidget( + sats: amount, + orderId: orderId, + onInvoiceConfirmed: (invoice) async { + await _submit(invoice, amount); + }, + onFallbackToManual: () { + setState(() => _manualMode = true); + }, + ) + else + _buildManualEntry(amount), + ], + ), + ), + ); + }, + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, st) => Center(child: Text('Error: $e')), + ); + } + + Widget _buildExplanation({ + required int amount, + required String orderIdValue, + required DateTime? deadline, + }) { + final s = S.of(context)!; + final deadlineLine = + deadline == null ? null : s.addBondInvoiceDeadline(_formatDate(deadline)); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + s.addBondInvoiceWonLine(orderIdValue), + style: const TextStyle( + color: AppTheme.textPrimary, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 12), + Text( + s.addBondInvoiceSubmitLine(amount), + style: const TextStyle( + color: AppTheme.textPrimary, + fontSize: 14, + ), + ), + if (deadlineLine != null) ...[ + const SizedBox(height: 8), + Text( + deadlineLine, + style: const TextStyle( + color: AppTheme.textSecondary, + fontSize: 14, + ), + ), + ], + ], + ); + } + + Widget _buildManualEntry(int amount) { + final s = S.of(context)!; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + 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: TextFormField( + key: const Key('bondInvoiceTextField'), + controller: _invoiceController, + style: const TextStyle(color: AppTheme.textPrimary), + decoration: InputDecoration( + labelText: s.addBondInvoiceInputLabel, + labelStyle: const TextStyle(color: AppTheme.textSecondary), + hintText: s.addBondInvoiceInputHint, + hintStyle: const TextStyle(color: AppTheme.textSecondary), + border: InputBorder.none, + contentPadding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + alignLabelWithHint: true, + ), + maxLines: 6, + ), + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: ElevatedButton( + key: const Key('submitBondInvoiceButton'), + onPressed: () async { + final invoice = _invoiceController.text.trim(); + if (invoice.isNotEmpty) { + await _submit(invoice, amount); + } + }, + 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.addBondInvoiceSubmitButton, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + ), + ), + ], + ), + ], + ); + } + + Future _submit(String invoice, int amount) async { + final orderNotifier = + ref.read(orderNotifierProvider(widget.orderId).notifier); + try { + logger.d('Submitting bond payout invoice for order ${widget.orderId}'); + await orderNotifier.sendBondInvoice(widget.orderId, invoice, amount); + if (mounted) context.go('/'); + } catch (e) { + if (mounted) { + WidgetsBinding.instance.addPostFrameCallback((_) { + SnackBarHelper.showTopSnackBar( + context, + S.of(context)!.addBondInvoiceFailedToSubmit(e.toString()), + ); + }); + } + } + } + + String _formatDate(DateTime d) { + final mm = d.month.toString().padLeft(2, '0'); + final dd = d.day.toString().padLeft(2, '0'); + final hh = d.hour.toString().padLeft(2, '0'); + final mi = d.minute.toString().padLeft(2, '0'); + return '${d.year}-$mm-$dd $hh:$mi'; + } +} diff --git a/lib/features/trades/screens/trade_detail_screen.dart b/lib/features/trades/screens/trade_detail_screen.dart index 9d4eb6e8c..979101b92 100644 --- a/lib/features/trades/screens/trade_detail_screen.dart +++ b/lib/features/trades/screens/trade_detail_screen.dart @@ -355,6 +355,15 @@ class TradeDetailScreen extends ConsumerWidget { } break; + case actions.Action.addBondInvoice: + widgets.add(_buildNostrButton( + S.of(context)!.addBondInvoiceButton, + action: actions.Action.addBondInvoice, + backgroundColor: AppTheme.mostroGreen, + onPressed: () => context.push('/add_bond_invoice/$orderId'), + )); + break; + case actions.Action.fiatSent: 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 0f7302a66..9bb0a6bea 100644 --- a/lib/features/trades/widgets/mostro_message_detail_widget.dart +++ b/lib/features/trades/widgets/mostro_message_detail_widget.dart @@ -86,6 +86,8 @@ class MostroMessageDetail extends ConsumerWidget { ); case actions.Action.payBondInvoice: return S.of(context)!.payBondMessage; + case actions.Action.addBondInvoice: + return S.of(context)!.addBondInvoiceMessage; case actions.Action.addInvoice: final expSecs = ref .read(orderRepositoryProvider) diff --git a/lib/features/trades/widgets/trades_list_item.dart b/lib/features/trades/widgets/trades_list_item.dart index d1f6b9f1d..4c22c0e1a 100644 --- a/lib/features/trades/widgets/trades_list_item.dart +++ b/lib/features/trades/widgets/trades_list_item.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/core/app_theme.dart'; +import 'package:mostro_mobile/data/models/enums/action.dart' as mostro_action; import 'package:mostro_mobile/data/models/enums/role.dart'; import 'package:mostro_mobile/data/models/enums/status.dart'; import 'package:mostro_mobile/data/models/enums/order_type.dart'; @@ -70,7 +71,9 @@ class TradesListItem extends ConsumerWidget { // Second row: Status and role chips + Premium/Discount Row( children: [ - _buildStatusChip(context, orderState.status), + orderState.action == mostro_action.Action.addBondInvoice + ? _buildBondClaimChip(context) + : _buildStatusChip(context, orderState.status), const SizedBox(width: 8), _buildRoleChip(context, isCreator), const Spacer(), @@ -160,6 +163,27 @@ class TradesListItem extends ConsumerWidget { ); } + /// Chip shown when mostrod is awaiting a bond-payout invoice from the user. + /// Takes precedence over the trade status chip because the actionable + /// thing for the user is to claim, not to look at the closed trade. + Widget _buildBondClaimChip(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: AppTheme.statusWaitingBackground.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(12), + ), + child: Text( + S.of(context)!.addBondInvoiceTitle, + style: const TextStyle( + color: AppTheme.statusWaitingText, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ); + } + Widget _buildRoleChip(BuildContext context, bool isCreator) { return Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), diff --git a/lib/l10n/intl_de.arb b/lib/l10n/intl_de.arb index c2cb221c5..a82def6fb 100644 --- a/lib/l10n/intl_de.arb +++ b/lib/l10n/intl_de.arb @@ -710,6 +710,44 @@ "statusWaitingTakerBond": "Awaiting deposit payment", "payBondMessage": "Please pay the anti-abuse deposit to continue this order.", "payBondButton": "Pay deposit", + "addBondInvoiceTitle": "Claim your share", + "addBondInvoiceMessage": "You won the dispute. Submit a Lightning invoice to claim your share of the slashed bond.", + "addBondInvoiceWonLine": "You won the dispute for order {order_id}.", + "@addBondInvoiceWonLine": { + "placeholders": { + "order_id": { + "type": "String" + } + } + }, + "addBondInvoiceSubmitLine": "Send a Lightning invoice for {amount} sats to claim your share of the deposit collected from your counterparty.", + "@addBondInvoiceSubmitLine": { + "placeholders": { + "amount": { + "type": "Object" + } + } + }, + "addBondInvoiceDeadline": "You must claim it before {date}.", + "@addBondInvoiceDeadline": { + "placeholders": { + "date": { + "type": "String" + } + } + }, + "addBondInvoiceInputLabel": "Lightning invoice", + "addBondInvoiceInputHint": "Paste your bolt11 invoice here", + "addBondInvoiceButton": "CLAIM YOUR SHARE", + "addBondInvoiceSubmitButton": "SUBMIT INVOICE", + "addBondInvoiceFailedToSubmit": "Failed to submit invoice: {error}", + "@addBondInvoiceFailedToSubmit": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "failedToCancelOrder": "Fehler beim Abbrechen der Order: {error}", "@failedToCancelOrder": { "placeholders": { diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index 8acc81ca8..d6eb7622d 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -710,6 +710,44 @@ "statusWaitingTakerBond": "Awaiting deposit payment", "payBondMessage": "Please pay the anti-abuse deposit to continue this order.", "payBondButton": "Pay deposit", + "addBondInvoiceTitle": "Claim your share", + "addBondInvoiceMessage": "You won the dispute. Submit a Lightning invoice to claim your share of the slashed bond.", + "addBondInvoiceWonLine": "You won the dispute for order {order_id}.", + "@addBondInvoiceWonLine": { + "placeholders": { + "order_id": { + "type": "String" + } + } + }, + "addBondInvoiceSubmitLine": "Send a Lightning invoice for {amount} sats to claim your share of the deposit collected from your counterparty.", + "@addBondInvoiceSubmitLine": { + "placeholders": { + "amount": { + "type": "Object" + } + } + }, + "addBondInvoiceDeadline": "You must claim it before {date}.", + "@addBondInvoiceDeadline": { + "placeholders": { + "date": { + "type": "String" + } + } + }, + "addBondInvoiceInputLabel": "Lightning invoice", + "addBondInvoiceInputHint": "Paste your bolt11 invoice here", + "addBondInvoiceButton": "CLAIM YOUR SHARE", + "addBondInvoiceSubmitButton": "SUBMIT INVOICE", + "addBondInvoiceFailedToSubmit": "Failed to submit invoice: {error}", + "@addBondInvoiceFailedToSubmit": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "failedToCancelOrder": "Failed to cancel order: {error}", "@failedToCancelOrder": { "placeholders": { diff --git a/lib/l10n/intl_es.arb b/lib/l10n/intl_es.arb index 0322c51bf..1d05348fc 100644 --- a/lib/l10n/intl_es.arb +++ b/lib/l10n/intl_es.arb @@ -623,6 +623,44 @@ "statusWaitingTakerBond": "Esperando pago del depósito", "payBondMessage": "Por favor paga el depósito anti-abuso para continuar con esta orden.", "payBondButton": "Pagar depósito", + "addBondInvoiceTitle": "Cobra tu parte", + "addBondInvoiceMessage": "Ganaste la disputa. Envía una factura Lightning para cobrar tu parte del depósito anti-abuso confiscado.", + "addBondInvoiceWonLine": "Ganaste la disputa por la orden {order_id}.", + "@addBondInvoiceWonLine": { + "placeholders": { + "order_id": { + "type": "String" + } + } + }, + "addBondInvoiceSubmitLine": "Envía una factura Lightning por {amount} sats para cobrar la parte que te corresponde del depósito cobrado a tu contraparte.", + "@addBondInvoiceSubmitLine": { + "placeholders": { + "amount": { + "type": "Object" + } + } + }, + "addBondInvoiceDeadline": "Debes reclamarlo antes del {date}.", + "@addBondInvoiceDeadline": { + "placeholders": { + "date": { + "type": "String" + } + } + }, + "addBondInvoiceInputLabel": "Factura Lightning", + "addBondInvoiceInputHint": "Pega aquí tu factura bolt11", + "addBondInvoiceButton": "COBRAR", + "addBondInvoiceSubmitButton": "ENVIAR FACTURA", + "addBondInvoiceFailedToSubmit": "Error al enviar la factura: {error}", + "@addBondInvoiceFailedToSubmit": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "failedToCancelOrder": "Error al cancelar orden: {error}", "@failedToCancelOrder": { "placeholders": { diff --git a/lib/l10n/intl_fr.arb b/lib/l10n/intl_fr.arb index 8a42dc8cc..c25d4a855 100644 --- a/lib/l10n/intl_fr.arb +++ b/lib/l10n/intl_fr.arb @@ -710,6 +710,44 @@ "statusWaitingTakerBond": "Awaiting deposit payment", "payBondMessage": "Please pay the anti-abuse deposit to continue this order.", "payBondButton": "Pay deposit", + "addBondInvoiceTitle": "Claim your share", + "addBondInvoiceMessage": "You won the dispute. Submit a Lightning invoice to claim your share of the slashed bond.", + "addBondInvoiceWonLine": "You won the dispute for order {order_id}.", + "@addBondInvoiceWonLine": { + "placeholders": { + "order_id": { + "type": "String" + } + } + }, + "addBondInvoiceSubmitLine": "Send a Lightning invoice for {amount} sats to claim your share of the deposit collected from your counterparty.", + "@addBondInvoiceSubmitLine": { + "placeholders": { + "amount": { + "type": "Object" + } + } + }, + "addBondInvoiceDeadline": "You must claim it before {date}.", + "@addBondInvoiceDeadline": { + "placeholders": { + "date": { + "type": "String" + } + } + }, + "addBondInvoiceInputLabel": "Lightning invoice", + "addBondInvoiceInputHint": "Paste your bolt11 invoice here", + "addBondInvoiceButton": "CLAIM YOUR SHARE", + "addBondInvoiceSubmitButton": "SUBMIT INVOICE", + "addBondInvoiceFailedToSubmit": "Failed to submit invoice: {error}", + "@addBondInvoiceFailedToSubmit": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "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 89442802e..8194999b3 100644 --- a/lib/l10n/intl_it.arb +++ b/lib/l10n/intl_it.arb @@ -653,6 +653,44 @@ "statusWaitingTakerBond": "In attesa del pagamento del deposito", "payBondMessage": "Per favore paga il deposito anti-abuso per continuare questo ordine.", "payBondButton": "Paga deposito", + "addBondInvoiceTitle": "Riscuoti la tua parte", + "addBondInvoiceMessage": "Hai vinto la disputa. Invia una fattura Lightning per riscuotere la tua parte del deposito confiscato.", + "addBondInvoiceWonLine": "Hai vinto la disputa per l'ordine {order_id}.", + "@addBondInvoiceWonLine": { + "placeholders": { + "order_id": { + "type": "String" + } + } + }, + "addBondInvoiceSubmitLine": "Invia una fattura Lightning di {amount} sats per riscuotere la tua parte del deposito confiscato alla controparte.", + "@addBondInvoiceSubmitLine": { + "placeholders": { + "amount": { + "type": "Object" + } + } + }, + "addBondInvoiceDeadline": "Devi riscuoterlo entro il {date}.", + "@addBondInvoiceDeadline": { + "placeholders": { + "date": { + "type": "String" + } + } + }, + "addBondInvoiceInputLabel": "Fattura Lightning", + "addBondInvoiceInputHint": "Incolla qui la tua fattura bolt11", + "addBondInvoiceButton": "RISCUOTI", + "addBondInvoiceSubmitButton": "INVIA FATTURA", + "addBondInvoiceFailedToSubmit": "Errore nell'invio della fattura: {error}", + "@addBondInvoiceFailedToSubmit": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "failedToCancelOrder": "Impossibile annullare l'ordine: {error}", "@failedToCancelOrder": { "placeholders": { diff --git a/lib/services/mostro_service.dart b/lib/services/mostro_service.dart index 5c4e28a92..312e8efca 100644 --- a/lib/services/mostro_service.dart +++ b/lib/services/mostro_service.dart @@ -153,6 +153,7 @@ class MostroService { } final msg = MostroMessage.fromJson(result[0]); + msg.timestamp ??= decryptedEvent.createdAt?.millisecondsSinceEpoch; final messageStorage = ref.read(mostroStorageProvider); @@ -236,6 +237,19 @@ class MostroService { ); } + Future sendBondInvoice( + String orderId, String invoice, int? amount) async { + final payload = PaymentRequest( + order: null, + lnInvoice: invoice, + amount: amount, + ); + await publishOrder( + MostroMessage( + action: Action.addBondInvoice, id: orderId, payload: payload), + ); + } + Future cancelOrder(String orderId) async { await publishOrder(MostroMessage(action: Action.cancel, id: orderId)); } diff --git a/lib/shared/providers/mostro_storage_provider.dart b/lib/shared/providers/mostro_storage_provider.dart index 1fbb4b1bc..5dbeb7d50 100644 --- a/lib/shared/providers/mostro_storage_provider.dart +++ b/lib/shared/providers/mostro_storage_provider.dart @@ -1,6 +1,5 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:mostro_mobile/data/models/mostro_message.dart'; -import 'package:mostro_mobile/data/models/order.dart'; +import 'package:mostro_mobile/data/models.dart'; import 'package:mostro_mobile/data/repositories/mostro_storage.dart'; import 'package:mostro_mobile/shared/providers/mostro_database_provider.dart'; @@ -28,3 +27,11 @@ final mostroOrderStreamProvider = final storage = ref.read(mostroStorageProvider); return storage.watchLatestMessageOfType(orderId); }); + +/// Latest [Action.addBondInvoice] message for an order. The generic order +/// stream filters for `Order` payload and wouldn't match this variant. +final mostroBondPayoutRequestStreamProvider = + StreamProvider.family((ref, orderId) { + final storage = ref.read(mostroStorageProvider); + return storage.watchLatestMessageOfType(orderId); +}); diff --git a/test/data/models/bond_payout_request_test.dart b/test/data/models/bond_payout_request_test.dart new file mode 100644 index 000000000..a85a00c23 --- /dev/null +++ b/test/data/models/bond_payout_request_test.dart @@ -0,0 +1,96 @@ +import 'package:mostro_mobile/data/models/bond_payout_request.dart'; +import 'package:mostro_mobile/data/models/order.dart'; +import 'package:mostro_mobile/data/models/payload.dart'; +import 'package:test/test.dart'; + +void main() { + group('BondPayoutRequest.fromJson', () { + test('parses real wire payload from mostrod', () { + // Real wire payload mostrod (mostro-core 0.11.3) ships with + // Action.addBondInvoice. The nested order's status is intentionally + // null (the field is not meaningful in the bond-payout request + // context; Order.fromJson defaults to Status.pending). + final json = { + 'order': { + 'id': '1d554f35-3121-47ef-8779-834d6d91a24d', + 'kind': 'buy', + 'status': null, + 'amount': 5, + 'fiat_code': 'CUP', + 'min_amount': null, + 'max_amount': null, + 'fiat_amount': 200, + 'payment_method': 'Saldo móvil', + 'premium': 0, + 'created_at': null, + 'expires_at': null, + }, + 'slashed_at': 1778867884, + }; + + final payload = BondPayoutRequest.fromJson(json); + + expect(payload.order.id, '1d554f35-3121-47ef-8779-834d6d91a24d'); + expect(payload.order.amount, 5); + expect(payload.order.fiatCode, 'CUP'); + expect(payload.slashedAt, 1778867884); + }); + + test('Payload.fromJson dispatches bond_payout_request to BondPayoutRequest', + () { + // Top-level dispatch shape: { "bond_payout_request": { order, slashed_at } } + final wirePayload = { + 'bond_payout_request': { + 'order': { + 'id': 'aaaa', + 'kind': 'sell', + 'status': null, + 'amount': 100, + 'fiat_code': 'USD', + 'fiat_amount': 50, + 'payment_method': 'cash', + 'premium': 0, + }, + 'slashed_at': 1700000000, + }, + }; + + final result = Payload.fromJson(wirePayload); + + expect(result, isA()); + final bpr = result as BondPayoutRequest; + expect(bpr.slashedAt, 1700000000); + expect(bpr.order.amount, 100); + }); + + test('round-trip toJson / fromJson preserves data', () { + // Order does not override == (identity equality only), so compare + // field-by-field. The wire shape must survive the round-trip cleanly. + final original = BondPayoutRequest( + order: Order.fromJson({ + 'id': 'bbbb', + 'kind': 'buy', + 'status': null, + 'amount': 42, + 'fiat_code': 'EUR', + 'fiat_amount': 10, + 'payment_method': 'sepa', + 'premium': 0, + }), + slashedAt: 1700000000, + ); + + final json = original.toJson(); + final round = BondPayoutRequest.fromJson( + json['bond_payout_request'] as Map); + + expect(round.slashedAt, original.slashedAt); + expect(round.order.id, original.order.id); + expect(round.order.kind, original.order.kind); + expect(round.order.amount, original.order.amount); + expect(round.order.fiatCode, original.order.fiatCode); + expect(round.order.fiatAmount, original.order.fiatAmount); + expect(round.order.paymentMethod, original.order.paymentMethod); + }); + }); +} diff --git a/test/data/models/enums/action_test.dart b/test/data/models/enums/action_test.dart new file mode 100644 index 000000000..e984af24b --- /dev/null +++ b/test/data/models/enums/action_test.dart @@ -0,0 +1,16 @@ +import 'package:mostro_mobile/data/models/enums/action.dart'; +import 'package:test/test.dart'; + +void main() { + group('Action enum', () { + test('addBondInvoice wire string is "add-bond-invoice"', () { + expect(Action.addBondInvoice.value, equals('add-bond-invoice')); + }); + + test('Action.fromString decodes bond actions without throwing', () { + // Missing mappings crash the app when mostrod sends these. + expect(Action.fromString('add-bond-invoice'), Action.addBondInvoice); + expect(Action.fromString('pay-bond-invoice'), Action.payBondInvoice); + }); + }); +} diff --git a/test/features/mostro/mostro_instance_test.dart b/test/features/mostro/mostro_instance_test.dart new file mode 100644 index 000000000..9ec29e3af --- /dev/null +++ b/test/features/mostro/mostro_instance_test.dart @@ -0,0 +1,33 @@ +import 'package:dart_nostr/nostr/model/event/event.dart'; +import 'package:mostro_mobile/features/mostro/mostro_instance.dart'; +import 'package:test/test.dart'; + +NostrEvent _eventWithTags(List> tags) => NostrEvent( + id: 'a' * 64, + kind: 38385, + content: '', + sig: 'b' * 128, + pubkey: 'c' * 64, + createdAt: DateTime(2026), + tags: tags, + ); + +void main() { + group('MostroInstanceExtensions.bondPayoutClaimWindowDays', () { + test('returns parsed int when tag is present', () { + final event = _eventWithTags([ + ['bond_enabled', 'true'], + ['bond_payout_claim_window_days', '15'], + ]); + expect(event.bondPayoutClaimWindowDays, 15); + }); + + test('returns null when tag is absent (older daemon)', () { + // Pre-Phase-3 daemons omit all bond_* tags entirely. Must not throw. + final event = _eventWithTags([ + ['mostro_version', '0.13.0'], + ]); + expect(event.bondPayoutClaimWindowDays, isNull); + }); + }); +} diff --git a/test/models/order_test.dart b/test/models/order_test.dart index babe6f761..8b833b2db 100644 --- a/test/models/order_test.dart +++ b/test/models/order_test.dart @@ -47,5 +47,33 @@ void main() { expect(order.paymentMethod, equals('face to face')); expect(order.premium, equals(1)); }); + + test('Parse add-bond-invoice order with null status', () { + // mostrod sends status, created_at, expires_at as null in this payload; + // only fiat context and amount carry meaning here. + final orderData = { + 'id': '1d554f35-3121-47ef-8779-834d6d91a24d', + 'kind': 'sell', + 'status': null, + 'amount': 5, + 'fiat_code': 'CUP', + 'min_amount': null, + 'max_amount': null, + 'fiat_amount': 200, + 'payment_method': 'Saldo móvil', + 'premium': 0, + 'created_at': null, + 'expires_at': null, + }; + + final order = Order.fromJson(orderData); + + expect(order.kind, equals(OrderType.sell)); + expect(order.status, equals(Status.pending)); // safe default + expect(order.amount, equals(5)); + expect(order.fiatCode, equals('CUP')); + expect(order.fiatAmount, equals(200)); + expect(order.paymentMethod, equals('Saldo móvil')); + }); }); }