Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 110 additions & 15 deletions lib/features/restore/restore_manager.dart
Original file line number Diff line number Diff line change
Expand Up @@ -838,23 +838,71 @@ class RestoreService {
}
}

// Create regular order message with Order payload
final mostroMessage = MostroMessage<Order>(
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.
// Seed replay timestamps from the order creation time so the
// reconstructed happy-path message keeps its historical ordering.
List<Action> actionsToApply = [action];
int baseTimestamp =
orderDetail.createdAt ?? DateTime.now().millisecondsSinceEpoch;
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];
// Stamp the replayed pair after every message already in
// storage so a later OrderNotifier.sync() (which replays storage
// sorted by timestamp) applies them last and converges to
// payment-failed. Seeding from createdAt instead would let an
// older re-delivered `released`/`settled` replay after the pair
// and drag the buyer back to "paying sats", making the fix
// non-persistent across provider recreation or app restart.
baseTimestamp = messages.fold<int>(
baseTimestamp,
(max, m) => (m.timestamp ?? 0) > max ? m.timestamp! : max,
) +
1;
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.
for (var i = 0; i < actionsToApply.length; i++) {
final replayAction = actionsToApply[i];
final mostroMessage = MostroMessage<Order>(
id: orderDetail.id,
action: replayAction,
payload: order,
timestamp: baseTimestamp + i,
Comment thread
AndreaDiazCorreia marked this conversation as resolved.
);

// 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(
Expand Down Expand Up @@ -1170,6 +1218,53 @@ Future<Map<String, dynamic>> decodeRestoreMessage(
return contentList[0] as Map<String, dynamic>;
}

/// 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, awaiting a new invoice". The
/// protocol distinguishes them only via the action, and `payment-failed` is a
/// client-synthesized status that never travels on the wire, so the snapshot
/// status alone cannot recover the failed substate.
///
/// Invariant this relies on (mostro#754): on `restore-session` the daemon
/// re-enqueues ONLY `add-invoice`, and only for orders flagged
/// `failed_payment = true` in `settled-hold-invoice`. It does NOT re-send
/// `payment-failed`, `released` or `settled`. Restore also clears local storage
/// before re-subscribing. A bare `add-invoice` sitting in freshly cleared
/// storage is therefore the daemon's targeted failed-payout prompt, which is
/// why a lone `add-invoice` counts as a signal here. Requiring a preceding
/// `payment-failed`/release for the single-message case would miss this re-send
/// and reintroduce the money-at-risk regression of #615.
///
/// The ordering check keeps this correct if a relay additionally re-delivers
/// the full historical stream: `add-invoice` also appears early in the happy
/// flow (waiting-buyer-invoice), so it is trusted as a failed-payout signal
/// only when no release/settle precedes it (the daemon's fresh re-send) or when
/// it arrives after one. `payment-failed`, if ever present, is definitive on
/// its own.
bool restoreHasFailedPayoutSignal(List<MostroMessage> 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;
Comment thread
AndreaDiazCorreia marked this conversation as resolved.
Comment thread
AndreaDiazCorreia marked this conversation as resolved.
}
return false;
}

/// Thrown when Mostro responds with cant-do: invalid_trade_index to
/// Action.lastTradeIndex during the restore flow.
class RestoreInvalidTradeIndexException implements Exception {
Expand Down
119 changes: 119 additions & 0 deletions test/features/restore/restore_failed_payout_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
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<Order> _msg(Action action, {int? timestamp}) =>
MostroMessage<Order>(
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<Action> 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,
);
});

// Per mostro#754 the daemon re-sends ONLY a bare `add-invoice` (no
// `payment-failed`, no `released`) for a failed payout, and restore clears
// storage first, so a lone post-clear `add-invoice` is the failed-payout
// prompt and must be honored. Weakening this to require `payment-failed` or
// a preceding release would reintroduce the money-at-risk bug in #615.
test('lone re-sent add-invoice is a failed-payout signal (mostro#754)', () {
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));
});
});
}
Loading