diff --git a/lib/features/order/models/order_state.dart b/lib/features/order/models/order_state.dart index 0482cf2d1..0cd5e4cb1 100644 --- a/lib/features/order/models/order_state.dart +++ b/lib/features/order/models/order_state.dart @@ -92,12 +92,15 @@ class OrderState { bool? fiatWasSent, UserInfo? peerReputation, bool clearPeerReputation = false, + bool clearPaymentRequest = false, }) { return OrderState( status: status ?? this.status, action: action ?? this.action, order: order ?? this.order, - paymentRequest: paymentRequest ?? this.paymentRequest, + paymentRequest: clearPaymentRequest + ? null + : paymentRequest ?? this.paymentRequest, cantDo: cantDo ?? this.cantDo, dispute: dispute ?? this.dispute, peer: peer ?? this.peer, @@ -109,6 +112,41 @@ class OrderState { ); } + /// Statuses that close the current take cycle: the order either went back + /// to the book or ended for good. + /// + /// Everything Mostro issued for that cycle — bond and escrow hold invoices + /// above all — is cancelled node-side when this happens, so no payload from + /// it may survive into the next take of the same order id. + /// + /// [Status.cooperativelyCanceled] is deliberately absent: here it is the + /// *pending* cooperative cancel (every `cooperative-cancel-initiated-*` + /// action maps to it) and the trade can still reach fiat-sent. The cancel + /// only lands with `cooperative-cancel-accepted`, which maps to + /// [Status.canceled]. + static bool endsTradeCycle(Status status) => + status == Status.pending || + status == Status.canceled || + status == Status.canceledByAdmin || + status == Status.expired; + + /// Actions that can only be the first message of a take cycle. + /// + /// A message that ends up rejected by the stale-transition guard is either a + /// late copy or the start of the *next* take of the same order id; the action + /// is half of what tells them apart (the other half is its event time, see + /// `AbstractMostroNotifier.applyToCycle`). `add-invoice` also appears in the + /// payout-retry flow, but that runs from `payment-failed`, never from a + /// status that ends a cycle, so it cannot be confused with a take here. + static bool opensTakeCycle(Action action) => + action == Action.takeBuy || + action == Action.takeSell || + action == Action.payBondInvoice || + action == Action.payInvoice || + action == Action.addInvoice || + action == Action.waitingSellerToPay || + action == Action.waitingBuyerInvoice; + /// Dispute statuses in which the dispute is over: a resolution has already /// been applied and no further admin action is expected for it. static const _terminalDisputeStatuses = { @@ -210,25 +248,16 @@ class OrderState { message.action == Action.fiatSent || message.action == Action.fiatSentOk; - // Remap generic cooperative cancel actions to semantic variants - // based on whether fiat was sent before the cancel was initiated - Action effectiveAction = message.action; - if (message.action == Action.cooperativeCancelInitiatedByYou) { - effectiveAction = newFiatWasSent - ? Action.cooperativeCancelFiatSentByYou - : Action.cooperativeCancelNoFiatByYou; - logger.d('Remapped ${message.action} → $effectiveAction (fiatWasSent: $newFiatWasSent)'); - } else if (message.action == Action.cooperativeCancelInitiatedByPeer) { - effectiveAction = newFiatWasSent - ? Action.cooperativeCancelFiatSentByPeer - : Action.cooperativeCancelNoFiatByPeer; - logger.d('Remapped ${message.action} → $effectiveAction (fiatWasSent: $newFiatWasSent)'); + // Remap generic cooperative cancel actions to semantic variants, then + // derive the status — the same derivation `wouldRejectAsStale` consults. + final transition = _transitionFor(message, fiatSent: newFiatWasSent); + final Action effectiveAction = transition.action; + final Status newStatus = transition.status; + if (effectiveAction != message.action) { + logger.d( + 'Remapped ${message.action} → $effectiveAction (fiatWasSent: $newFiatWasSent)'); } - // Determine the new status based on the action received - Status newStatus = _getStatusFromAction( - effectiveAction, message.getPayload()?.status); - // DEBUG: Log status mapping logger.d('Status mapping: $effectiveAction → $newStatus'); @@ -256,11 +285,20 @@ class OrderState { logger.d('Order returned to pending: dropping the taker reputation'); } - // Preserve PaymentRequest correctly + // Preserve PaymentRequest correctly — but only within the take cycle it + // belongs to. Mostro cancels the bond and escrow hold invoices when the + // cycle ends, so carrying one into the next take renders a bolt11 that + // can no longer be paid (INCORRECT_PAYMENT_DETAILS). + final bool cycleEnded = endsTradeCycle(newStatus); PaymentRequest? newPaymentRequest; if (message.payload is PaymentRequest) { newPaymentRequest = message.getPayload(); logger.d('New PaymentRequest found in message'); + } else if (cycleEnded) { + newPaymentRequest = null; + if (paymentRequest != null) { + logger.d('Take cycle ended ($newStatus): dropping the stale invoice'); + } } else { newPaymentRequest = paymentRequest; // Preserve existing } @@ -400,6 +438,7 @@ class OrderState { ? message.getPayload()!.order : order, paymentRequest: newPaymentRequest, + clearPaymentRequest: newPaymentRequest == null, cantDo: message.getPayload() ?? cantDo, dispute: updatedDispute, peer: newPeer, @@ -460,6 +499,50 @@ class OrderState { return !isRepublish; } + /// The action and status [updateWith] would apply for [message]. + /// + /// The cooperative-cancel remap depends on whether fiat was sent, so it is + /// computed from this state unless the caller already knows the updated + /// value. Kept in one place so [wouldRejectAsStale] can never disagree with + /// what [updateWith] actually does. + ({Action action, Status status}) _transitionFor( + MostroMessage message, { + bool? fiatSent, + }) { + final sent = fiatSent ?? + (fiatWasSent || + message.action == Action.fiatSent || + message.action == Action.fiatSentOk); + + var action = message.action; + if (action == Action.cooperativeCancelInitiatedByYou) { + action = sent + ? Action.cooperativeCancelFiatSentByYou + : Action.cooperativeCancelNoFiatByYou; + } else if (action == Action.cooperativeCancelInitiatedByPeer) { + action = sent + ? Action.cooperativeCancelFiatSentByPeer + : Action.cooperativeCancelNoFiatByPeer; + } + + return ( + action: action, + status: _getStatusFromAction(action, message.getPayload()?.status), + ); + } + + /// Whether [message] would be dropped by the stale-transition guard. + /// + /// Lets callers replaying persisted history tell a genuinely late copy from + /// the first message of a *new* take cycle: after a cancel, every later + /// message looks backwards to the guard, because a cancelled order outranks + /// every waiting phase. It is only half of the answer — see + /// `AbstractMostroNotifier.applyToCycle` for the rest (#731). + bool wouldRejectAsStale(MostroMessage message) { + final transition = _transitionFor(message); + return isStaleTransition(transition.action, transition.status); + } + /// Maps actions to their corresponding statuses based on mostrod DM messages Status _getStatusFromAction(Action action, Status? payloadStatus) { switch (action) { diff --git a/lib/features/order/notifiers/abstract_mostro_notifier.dart b/lib/features/order/notifiers/abstract_mostro_notifier.dart index bb7e13011..2cdafeb74 100644 --- a/lib/features/order/notifiers/abstract_mostro_notifier.dart +++ b/lib/features/order/notifiers/abstract_mostro_notifier.dart @@ -41,6 +41,98 @@ class AbstractMostroNotifier extends StateNotifier { // uses the same Action.canceled. Consumed once per cancel in subscribe(). static final Set _userInitiatedCancels = {}; + /// Event time of the message that left this order in a cycle-ending status. + /// + /// `null` while the current cycle is still running. Maintained by + /// [applyToCycle], which is the only place that may restart a cycle. + int? cycleEndedAt; + + /// Event time of the message that opened the cycle this order is in. + /// + /// `null` until a cycle has been seen to open. Everything older than it + /// belongs to a cycle that is over — see [precedesActiveCycle]. + int? cycleStartedAt; + + static int _eventTimeOf(MostroMessage message) => + message.eventCreatedAt ?? message.timestamp ?? 0; + + /// Whether [message] belongs to a take cycle this order has already left. + /// + /// Such a message would be destructive: the stale guard waves a previous + /// cycle's `canceled` through — a cancelled order outranks every waiting + /// phase — so applying it would void the invoice the user is looking at, + /// delete the session and navigate away. Callers use this to drop the + /// message and its side effects, the way + /// [OrderState.rejectsAdminDisputeMessage] already does. + /// + /// This is a defence, not a case observed on the live stream: the orders + /// stream is `watchLatestMessage`, which emits only the newest stored + /// message by `compareByEventTime`, so an older cycle's `canceled` stored + /// after the new bond is never the latest and never reaches it (#732 + /// review). It still guards `sync()` and any future caller. + bool precedesActiveCycle(MostroMessage message) { + final startedAt = cycleStartedAt; + return startedAt != null && _eventTimeOf(message) < startedAt; + } + + /// Applies [message] to [current], restarting the take cycle first when the + /// message opens a *new* one rather than being a late copy of the old. + /// + /// After a cancel every later message looks backwards to the stale guard — + /// a cancelled order outranks every waiting phase — so the guard alone can + /// no longer tell "the order was taken again" (#731) from "a duplicate + /// arrived late" (#723). Restarting therefore needs positive evidence: + /// + /// 1. the action can only open a cycle ([OrderState.opensTakeCycle]), and + /// 2. its event time is *strictly* later than the message that ended the + /// previous cycle. `created_at` has one-second resolution and relays + /// replay newest-first, so a copy from the same second as the cancel is + /// a late copy: Mostro cannot cancel a take and accept the next one + /// within the same second. + /// + /// Messages from before the current cycle opened are dropped outright + /// ([precedesActiveCycle]). + OrderState applyToCycle(OrderState current, MostroMessage message) { + if (precedesActiveCycle(message)) { + logger.w( + 'Ignoring ${message.action} for order $orderId: it belongs to a take ' + 'cycle that ended at $cycleStartedAt'); + return current; + } + + final eventTime = _eventTimeOf(message); + final endedAt = cycleEndedAt; + var restarted = false; + + if (endedAt != null && + eventTime > endedAt && + OrderState.endsTradeCycle(current.status) && + OrderState.opensTakeCycle(message.action) && + current.wouldRejectAsStale(message)) { + logger.i( + 'Order $orderId was taken again: replaying ${message.action} as a new cycle'); + current = OrderState( + status: Status.pending, + action: Action.newOrder, + order: current.order, + ); + restarted = true; + } + + final cameFromEndedCycle = OrderState.endsTradeCycle(current.status); + final next = current.updateWith(message); + + // Recorded from the resulting status rather than from the transition: a + // replay starts from the current state, so the message that ended the + // cycle can be applied onto an already-ended one. + final ended = OrderState.endsTradeCycle(next.status); + cycleEndedAt = ended ? eventTime : null; + if (!ended && (restarted || cameFromEndedCycle)) { + cycleStartedAt = eventTime; + } + return next; + } + /// Marks an order as cancelled by the user, so the matching `canceled` /// response is treated as a voluntary cancel (immediate session deletion) /// and notified as user-initiated rather than a counterparty timeout. @@ -119,6 +211,18 @@ class AbstractMostroNotifier extends StateNotifier { return; } + // Same reasoning for a message the current cycle has + // outlived: applying it would void the new bond invoice, and + // notifying or navigating on it would send the user out of a + // trade that is running (#731). Kept as a defence — the stream + // emits only the latest stored message, so it should not be + // able to hand one over. + if (precedesActiveCycle(msg)) { + logger.w( + 'Dropping ${msg.action} for order $orderId: it predates the current take cycle'); + return; + } + // Cancel timer on ANY response from Mostro for this order cancelSessionTimeoutCleanup(orderId); @@ -134,7 +238,7 @@ class AbstractMostroNotifier extends StateNotifier { _userInitiatedCancels.remove(orderId); if (mounted) { - state = state.updateWith(msg); + state = applyToCycle(state, msg); } if (msg.timestamp != null && diff --git a/lib/features/order/notifiers/order_notifier.dart b/lib/features/order/notifiers/order_notifier.dart index bce7b2f49..a747cb067 100644 --- a/lib/features/order/notifiers/order_notifier.dart +++ b/lib/features/order/notifiers/order_notifier.dart @@ -91,10 +91,12 @@ class OrderNotifier extends AbstractMostroNotifier { OrderState currentState = state; + // The replay rebuilds the cycle bookkeeping from the history itself. + cycleEndedAt = null; + cycleStartedAt = null; for (final message in messages) { - if (message.action != Action.cantDo) { - currentState = currentState.updateWith(message); - } + if (message.action == Action.cantDo) continue; + currentState = applyToCycle(currentState, message); } // A replay that lands on the same values notifies nobody: @@ -273,9 +275,16 @@ class OrderNotifier extends AbstractMostroNotifier { } /// Update state from MostroMessage (used during restore) + /// + /// Goes through [applyToCycle] like `sync()` and the live stream do. + /// RestoreManager calls this for every restored order, cancelled and expired + /// included; writing the state directly left `cycleEndedAt` null, so the + /// next take of that order could not restart the cycle and its bond invoice + /// was dropped as stale — #731 again, through the one write that skipped the + /// rule (#732 review). void updateStateFromMessage(MostroMessage message) { if (mounted) { - state = state.updateWith(message); + state = applyToCycle(state, message); } } diff --git a/lib/features/order/screens/pay_bond_invoice_screen.dart b/lib/features/order/screens/pay_bond_invoice_screen.dart index 29358f085..bd440cfa0 100644 --- a/lib/features/order/screens/pay_bond_invoice_screen.dart +++ b/lib/features/order/screens/pay_bond_invoice_screen.dart @@ -6,6 +6,8 @@ import 'package:qr_flutter/qr_flutter.dart'; import 'package:share_plus/share_plus.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:mostro_mobile/core/app_theme.dart'; +import 'package:mostro_mobile/data/enums.dart' as enums; +import 'package:mostro_mobile/features/order/models/order_state.dart'; import 'package:mostro_mobile/features/order/providers/order_notifier_provider.dart'; import 'package:mostro_mobile/features/order/widgets/order_app_bar.dart'; import 'package:mostro_mobile/generated/l10n.dart'; @@ -123,17 +125,102 @@ class PayBondInvoiceScreen extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final s = S.of(context)!; final orderState = ref.watch(orderNotifierProvider(orderId)); - final lnInvoice = orderState.paymentRequest?.lnInvoice ?? ''; - final bondAmount = orderState.paymentRequest?.order?.amount; + // Only the invoice of the bond phase belongs on this screen. The order + // notifier is keyed by order id, so an escrow invoice from an earlier + // take of the same order could otherwise be rendered here as a bond — + // long after Mostro cancelled it node-side (#731). + final isBondPhase = orderState.action == enums.Action.payBondInvoice || + orderState.status == enums.Status.waitingTakerBond; + final lnInvoice = + isBondPhase ? orderState.paymentRequest?.lnInvoice ?? '' : ''; + final bondAmount = + isBondPhase ? orderState.paymentRequest?.order?.amount : null; // A maker creating an order pays the bond before it is published, so the // copy must warn them to keep the screen open or the order won't be created. - final isMakerBond = ref - .read(sessionNotifierProvider.notifier) - .getSessionByOrderId(orderId) - ?.bondPending ?? - false; + final session = + ref.read(sessionNotifierProvider.notifier).getSessionByOrderId(orderId); + final isMakerBond = session?.bondPending ?? false; final explanation = isMakerBond ? s.bondExplanationMaker : s.bondExplanation; + if (lnInvoice.isEmpty) { + // No invoice to show — and the reason decides what to tell the user. + // Saying "expired, take the order again" in every case is what could + // make a maker abandon a bond that was merely still loading. + final cycleEnded = + OrderState.endsTradeCycle(orderState.status) && + orderState.status != enums.Status.pending; + // `pending` is not enough on its own. It is the notifier's unhydrated + // initial state, the state of a maker awaiting their bond invoice, the + // state of a maker's order that is paid and live in the book, and the + // state a taker finds after their take timed out and Mostro republished + // the order. This screen is reachable from a notification card at any + // time, so any of them can show up here (#732 review). Three signals + // separate them: an absent order payload means nothing has hydrated + // yet, `bondPending` means a maker bond is actually awaited, and a + // session for this order means the order is the user's own — Mostro + // deletes the taker's session when it republishes. + final notHydrated = orderState.order == null; + final stillLoading = !cycleEnded && + (orderState.status == enums.Status.waitingTakerBond || + notHydrated || + (orderState.status == enums.Status.pending && isMakerBond)); + + if (stillLoading) { + return _EmptyBondState( + title: s.bondScreenTitle, + icon: null, + message: s.bondInvoicePending, + ); + } + + if (cycleEnded) { + return _EmptyBondState( + title: s.bondScreenTitle, + icon: Icons.hourglass_disabled, + // A maker cannot take their own order, so "take the order again" + // only makes sense to a taker. + message: isMakerBond + ? s.bondOrderNoLongerActive + : s.bondInvoiceUnavailable, + // The copy says "go back", so go back when there is a stack to pop. + actionLabel: s.close, + onAction: (context) => + context.canPop() ? context.pop() : context.go('/'), + ); + } + + // Hydrated and still pending, with no bond awaited: either the maker's + // own order, published and paid, or a taker whose take ended and whose + // session Mostro deleted when it put the order back in the book. + if (orderState.status == enums.Status.pending) { + return session != null + ? _EmptyBondState( + title: s.bondScreenTitle, + icon: Icons.check_circle_outline, + message: s.bondOrderPublished, + actionLabel: s.goToTrade, + onAction: (context) => context.go('/trade_detail/$orderId'), + ) + : _EmptyBondState( + title: s.bondScreenTitle, + icon: Icons.hourglass_disabled, + message: s.bondInvoiceUnavailable, + actionLabel: s.close, + onAction: (context) => + context.canPop() ? context.pop() : context.go('/'), + ); + } + + // Past the bond phase: the bond is paid and the trade moved on. + return _EmptyBondState( + title: s.bondScreenTitle, + icon: Icons.check_circle_outline, + message: s.bondAlreadyPaid, + actionLabel: s.goToTrade, + onAction: (context) => context.go('/trade_detail/$orderId'), + ); + } + return Scaffold( backgroundColor: AppTheme.dark1, appBar: OrderAppBar(title: s.bondScreenTitle), @@ -242,3 +329,60 @@ class PayBondInvoiceScreen extends ConsumerWidget { ); } } + +/// The bond screen with no invoice to show: one message, at most one action. +class _EmptyBondState extends StatelessWidget { + final String title; + final IconData? icon; + final String message; + final String? actionLabel; + final void Function(BuildContext context)? onAction; + + const _EmptyBondState({ + required this.title, + required this.icon, + required this.message, + this.actionLabel, + this.onAction, + }); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppTheme.dark1, + appBar: OrderAppBar(title: title), + body: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (icon != null) + Icon(icon, color: AppTheme.textSecondary, size: 48) + else + const CircularProgressIndicator(color: AppTheme.mostroGreen), + const SizedBox(height: 16), + Text( + message, + textAlign: TextAlign.center, + style: const TextStyle( + color: AppTheme.cream1, + fontSize: 15, + height: 1.4, + ), + ), + if (actionLabel != null && onAction != null) ...[ + const SizedBox(height: 24), + ElevatedButton( + onPressed: () => onAction!(context), + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.mostroGreen, + ), + child: Text(actionLabel!), + ), + ], + ], + ), + ), + ); + } +} diff --git a/lib/features/restore/restore_manager.dart b/lib/features/restore/restore_manager.dart index bb1463d33..b03577370 100644 --- a/lib/features/restore/restore_manager.dart +++ b/lib/features/restore/restore_manager.dart @@ -773,9 +773,8 @@ class RestoreService { disputeId: restoredDispute.disputeId, orderId: restoredDispute.orderId, status: restoredDispute.status, - createdAt: orderDetail.createdAt != null - ? DateTime.fromMillisecondsSinceEpoch(orderDetail.createdAt!) - : DateTime.now(), + createdAt: DateTime.fromMillisecondsSinceEpoch( + restoreCreatedAtMillis(orderDetail.createdAt)), action: userInitiated ? 'dispute-initiated-by-you' : 'dispute-initiated-by-peer', @@ -819,9 +818,7 @@ class RestoreService { id: orderDetail.id, action: action, payload: dispute, - timestamp: - orderDetail.createdAt ?? - DateTime.now().millisecondsSinceEpoch, + timestamp: restoreCreatedAtMillis(orderDetail.createdAt), ); // Save dispute message to storage @@ -861,9 +858,7 @@ class RestoreService { id: orderDetail.id, action: action, payload: order, - timestamp: - orderDetail.createdAt ?? - DateTime.now().millisecondsSinceEpoch, + timestamp: restoreCreatedAtMillis(orderDetail.createdAt), ); // Save order message to storage @@ -1152,6 +1147,20 @@ class RestoreService { } } +/// Converts mostrod's `created_at` to the millisecond scale this app stores. +/// +/// The daemon reports it in seconds (`Timestamp::now().as_secs()` in +/// `mostro/src/flow.rs`), while [MostroMessage.timestamp] and +/// [MostroMessage.eventCreatedAt] are both milliseconds. Feeding the raw value +/// in put restored messages ~1000x in the past, which mattered once restored +/// messages started driving the take-cycle bookkeeping (#732 review), and it +/// also dated restored disputes to January 1970. +@visibleForTesting +int restoreCreatedAtMillis(int? createdAtSeconds) => + createdAtSeconds != null + ? createdAtSeconds * Duration.millisecondsPerSecond + : DateTime.now().millisecondsSinceEpoch; + /// Decodes a restore response into its message map, handling both transports: /// v2 (kind 14, NIP-44 direct, decrypted straight to the tuple) and v1 /// (kind 1059, gift wrap unwrapped to a rumor whose content is the tuple). Both diff --git a/lib/l10n/intl_de.arb b/lib/l10n/intl_de.arb index f7625814e..1c65383f0 100644 --- a/lib/l10n/intl_de.arb +++ b/lib/l10n/intl_de.arb @@ -843,6 +843,12 @@ "copy": "Kopieren", "share": "Teilen", "failedToShareInvoice": "Teilen der Rechnung fehlgeschlagen. Bitte versuche stattdessen, sie zu kopieren.", + "bondInvoicePending": "Warte auf die Kautions-Rechnung …", + "bondAlreadyPaid": "Diese Kaution ist bereits bezahlt. Dein Handel läuft schon.", + "goToTrade": "ZUM HANDEL", + "bondInvoiceUnavailable": "Diese Kautions-Rechnung ist nicht mehr gültig. Gehe zurück und nimm die Order erneut an, um eine neue zu erhalten.", + "bondOrderPublished": "Deine Order ist im Orderbuch veröffentlicht und ihre Kaution ist bereits bezahlt.", + "bondOrderNoLongerActive": "Diese Order ist nicht mehr aktiv, daher ist ihre Kautions-Rechnung nicht mehr gültig.", "bondScreenTitle": "Anti-Missbrauchs-Kaution", "bondExplanation": "Um diese Order anzunehmen, musst du eine Anti-Missbrauchs-Kaution zahlen. So funktioniert es:\n\n• Deine Sats werden in deiner Wallet gehalten, sie werden nicht ausgegeben.\n• Wenn der Tausch erfolgreich abgeschlossen wird, erhältst du deine Kaution automatisch zurück.\n• Wenn du bei dieser Order einen Streit hast und ihn verlierst, verlierst du die Kaution.\n• Dieser Mechanismus schützt alle Nutzer vor Betrügern.", "bondExplanationMaker": "Bevor deine Order erstellt werden kann, musst du eine Anti-Missbrauchs-Kaution zahlen. Lass diesen Bildschirm geöffnet, bis die Zahlung abgeschlossen ist – wenn du ihn schließt oder nicht zahlst, wird die Order nicht erstellt. So funktioniert es:\n\n• Deine Sats werden in deiner Wallet gehalten, sie werden nicht ausgegeben.\n• Wenn der Tausch erfolgreich abgeschlossen wird, erhältst du deine Kaution automatisch zurück.\n• Wenn du bei dieser Order einen Streit hast und ihn verlierst, verlierst du die Kaution.\n• Dieser Mechanismus schützt alle Nutzer vor Betrügern.", diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index 413f07e31..252fd4a2e 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -843,6 +843,12 @@ "copy": "Copy", "share": "Share", "failedToShareInvoice": "Failed to share invoice. Please try copying instead.", + "bondInvoicePending": "Waiting for the deposit invoice…", + "bondAlreadyPaid": "This deposit has already been paid. Your trade is already in progress.", + "goToTrade": "GO TO TRADE", + "bondInvoiceUnavailable": "This bond invoice is no longer valid. Go back and take the order again to get a new one.", + "bondOrderPublished": "Your order is published in the order book and its deposit has already been paid.", + "bondOrderNoLongerActive": "This order is no longer active, so its deposit invoice is no longer valid.", "bondScreenTitle": "Anti-abuse deposit", "bondExplanation": "To take this order you must pay an anti-abuse deposit. Here's how it works:\n\n• Your sats are held in your wallet, they are not spent.\n• If the exchange completes successfully, you get your deposit back automatically.\n• If you have a dispute on this order and lose it, you will lose the deposit.\n• This mechanism protects all users against scammers.", "bondExplanationMaker": "Before your order can be created, you must pay an anti-abuse deposit. Keep this screen open until the payment is done — if you close it or don't pay, the order won't be created. Here's how it works:\n\n• Your sats are held in your wallet, they are not spent.\n• If the exchange completes successfully, you get your deposit back automatically.\n• If you have a dispute on this order and lose it, you will lose the deposit.\n• This mechanism protects all users against scammers.", diff --git a/lib/l10n/intl_es.arb b/lib/l10n/intl_es.arb index 4002d018e..02ebaae37 100644 --- a/lib/l10n/intl_es.arb +++ b/lib/l10n/intl_es.arb @@ -693,6 +693,12 @@ "copy": "Copiar", "share": "Compartir", "failedToShareInvoice": "Error al compartir factura. Por favor intenta copiarla en su lugar.", + "bondInvoicePending": "Esperando la factura del depósito…", + "bondAlreadyPaid": "Este depósito ya está pagado. Tu intercambio ya está en marcha.", + "goToTrade": "IR AL INTERCAMBIO", + "bondInvoiceUnavailable": "Esta factura de depósito ya no es válida. Vuelve atrás y toma la orden de nuevo para obtener una nueva.", + "bondOrderPublished": "Tu orden está publicada en el libro de órdenes y su depósito ya está pagado.", + "bondOrderNoLongerActive": "Esta orden ya no está activa, así que su factura de depósito ya no es válida.", "bondScreenTitle": "Depósito anti-abuso", "bondExplanation": "Para tomar esta orden debes pagar un depósito anti-abuso. Funciona así:\n\n• Tus sats quedan retenidos en tu wallet, no se gastan.\n• Si el intercambio finaliza correctamente, recuperas el depósito automáticamente.\n• Si tienes una disputa en esta orden y la pierdes, perderás el depósito.\n• Este mecanismo protege a todos los usuarios contra estafadores.", "bondExplanationMaker": "Para crear tu orden primero debes pagar un depósito anti-abuso. Mantén esta pantalla abierta hasta completar el pago: si la cierras o no pagas, la orden no se creará. Funciona así:\n\n• Tus sats quedan retenidos en tu wallet, no se gastan.\n• Si el intercambio finaliza correctamente, recuperas el depósito automáticamente.\n• Si tienes una disputa en esta orden y la pierdes, perderás el depósito.\n• Este mecanismo protege a todos los usuarios contra estafadores.", diff --git a/lib/l10n/intl_fr.arb b/lib/l10n/intl_fr.arb index 4cb398b64..eec767af7 100644 --- a/lib/l10n/intl_fr.arb +++ b/lib/l10n/intl_fr.arb @@ -843,6 +843,12 @@ "copy": "Copier", "share": "Partager", "failedToShareInvoice": "Échec du partage de la facture. Veuillez essayer de copier à la place.", + "bondInvoicePending": "En attente de la facture de dépôt…", + "bondAlreadyPaid": "Ce dépôt est déjà payé. Votre échange est déjà en cours.", + "goToTrade": "ALLER À L'ÉCHANGE", + "bondInvoiceUnavailable": "Cette facture de dépôt n'est plus valide. Revenez en arrière et reprenez l'ordre pour en obtenir une nouvelle.", + "bondOrderPublished": "Votre ordre est publié dans le carnet d'ordres et son dépôt est déjà payé.", + "bondOrderNoLongerActive": "Cet ordre n'est plus actif, sa facture de dépôt n'est donc plus valide.", "bondScreenTitle": "Dépôt anti-abus", "bondExplanation": "Pour prendre cette commande, vous devez payer un dépôt anti-abus. Voici comment cela fonctionne :\n\n• Vos sats sont conservés dans votre portefeuille, ils ne sont pas dépensés.\n• Si l'échange se termine avec succès, vous récupérez votre dépôt automatiquement.\n• Si vous avez un litige sur cette commande et que vous le perdez, vous perdrez le dépôt.\n• Ce mécanisme protège tous les utilisateurs contre les escrocs.", "bondExplanationMaker": "Avant que votre commande puisse être créée, vous devez payer un dépôt anti-abus. Gardez cet écran ouvert jusqu'à ce que le paiement soit effectué — si vous le fermez ou ne payez pas, la commande ne sera pas créée. Voici comment cela fonctionne :\n\n• Vos sats sont conservés dans votre portefeuille, ils ne sont pas dépensés.\n• Si l'échange se termine avec succès, vous récupérez votre dépôt automatiquement.\n• Si vous avez un litige sur cette commande et que vous le perdez, vous perdrez le dépôt.\n• Ce mécanisme protège tous les utilisateurs contre les escrocs.", diff --git a/lib/l10n/intl_it.arb b/lib/l10n/intl_it.arb index 1a0c3c3a5..daf56ad34 100644 --- a/lib/l10n/intl_it.arb +++ b/lib/l10n/intl_it.arb @@ -723,6 +723,12 @@ "copy": "Copia", "share": "Condividi", "failedToShareInvoice": "Errore nel condividere la fattura. Per favore prova a copiarla invece.", + "bondInvoicePending": "In attesa della fattura del deposito…", + "bondAlreadyPaid": "Questo deposito è già stato pagato. Il tuo scambio è già in corso.", + "goToTrade": "VAI ALLO SCAMBIO", + "bondInvoiceUnavailable": "Questa fattura di deposito non è più valida. Torna indietro e prendi di nuovo l'ordine per ottenerne una nuova.", + "bondOrderPublished": "Il tuo ordine è pubblicato nel libro ordini e il suo deposito è già stato pagato.", + "bondOrderNoLongerActive": "Questo ordine non è più attivo, quindi la sua fattura di deposito non è più valida.", "bondScreenTitle": "Deposito anti-abuso", "bondExplanation": "Per prendere questo ordine devi pagare un deposito anti-abuso. Funziona così:\n\n• I tuoi sats vengono trattenuti nel tuo wallet, non vengono spesi.\n• Se lo scambio si conclude correttamente, recuperi il deposito automaticamente.\n• Se hai una disputa su questo ordine e la perdi, perderai il deposito.\n• Questo meccanismo protegge tutti gli utenti dai truffatori.", "bondExplanationMaker": "Prima che il tuo ordine possa essere creato, devi pagare un deposito anti-abuso. Tieni aperta questa schermata fino al completamento del pagamento: se la chiudi o non paghi, l'ordine non verrà creato. Funziona così:\n\n• I tuoi sats vengono trattenuti nel tuo wallet, non vengono spesi.\n• Se lo scambio si conclude correttamente, recuperi il deposito automaticamente.\n• Se hai una disputa su questo ordine e la perdi, perderai il deposito.\n• Questo meccanismo protegge tutti gli utenti dai truffatori.", diff --git a/lib/l10n/intl_nl.arb b/lib/l10n/intl_nl.arb index 3e82ff944..1484ae34d 100644 --- a/lib/l10n/intl_nl.arb +++ b/lib/l10n/intl_nl.arb @@ -846,6 +846,12 @@ "bondScreenTitle": "Borg tegen misbruik", "bondExplanation": "Om deze order te accepteren moet je een borg tegen misbruik betalen. Zo werkt het:\n\n• Je sats worden in je wallet vastgezet, niet uitgegeven.\n• Verloopt de ruil goed, dan krijg je je borg automatisch terug.\n• Krijg je een dispuut over deze order en verlies je dat, dan ben je de borg kwijt.\n• Dit beschermt alle gebruikers tegen oplichters.", "bondExplanationMaker": "Voordat je order kan worden aangemaakt, moet je een borg tegen misbruik betalen. Houd dit scherm open tot de betaling rond is; sluit je het of betaal je niet, dan wordt de order niet aangemaakt. Zo werkt het:\n\n• Je sats worden in je wallet vastgezet, niet uitgegeven.\n• Verloopt de ruil goed, dan krijg je je borg automatisch terug.\n• Krijg je een dispuut over deze order en verlies je dat, dan ben je de borg kwijt.\n• Dit beschermt alle gebruikers tegen oplichters.", + "bondInvoicePending": "Wachten op de borg-invoice…", + "bondAlreadyPaid": "Deze borg is al betaald. Je ruil is al onderweg.", + "goToTrade": "NAAR DE RUIL", + "bondInvoiceUnavailable": "Deze borg-invoice is niet meer geldig. Ga terug en accepteer de order opnieuw voor een nieuwe.", + "bondOrderPublished": "Je order staat in het orderboek en de borg is al betaald.", + "bondOrderNoLongerActive": "Deze order is niet meer actief, dus de borg-invoice is niet meer geldig.", "bondPayInvoicePrompt": "Betaal de invoice hieronder van {amount} sats om door te gaan, of annuleer als je niet akkoord gaat.", "statusWaitingTakerBond": "Wacht op borg", "payBondMessage": "Betaal de borg tegen misbruik om door te gaan met deze order.", diff --git a/lib/l10n/intl_pt.arb b/lib/l10n/intl_pt.arb index c5f12e052..f1de6899c 100644 --- a/lib/l10n/intl_pt.arb +++ b/lib/l10n/intl_pt.arb @@ -843,6 +843,12 @@ "copy": "Copiar", "share": "Compartilhar", "failedToShareInvoice": "Falha ao compartilhar a fatura. Por favor, tente copiar em vez disso.", + "bondInvoicePending": "Aguardando a fatura do depósito…", + "bondAlreadyPaid": "Este depósito já foi pago. Sua troca já está em andamento.", + "goToTrade": "IR PARA A TROCA", + "bondInvoiceUnavailable": "Esta fatura de depósito não é mais válida. Volte atrás e aceite a ordem novamente para obter uma nova.", + "bondOrderPublished": "Sua ordem está publicada no livro de ordens e o depósito dela já foi pago.", + "bondOrderNoLongerActive": "Esta ordem não está mais ativa, então a fatura de depósito dela não é mais válida.", "bondScreenTitle": "Depósito antiabuso", "bondExplanation": "Para aceitar esta ordem, você deve pagar um depósito antiabuso. Veja como funciona:\n\n• Seus sats ficam retidos na sua carteira, eles não são gastos.\n• Se a troca for concluída com sucesso, você recebe seu depósito de volta automaticamente.\n• Se você tiver uma disputa nesta ordem e perdê-la, você perderá o depósito.\n• Este mecanismo protege todos os usuários contra golpistas.", "bondExplanationMaker": "Antes que sua ordem possa ser criada, você deve pagar um depósito antiabuso. Mantenha esta tela aberta até que o pagamento seja concluído — se você fechá-la ou não pagar, a ordem não será criada. Veja como funciona:\n\n• Seus sats ficam retidos na sua carteira, eles não são gastos.\n• Se a troca for concluída com sucesso, você recebe seu depósito de volta automaticamente.\n• Se você tiver uma disputa nesta ordem e perdê-la, você perderá o depósito.\n• Este mecanismo protege todos os usuários contra golpistas.", diff --git a/test/features/order/models/order_state_retake_test.dart b/test/features/order/models/order_state_retake_test.dart new file mode 100644 index 000000000..8814bec02 --- /dev/null +++ b/test/features/order/models/order_state_retake_test.dart @@ -0,0 +1,167 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mostro_mobile/data/enums.dart'; +import 'package:mostro_mobile/data/models.dart'; +import 'package:mostro_mobile/features/order/models/order_state.dart'; + +/// An invoice belongs to the take cycle that produced it. Mostro cancels the +/// bond and escrow hold invoices node-side when a cycle ends, so a bolt11 kept +/// across a cancel is unpayable (INCORRECT_PAYMENT_DETAILS) — and the bond +/// screen rendering the previous cycle's escrow invoice is what made an order +/// untakeable in the first place. +void main() { + Order orderPayload({Status status = Status.pending}) => Order( + id: 'order-1', + kind: OrderType.buy, + status: status, + amount: 16558, + fiatCode: 'EUR', + fiatAmount: 10, + paymentMethod: 'prueba', + ); + + MostroMessage invoiceMessage(Action action, String bolt11) => + MostroMessage( + id: 'order-1', + action: action, + payload: PaymentRequest( + order: orderPayload(), + lnInvoice: bolt11, + ), + ); + + MostroMessage orderMessage(Action action, Status status) => + MostroMessage( + id: 'order-1', + action: action, + payload: orderPayload(status: status), + ); + + OrderState initial() => OrderState( + action: Action.newOrder, + status: Status.pending, + order: null, + ); + + group('take cycle boundaries', () { + test('the bond invoice survives inside its own cycle', () { + final state = + initial().updateWith(invoiceMessage(Action.payBondInvoice, 'lnbcbond')); + + expect(state.status, Status.waitingTakerBond); + expect(state.paymentRequest?.lnInvoice, 'lnbcbond'); + }); + + test('cancelling the take drops the escrow invoice', () { + final state = initial() + .updateWith(invoiceMessage(Action.payBondInvoice, 'lnbcbond')) + .updateWith(invoiceMessage(Action.payInvoice, 'lnbcescrow')) + .updateWith(orderMessage(Action.canceled, Status.canceled)); + + expect(state.status, Status.canceled); + expect(state.paymentRequest, isNull); + }); + + test('a republish back to pending drops the escrow invoice', () { + final state = initial() + .updateWith(invoiceMessage(Action.payInvoice, 'lnbcescrow')) + .updateWith(orderMessage(Action.newOrder, Status.pending)); + + expect(state.status, Status.pending); + expect(state.paymentRequest, isNull); + }); + + test('the invoice of the next cycle is not swallowed by the cleanup', () { + // Republished back to the book, then taken again: the bond invoice of + // the new cycle arrives on a state the cleanup just emptied. + final state = initial() + .updateWith(invoiceMessage(Action.payInvoice, 'lnbcescrow')) + .updateWith(orderMessage(Action.newOrder, Status.pending)) + .updateWith(invoiceMessage(Action.payBondInvoice, 'lnbcbond2')); + + expect(state.status, Status.waitingTakerBond); + expect(state.paymentRequest?.lnInvoice, 'lnbcbond2'); + }); + }); + + group('endsTradeCycle', () { + test('covers every status in which Mostro voids the cycle invoices', () { + for (final status in [ + Status.pending, + Status.canceled, + Status.canceledByAdmin, + Status.expired, + ]) { + expect(OrderState.endsTradeCycle(status), isTrue, reason: '$status'); + } + + for (final status in [ + // The *pending* cooperative cancel: the trade can still reach + // fiat-sent, so nothing of the cycle may be dropped yet. + Status.cooperativelyCanceled, + Status.waitingTakerBond, + Status.waitingPayment, + Status.waitingBuyerInvoice, + Status.active, + Status.fiatSent, + Status.success, + ]) { + expect(OrderState.endsTradeCycle(status), isFalse, reason: '$status'); + } + }); + }); + + group('opensTakeCycle', () { + test('accepts only the actions that can start a take', () { + for (final action in [ + Action.takeBuy, + Action.takeSell, + Action.payBondInvoice, + Action.payInvoice, + Action.addInvoice, + Action.waitingSellerToPay, + Action.waitingBuyerInvoice, + ]) { + expect(OrderState.opensTakeCycle(action), isTrue, reason: '$action'); + } + + // Mid-trade and terminal actions: a copy of one of these arriving after + // a cancel is a late duplicate, never a new take. + for (final action in [ + Action.holdInvoicePaymentAccepted, + Action.buyerTookOrder, + Action.fiatSentOk, + Action.released, + Action.canceled, + Action.newOrder, + ]) { + expect(OrderState.opensTakeCycle(action), isFalse, reason: '$action'); + } + }); + }); + + group('wouldRejectAsStale', () { + test('flags the first message of the take that follows a cancel', () { + final canceled = + initial().updateWith(orderMessage(Action.canceled, Status.canceled)); + + // A cancelled order outranks every waiting phase, so without the + // new-cycle reset in OrderNotifier.sync the retake would be dropped. + expect( + canceled.wouldRejectAsStale( + invoiceMessage(Action.payBondInvoice, 'lnbcbond2')), + isTrue, + ); + }); + + test('does not flag a forward move inside the cycle', () { + final bond = + initial().updateWith(invoiceMessage(Action.payBondInvoice, 'lnbcbond')); + + expect( + bond.wouldRejectAsStale(invoiceMessage(Action.payInvoice, 'lnbcescrow')), + isFalse, + ); + }); + }); + +} diff --git a/test/features/order/notifiers/order_notifier_retake_cycle_test.dart b/test/features/order/notifiers/order_notifier_retake_cycle_test.dart new file mode 100644 index 000000000..5ceae6cc7 --- /dev/null +++ b/test/features/order/notifiers/order_notifier_retake_cycle_test.dart @@ -0,0 +1,441 @@ +import 'package:dart_nostr/dart_nostr.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mostro_mobile/data/models/enums/action.dart'; +import 'package:mostro_mobile/data/models/enums/order_type.dart'; +import 'package:mostro_mobile/data/models/enums/status.dart'; +import 'package:mostro_mobile/data/models/mostro_message.dart'; +import 'package:mostro_mobile/data/models/order.dart'; +import 'package:mostro_mobile/data/models/payment_request.dart'; +import 'package:mostro_mobile/data/repositories/session_storage.dart'; +import 'package:mostro_mobile/features/order/notifiers/order_notifier.dart'; +import 'package:mostro_mobile/features/order/providers/order_notifier_provider.dart'; +import 'package:mostro_mobile/features/settings/settings.dart'; +import 'package:mostro_mobile/services/mostro_service.dart'; +import 'package:mostro_mobile/services/nostr_service.dart'; +import 'package:mostro_mobile/shared/notifiers/session_notifier.dart'; +import 'package:mostro_mobile/shared/providers/mostro_database_provider.dart'; +import 'package:mostro_mobile/shared/providers/mostro_service_provider.dart'; +import 'package:mostro_mobile/shared/providers/mostro_storage_provider.dart'; +import 'package:mostro_mobile/shared/providers/nostr_service_provider.dart'; +import 'package:mostro_mobile/shared/providers/session_notifier_provider.dart'; +import 'package:mostro_mobile/shared/providers/storage_providers.dart'; +import 'package:sembast/sembast_memory.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; +import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; + +/// Sessions play no part in these scenarios; this keeps the harness free of +/// the generated mocks. +class _NoopSessionStorage implements SessionStorage { + @override + dynamic noSuchMethod(Invocation invocation) => Future.value(); +} + +/// Retaking an order reuses its notifier, so the replay has to tell the first +/// message of the *new* take from a late copy of the old one. Getting that +/// wrong either leaves the previous cycle's invoice on the bond screen (#731) +/// or lets a duplicate reopen a terminal trade (#723). Both directions are +/// exercised here against the real `sync()`. +class _SilentNostrService extends NostrService { + @override + bool get isInitialized => true; + + @override + Stream subscribeToEvents( + NostrRequest request, { + void Function(String)? onEose, + }) => + const Stream.empty(); +} + +class _IdleMostroService extends MostroService { + _IdleMostroService(super.ref); +} + +class _FixedSessionNotifier extends SessionNotifier { + _FixedSessionNotifier(Ref ref) + : super( + ref, + _NoopSessionStorage(), + Settings( + relays: [], + fullPrivacyMode: false, + mostroPublicKey: 'test', + ), + ) { + state = []; + } +} + +/// Real `sync()`, no live stream — and no unawaited pass either. +/// +/// The OrderNotifier constructor starts a `sync()` nothing can await, and a +/// second `sync()` while that one runs only sets `_resyncRequested` and +/// returns. Swallowing the constructor's call leaves the pass the test awaits +/// as the only one, so the assertions never race hydration. +class _SyncOnlyOrderNotifier extends OrderNotifier { + _SyncOnlyOrderNotifier(super.orderId, super.ref); + + bool _constructorSyncSkipped = false; + + @override + void subscribe() {} + + @override + Future sync() async { + if (!_constructorSyncSkipped) { + _constructorSyncSkipped = true; + return; + } + return super.sync(); + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const orderId = 'test-order-id'; + + late Database db; + late ProviderContainer container; + + setUp(() async { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.empty(); + db = await newDatabaseFactoryMemory().openDatabase('retake_cycle.db'); + + container = ProviderContainer( + overrides: [ + sharedPreferencesProvider.overrideWithValue(SharedPreferencesAsync()), + mostroDatabaseProvider.overrideWithValue(db), + nostrServiceProvider.overrideWithValue(_SilentNostrService()), + mostroServiceProvider.overrideWith((ref) => _IdleMostroService(ref)), + sessionNotifierProvider + .overrideWith((ref) => _FixedSessionNotifier(ref)), + orderNotifierProvider.overrideWith( + (ref, id) => _SyncOnlyOrderNotifier(id, ref), + ), + ], + ); + }); + + tearDown(() async { + container.dispose(); + await db.close(); + }); + + Order orderPayload(Status status) => Order( + id: orderId, + kind: OrderType.buy, + status: status, + amount: 16558, + fiatCode: 'EUR', + fiatAmount: 10, + paymentMethod: 'face to face', + ); + + MostroMessage message( + Action action, + Status status, { + required int? eventCreatedAt, + required int timestamp, + }) => + MostroMessage( + action: action, + id: orderId, + eventCreatedAt: eventCreatedAt, + timestamp: timestamp, + payload: orderPayload(status), + ); + + MostroMessage invoice( + Action action, + String bolt11, { + required int eventCreatedAt, + required int timestamp, + }) => + MostroMessage( + action: action, + id: orderId, + eventCreatedAt: eventCreatedAt, + timestamp: timestamp, + payload: PaymentRequest( + order: orderPayload(Status.pending), + lnInvoice: bolt11, + ), + ); + + Future persist(List<(String, MostroMessage)> history) async { + final storage = container.read(mostroStorageProvider); + for (final (key, msg) in history) { + await storage.addMessage(key, msg); + } + } + + Future syncedState() async { + // _SyncOnlyOrderNotifier swallows the constructor's unawaited sync(), so + // this is the only pass and awaiting it is enough. + await container.read(orderNotifierProvider(orderId).notifier).sync(); + final state = container.read(orderNotifierProvider(orderId)); + return OrderStateSnapshot( + status: state.status, + action: state.action, + invoice: state.paymentRequest?.lnInvoice, + fiatWasSent: state.fiatWasSent, + ); + } + + group('a genuine retake', () { + test('ends on the new bond invoice, never the cancelled escrow one', + () async { + await persist([ + ('a', invoice(Action.payBondInvoice, 'lnbcbond', + eventCreatedAt: 1000, timestamp: 1)), + ('b', invoice(Action.payInvoice, 'lnbcescrow', + eventCreatedAt: 2000, timestamp: 2)), + ( + 'c', + message(Action.waitingBuyerInvoice, Status.waitingBuyerInvoice, + eventCreatedAt: 2500, timestamp: 3) + ), + ( + 'd', + message(Action.canceled, Status.canceled, + eventCreatedAt: 3000, timestamp: 4) + ), + ('e', invoice(Action.payBondInvoice, 'lnbcbond2', + eventCreatedAt: 4000, timestamp: 5)), + ]); + + final state = await syncedState(); + + expect(state.status, Status.waitingTakerBond); + expect(state.action, Action.payBondInvoice); + expect(state.invoice, 'lnbcbond2'); + }); + + test('a history that ends on the cancel keeps no invoice at all', () async { + await persist([ + ('a', invoice(Action.payBondInvoice, 'lnbcbond', + eventCreatedAt: 1000, timestamp: 1)), + ('b', invoice(Action.payInvoice, 'lnbcescrow', + eventCreatedAt: 2000, timestamp: 2)), + ( + 'c', + message(Action.canceled, Status.canceled, + eventCreatedAt: 3000, timestamp: 3) + ), + ]); + + final state = await syncedState(); + + expect(state.status, Status.canceled); + expect(state.invoice, isNull); + }); + }); + + group('late copies must not reopen a terminal order', () { + // `created_at` has one-second resolution and relays replay newest-first, + // so a duplicate from the same second as the cancel sorts after it. + test('same-second copy of a setup message after canceled', () async { + await persist([ + ( + 'a', + message(Action.waitingSellerToPay, Status.waitingPayment, + eventCreatedAt: 1000, timestamp: 1) + ), + ( + 'b', + message(Action.holdInvoicePaymentAccepted, Status.active, + eventCreatedAt: 2000, timestamp: 2) + ), + ( + 'c', + message(Action.canceled, Status.canceled, + eventCreatedAt: 3000, timestamp: 3) + ), + ( + 'd', + message(Action.waitingSellerToPay, Status.waitingPayment, + eventCreatedAt: 3000, timestamp: 4) + ), + ]); + + expect((await syncedState()).status, Status.canceled); + }); + + test('same-second copy of a mid-trade message after canceled', () async { + await persist([ + ( + 'a', + message(Action.waitingSellerToPay, Status.waitingPayment, + eventCreatedAt: 1000, timestamp: 1) + ), + ( + 'b', + message(Action.holdInvoicePaymentAccepted, Status.active, + eventCreatedAt: 2000, timestamp: 2) + ), + ( + 'c', + message(Action.canceled, Status.canceled, + eventCreatedAt: 3000, timestamp: 3) + ), + ( + 'd', + message(Action.holdInvoicePaymentAccepted, Status.active, + eventCreatedAt: 3000, timestamp: 4) + ), + ]); + + expect((await syncedState()).status, Status.canceled); + }); + + // `canceled-by-admin` and `expired` take the same path: they are just + // more statuses `endsTradeCycle` covers. They are not driven from here + // because an `admin-*` message is itself dropped without a tracked + // dispute (`rejectsAdminDisputeMessage`), which would prove nothing + // about the cycle rule. + }); + + group('live delivery is not ordered', () { + test('a cancel from the previous cycle cannot void the new invoice', + () async { + await persist([ + ('a', invoice(Action.payBondInvoice, 'lnbcbond', + eventCreatedAt: 1000, timestamp: 1)), + ( + 'b', + message(Action.canceled, Status.canceled, + eventCreatedAt: 3000, timestamp: 2) + ), + ('c', invoice(Action.payBondInvoice, 'lnbcbond2', + eventCreatedAt: 4000, timestamp: 3)), + ]); + + // Replay leaves the notifier inside the new cycle… + expect((await syncedState()).invoice, 'lnbcbond2'); + + // …and the previous cycle's cancel, delivered late by the live stream, + // must not reach the state: it would void the invoice on screen, and + // downstream it deletes the session and navigates away. + final notifier = + container.read(orderNotifierProvider(orderId).notifier); + final lateCancel = message(Action.canceled, Status.canceled, + eventCreatedAt: 3000, timestamp: 9); + + expect(notifier.precedesActiveCycle(lateCancel), isTrue); + final after = + notifier.applyToCycle(container.read(orderNotifierProvider(orderId)), + lateCancel); + + expect(after.status, Status.waitingTakerBond); + expect(after.paymentRequest?.lnInvoice, 'lnbcbond2'); + }); + }); + + group('a pending cooperative cancel is not a cycle end', () { + test('a late setup copy keeps fiatWasSent', () async { + await persist([ + ( + 'a', + message(Action.buyerTookOrder, Status.active, + eventCreatedAt: 1000, timestamp: 1) + ), + ( + 'b', + message(Action.fiatSentOk, Status.fiatSent, + eventCreatedAt: 2000, timestamp: 2) + ), + ( + 'c', + message(Action.cooperativeCancelInitiatedByPeer, Status.active, + eventCreatedAt: 3000, timestamp: 3) + ), + ( + 'd', + message(Action.buyerTookOrder, Status.active, + eventCreatedAt: 3000, timestamp: 4) + ), + ]); + + final state = await syncedState(); + + expect(state.status, Status.cooperativelyCanceled); + expect(state.fiatWasSent, isTrue); + }); + }); + + group('the cycle guard', () { + test('a message with no time at all counts as preceding the cycle', + () async { + final notifier = + container.read(orderNotifierProvider(orderId).notifier); + + notifier.state = notifier.applyToCycle( + container.read(orderNotifierProvider(orderId)), + invoice(Action.payBondInvoice, 'lnbcbond', + eventCreatedAt: 4000, timestamp: 4), + ); + + // _eventTimeOf falls back to 0, so a message carrying neither clock is + // treated as older than any started cycle and dropped. Documented here + // because the drop is silent. + expect( + notifier.precedesActiveCycle( + MostroMessage( + action: Action.canceled, + id: orderId, + payload: orderPayload(Status.canceled), + ), + ), + isTrue, + ); + }); + }); + + // RestoreManager writes state through updateStateFromMessage, not sync(). If + // that path skips the cycle bookkeeping, cycleEndedAt stays null, the + // restart condition cannot fire and the next take's bond invoice is dropped + // as stale — #731 again, through the one write that bypassed the rule + // (#732 review). + group('the restore path', () { + test('a restored cancel still lets the next take open a cycle', () async { + final notifier = + container.read(orderNotifierProvider(orderId).notifier); + + // What RestoreManager builds: a locally synthesized message, so no + // eventCreatedAt — only a timestamp, in milliseconds. + notifier.updateStateFromMessage( + message(Action.canceled, Status.canceled, + eventCreatedAt: null, timestamp: 3000), + ); + + final next = notifier.applyToCycle( + container.read(orderNotifierProvider(orderId)), + invoice(Action.payBondInvoice, 'lnbcnewbond', + eventCreatedAt: 5000, timestamp: 5000), + ); + + expect(next.status, Status.waitingTakerBond); + expect(next.paymentRequest?.lnInvoice, 'lnbcnewbond'); + }); + + }); +} + +/// The few fields these scenarios assert on. +class OrderStateSnapshot { + final Status status; + final Action action; + final String? invoice; + final bool fiatWasSent; + + OrderStateSnapshot({ + required this.status, + required this.action, + required this.invoice, + required this.fiatWasSent, + }); +} diff --git a/test/features/order/screens/pay_bond_invoice_screen_test.dart b/test/features/order/screens/pay_bond_invoice_screen_test.dart new file mode 100644 index 000000000..c20cfa99b --- /dev/null +++ b/test/features/order/screens/pay_bond_invoice_screen_test.dart @@ -0,0 +1,324 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:mostro_mobile/data/enums.dart' as enums; +import 'package:mostro_mobile/data/models.dart'; +import 'package:mostro_mobile/features/order/models/order_state.dart'; +import 'package:mostro_mobile/features/order/notifiers/order_notifier.dart'; +import 'package:mostro_mobile/features/order/providers/order_notifier_provider.dart'; +import 'package:mostro_mobile/features/order/screens/pay_bond_invoice_screen.dart'; +import 'package:mostro_mobile/generated/l10n.dart'; +import 'package:mostro_mobile/services/mostro_service.dart'; +import 'package:mostro_mobile/shared/providers/mostro_service_provider.dart'; +import 'package:mostro_mobile/shared/providers/order_repository_provider.dart'; +import 'package:mostro_mobile/shared/providers/session_notifier_provider.dart'; +import 'package:mostro_mobile/shared/providers/storage_providers.dart'; +import 'package:mostro_mobile/shared/notifiers/session_notifier.dart'; +import 'package:mostro_mobile/features/settings/settings.dart'; +import 'package:mostro_mobile/data/repositories/session_storage.dart'; +import 'package:mostro_mobile/shared/utils/nostr_utils.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; +import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; +import 'package:qr_flutter/qr_flutter.dart'; + +/// The screen is reachable from a notification card, from trade detail and +/// from the maker create flow, so it can be opened at any point of an order's +/// life. "No invoice to show" then means three different things, and telling +/// a maker mid-bond that the invoice expired would make them abandon a valid +/// order (#732 review). +class _NoopSessionStorage implements SessionStorage { + @override + dynamic noSuchMethod(Invocation invocation) => Future.value(); +} + +/// The screen asks the session whether a bond is actually awaited, which is +/// what separates "the invoice has not arrived" from "the bond is paid and the +/// order is live in the book". +class _FixedSessionNotifier extends SessionNotifier { + _FixedSessionNotifier(Ref ref, this._sessions) + : super( + ref, + _NoopSessionStorage(), + Settings( + relays: [], + fullPrivacyMode: false, + mostroPublicKey: 'test', + ), + ) { + state = _sessions; + } + + final List _sessions; + + // The real lookup reads a private map that only its own writes populate, so + // seeding `state` is not enough for a double. + @override + Session? getSessionByOrderId(String orderId) { + for (final session in _sessions) { + if (session.orderId == orderId) return session; + } + return null; + } +} + +/// A maker session for [orderId], in bond limbo or past it. +Session _makerSession(String orderId, {required bool bondPending}) { + final session = Session( + masterKey: NostrUtils.generateKeyPair(), + tradeKey: NostrUtils.generateKeyPair(), + keyIndex: 1, + fullPrivacy: false, + startTime: DateTime.now(), + orderId: orderId, + ); + session.bondPending = bondPending; + return session; +} + +class _IdleMostroService extends MostroService { + _IdleMostroService(super.ref); +} + +class _FixedOrderNotifier extends OrderNotifier { + _FixedOrderNotifier(super.orderId, super.ref, OrderState initial) { + state = initial; + } + + @override + void subscribe() {} + + @override + Future sync() async {} +} + +void main() { + const orderId = 'order-1'; + + setUp(() { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.empty(); + }); + + Order order(enums.Status status) => Order( + id: orderId, + kind: enums.OrderType.buy, + status: status, + amount: 16558, + fiatCode: 'EUR', + fiatAmount: 10, + paymentMethod: 'face to face', + ); + + OrderState stateWith({ + required enums.Status status, + required enums.Action action, + String? invoice, + bool hydrated = true, + }) => + OrderState( + status: status, + action: action, + // The notifier's initial state carries no payload until a sync() has + // read the history, which is how the screen tells "nothing has loaded + // yet" from a real pending order. + order: hydrated ? order(status) : null, + paymentRequest: invoice == null + ? null + : PaymentRequest(order: order(status), lnInvoice: invoice), + ); + + /// [bondPending] mirrors `Session.bondPending`: true while a maker-created + /// order sits in bond limbo, false once the bond is paid and the order is + /// published — and false too when there is no session at all (a taker). + Future pumpScreen( + WidgetTester tester, + OrderState state, { + bool? bondPending, + }) async { + final router = GoRouter( + initialLocation: '/pay_bond/$orderId', + routes: [ + GoRoute(path: '/', builder: (_, __) => const Scaffold()), + GoRoute( + path: '/trade_detail/:id', builder: (_, __) => const Scaffold()), + GoRoute( + path: '/pay_bond/:orderId', + builder: (_, __) => const PayBondInvoiceScreen(orderId: orderId), + ), + ], + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + sharedPreferencesProvider.overrideWithValue(SharedPreferencesAsync()), + sessionNotifierProvider.overrideWith( + (ref) => _FixedSessionNotifier( + ref, + bondPending == null + ? [] + : [_makerSession(orderId, bondPending: bondPending)], + ), + ), + mostroServiceProvider.overrideWith((ref) => _IdleMostroService(ref)), + eventProvider(orderId).overrideWithValue(null), + orderNotifierProvider.overrideWith( + (ref, id) => _FixedOrderNotifier(id, ref, state), + ), + ], + child: MaterialApp.router( + routerConfig: router, + localizationsDelegates: S.localizationsDelegates, + supportedLocales: S.supportedLocales, + ), + ), + ); + await tester.pump(); + } + + testWidgets('renders the QR while the bond invoice is the current one', + (tester) async { + await pumpScreen( + tester, + stateWith( + status: enums.Status.waitingTakerBond, + action: enums.Action.payBondInvoice, + invoice: 'lnbcbond', + ), + ); + + expect(find.byType(QrImageView), findsOneWidget); + }); + + testWidgets('waits, without a destructive action, before the invoice arrives', + (tester) async { + await pumpScreen( + tester, + // The maker create flow lands here while this provider is still at its + // initial state: the bond message reaches it only after its own sync(). + stateWith( + status: enums.Status.pending, + action: enums.Action.newOrder, + ), + bondPending: true, + ); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + expect(find.byType(QrImageView), findsNothing); + expect(find.byType(ElevatedButton), findsNothing); + }); + + // The pay-bond notification stays in the history, and tapping it pushes this + // screen. A maker who taps it after paying is on a published order: no + // invoice is coming, so a spinner would never resolve (#732 review). + testWidgets('does not spin on a published maker order', (tester) async { + await pumpScreen( + tester, + stateWith( + status: enums.Status.pending, + action: enums.Action.newOrder, + ), + bondPending: false, + ); + await tester.pump(const Duration(seconds: 5)); + + final context = tester.element(find.byType(PayBondInvoiceScreen)); + final s = S.of(context)!; + expect(find.byType(CircularProgressIndicator), findsNothing); + expect(find.byType(QrImageView), findsNothing); + expect(find.text(s.bondOrderPublished), findsOneWidget); + expect(find.byType(ElevatedButton), findsOneWidget); + }); + + testWidgets('sends the user to the trade once the bond has been paid', + (tester) async { + await pumpScreen( + tester, + stateWith( + status: enums.Status.waitingBuyerInvoice, + action: enums.Action.waitingBuyerInvoice, + ), + ); + + expect(find.byType(QrImageView), findsNothing); + expect(find.byType(CircularProgressIndicator), findsNothing); + + await tester.tap(find.byType(ElevatedButton)); + await tester.pumpAndSettle(); + + expect(find.byType(PayBondInvoiceScreen), findsNothing); + }); + + // A taker whose take timed out finds the order back in the book, with their + // bond returned and no session: telling them "your order is published" would + // be a statement about an order they do not own (#732 review). + testWidgets('tells a taker to retake a republished order', (tester) async { + await pumpScreen( + tester, + stateWith( + status: enums.Status.pending, + action: enums.Action.newOrder, + ), + // No session: Mostro deletes the taker's when it republishes. + ); + await tester.pump(const Duration(seconds: 5)); + + final context = tester.element(find.byType(PayBondInvoiceScreen)); + final s = S.of(context)!; + expect(find.text(s.bondInvoiceUnavailable), findsOneWidget); + expect(find.text(s.bondOrderPublished), findsNothing); + expect(find.byType(CircularProgressIndicator), findsNothing); + }); + + // Opened from a notification before this order's notifier has synced: the + // state is the unhydrated initial one, so waiting is the honest answer. + testWidgets('waits while the order has not hydrated yet', (tester) async { + await pumpScreen( + tester, + stateWith( + status: enums.Status.pending, + action: enums.Action.newOrder, + hydrated: false, + ), + ); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + expect(find.byType(ElevatedButton), findsNothing); + }); + + // A maker cannot take their own order, so "take the order again" is wrong + // for the maker of an expired one (#732 review). + testWidgets('tells a maker their own order is over, not to retake it', + (tester) async { + await pumpScreen( + tester, + stateWith( + status: enums.Status.expired, + action: enums.Action.canceled, + ), + bondPending: true, + ); + + final context = tester.element(find.byType(PayBondInvoiceScreen)); + final s = S.of(context)!; + expect(find.text(s.bondOrderNoLongerActive), findsOneWidget); + expect(find.text(s.bondInvoiceUnavailable), findsNothing); + }); + + testWidgets('says the invoice is gone once the take cycle ended', + (tester) async { + await pumpScreen( + tester, + stateWith( + status: enums.Status.canceled, + action: enums.Action.canceled, + ), + ); + + expect(find.byType(QrImageView), findsNothing); + expect(find.byType(CircularProgressIndicator), findsNothing); + expect(find.byType(ElevatedButton), findsOneWidget); + }); +} diff --git a/test/features/restore/restore_decode_test.dart b/test/features/restore/restore_decode_test.dart index bac757217..7b0d7b71e 100644 --- a/test/features/restore/restore_decode_test.dart +++ b/test/features/restore/restore_decode_test.dart @@ -117,4 +117,28 @@ void main() { ); }); }); + + // Mostrod reports created_at in seconds; MostroMessage timestamps are + // milliseconds. Restored messages now drive the take-cycle bookkeeping, so + // the scale is load-bearing (#732 review). + group('restoreCreatedAtMillis', () { + test('scales the daemon seconds to milliseconds', () { + // 2026-10-02T23:23:29Z, as the node's orders table stores it. + expect(restoreCreatedAtMillis(1790983409), 1790983409000); + expect( + DateTime.fromMillisecondsSinceEpoch( + restoreCreatedAtMillis(1790983409), isUtc: true) + .year, + 2026, + ); + }); + + test('falls back to now, already in milliseconds', () { + final before = DateTime.now().millisecondsSinceEpoch; + final value = restoreCreatedAtMillis(null); + expect(value, greaterThanOrEqualTo(before)); + expect(DateTime.fromMillisecondsSinceEpoch(value).year, + DateTime.now().year); + }); + }); }