From 498ec58e05c76ef32517c5807dbd001a76baa898 Mon Sep 17 00:00:00 2001 From: Andrea Diaz Correia Date: Thu, 18 Jun 2026 00:32:40 -0300 Subject: [PATCH] fix(restore): detect and restore failed payout substate for settled-hold-invoice buyers --- lib/features/restore/restore_manager.dart | 103 +++++++++++++--- .../restore/restore_failed_payout_test.dart | 115 ++++++++++++++++++ 2 files changed, 203 insertions(+), 15 deletions(-) create mode 100644 test/features/restore/restore_failed_payout_test.dart diff --git a/lib/features/restore/restore_manager.dart b/lib/features/restore/restore_manager.dart index e3bae77bb..292cfea82 100644 --- a/lib/features/restore/restore_manager.dart +++ b/lib/features/restore/restore_manager.dart @@ -798,23 +798,57 @@ class RestoreService { } } - // Create regular order message with Order payload - final mostroMessage = MostroMessage( - id: orderDetail.id, - action: action, - payload: order, - timestamp: - orderDetail.createdAt ?? - DateTime.now().millisecondsSinceEpoch, - ); + // `settled-hold-invoice` is overloaded for the buyer (see issue + // #615): it covers both "hold settled, sats in flight" and "payout + // failed, awaiting a new invoice". The protocol distinguishes them + // only via the action (`payment-failed` / `add-invoice`), so a + // status-based rebuild always lands on the "paying sats" screen. + // On restore the daemon re-sends `add-invoice` to the buyer's trade + // key when the payout failed (mostro#754). Detect that substate and + // replay `payment-failed` then `add-invoice` so the order lands on + // `payment-failed` + `add-invoice` (the new-invoice prompt) instead. + List actionsToApply = [action]; + final orderSession = ref + .read(sessionNotifierProvider.notifier) + .getSessionByOrderId(orderDetail.id); + if (order.status == Status.settledHoldInvoice && + orderSession?.role == Role.buyer) { + final messages = await storage.getAllMessagesForOrderId( + orderDetail.id, + ); + if (restoreHasFailedPayoutSignal(messages)) { + actionsToApply = [Action.paymentFailed, Action.addInvoice]; + logger.i( + 'Restore: detected failed payout for order ${orderDetail.id}, ' + 'restoring payment-failed substate instead of "paying sats"', + ); + } + } - // Save order message to storage - final key = - '${orderDetail.id}_restore_${action.value}_${DateTime.now().millisecondsSinceEpoch}'; - await storage.addMessage(key, mostroMessage); + // Create and apply the regular order message(s) with Order payload. + // When more than one action is replayed, stagger their timestamps so + // a later sync() replays them in the same order and converges to the + // same final state. + final baseTimestamp = + orderDetail.createdAt ?? DateTime.now().millisecondsSinceEpoch; + for (var i = 0; i < actionsToApply.length; i++) { + final replayAction = actionsToApply[i]; + final mostroMessage = MostroMessage( + id: orderDetail.id, + action: replayAction, + payload: order, + timestamp: baseTimestamp + i, + ); + + // Save order message to storage + final key = + '${orderDetail.id}_restore_${replayAction.value}_' + '${DateTime.now().millisecondsSinceEpoch}_$i'; + await storage.addMessage(key, mostroMessage); - // Update state with order message - notifier.updateStateFromMessage(mostroMessage); + // Update state with order message + notifier.updateStateFromMessage(mostroMessage); + } } } catch (e, stack) { logger.e( @@ -1071,6 +1105,45 @@ class RestoreService { } } +/// Detects the "payout failed, awaiting a new invoice" substate of an order +/// that Mostro reports as `settled-hold-invoice`. See issue #615. +/// +/// `settled-hold-invoice` is overloaded for the buyer: it covers both "hold +/// settled, sats in flight" and "payout failed". The protocol distinguishes +/// them only via the action, so the snapshot status alone cannot recover the +/// failed substate. On restore the daemon re-sends `add-invoice` (and may also +/// re-send `payment-failed`) to the buyer's trade key when the payout failed +/// (mostro#754). +/// +/// `payment-failed` only ever occurs after a failed payout, so it is a +/// definitive signal on its own. `add-invoice` also appears early in the happy +/// flow (waiting-buyer-invoice), so it is only treated as a failed-payout signal +/// when it arrives after the hold was released/settled. Restore clears storage +/// before re-subscribing, so in practice only freshly re-sent messages are +/// present, but the ordering check keeps this correct even if the daemon +/// re-sends the full message history. +bool restoreHasFailedPayoutSignal(List messages) { + final sorted = [...messages] + ..sort((a, b) => (a.timestamp ?? 0).compareTo(b.timestamp ?? 0)); + + int releaseIndex = -1; + for (var i = 0; i < sorted.length; i++) { + final action = sorted[i].action; + if (action == Action.release || + action == Action.released || + action == Action.holdInvoicePaymentSettled) { + releaseIndex = i; + } + } + + for (var i = 0; i < sorted.length; i++) { + final action = sorted[i].action; + if (action == Action.paymentFailed) return true; + if (action == Action.addInvoice && i > releaseIndex) return true; + } + return false; +} + /// Thrown when Mostro responds with cant-do: invalid_trade_index to /// Action.lastTradeIndex during the restore flow. class RestoreInvalidTradeIndexException implements Exception { diff --git a/test/features/restore/restore_failed_payout_test.dart b/test/features/restore/restore_failed_payout_test.dart new file mode 100644 index 000000000..d562f6f3b --- /dev/null +++ b/test/features/restore/restore_failed_payout_test.dart @@ -0,0 +1,115 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mostro_mobile/data/models.dart'; +import 'package:mostro_mobile/data/enums.dart'; +import 'package:mostro_mobile/features/order/models/order_state.dart'; +import 'package:mostro_mobile/features/restore/restore_manager.dart'; + +/// Regression coverage for issue #615: restoring an order that Mostro reports +/// as `settled-hold-invoice` after a failed Lightning payout must land on the +/// new-invoice prompt (`add-invoice` + `payment-failed`), not "paying sats" +/// (`released` + `settled-hold-invoice`). + +MostroMessage _msg(Action action, {int? timestamp}) => + MostroMessage( + action: action, + id: 'order-1', + timestamp: timestamp, + payload: const Order( + id: 'order-1', + kind: OrderType.buy, + status: Status.settledHoldInvoice, + amount: 500, + fiatCode: 'USD', + fiatAmount: 50, + paymentMethod: 'Cash', + ), + ); + +/// Replays a sequence of actions onto a fresh order state, mirroring how +/// RestoreService.restore() applies snapshot-derived messages via +/// OrderNotifier.updateStateFromMessage(). +OrderState _replay(List actions) { + OrderState state = OrderState( + action: Action.newOrder, + status: Status.pending, + order: null, + ); + for (final action in actions) { + state = state.updateWith(_msg(action)); + } + return state; +} + +void main() { + group('restoreHasFailedPayoutSignal', () { + test('returns false for empty history', () { + expect(restoreHasFailedPayoutSignal([]), isFalse); + }); + + test('returns true when payment-failed is present', () { + expect( + restoreHasFailedPayoutSignal([_msg(Action.paymentFailed, timestamp: 1)]), + isTrue, + ); + }); + + test('returns true for a re-sent add-invoice (storage cleared on restore)', + () { + expect( + restoreHasFailedPayoutSignal([_msg(Action.addInvoice, timestamp: 1)]), + isTrue, + ); + }); + + test('returns true when add-invoice arrives after the hold was released', + () { + expect( + restoreHasFailedPayoutSignal([ + _msg(Action.released, timestamp: 1), + _msg(Action.addInvoice, timestamp: 2), + ]), + isTrue, + ); + }); + + test('returns false for an early add-invoice that precedes the release', () { + // Happy path where the daemon re-sends full history: the only add-invoice + // is the early waiting-buyer-invoice one, before the release. + expect( + restoreHasFailedPayoutSignal([ + _msg(Action.addInvoice, timestamp: 1), + _msg(Action.released, timestamp: 2), + ]), + isFalse, + ); + }); + + test('returns false for a settled order with no failed-payout signal', () { + expect( + restoreHasFailedPayoutSignal([ + _msg(Action.holdInvoicePaymentSettled, timestamp: 1), + ]), + isFalse, + ); + }); + }); + + group('restore state rebuild for settled-hold-invoice + buyer', () { + test( + 'failed payout replays to payment-failed + add-invoice (new-invoice prompt)', + () { + final state = _replay([Action.paymentFailed, Action.addInvoice]); + + expect(state.status, equals(Status.paymentFailed)); + expect(state.action, equals(Action.addInvoice)); + }); + + test('happy path replays to settled-hold-invoice + released (paying sats)', + () { + final state = _replay([Action.released]); + + expect(state.status, equals(Status.settledHoldInvoice)); + expect(state.action, equals(Action.released)); + }); + }); +}