Skip to content
Merged
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
2 changes: 2 additions & 0 deletions integration_test/test_helpers.dart
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,8 @@ class FakeMostroService implements MostroService {

@override
Future<void> sendInvoice(String orderId, String invoice, int? amount) async {}
@override
Future<void> sendBondPayoutInvoice(String orderId, String invoice) async {}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This fake is a no-op, so the integration harness cannot exercise the new bond payout submit path. Please mirror the production behavior here (or provide a dedicated test double) so the new flow can actually be covered.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Suggestion: the fake is still a no-op, so the new bond-payout flow is not exercised end-to-end in integration tests. If you want the new coverage to prove the full path, mirror the production side effect here (store the outbound addBondInvoice) or add a dedicated test double for this flow.


@override
Future<void> cancelOrder(String orderId) async {}
Expand Down
12 changes: 12 additions & 0 deletions lib/core/app_routes.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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/bond_payout_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';
Expand Down Expand Up @@ -297,6 +298,17 @@ GoRouter createRouter(WidgetRef ref) {
),
),
),
GoRoute(
path: '/bond_payout/:orderId',
pageBuilder: (context, state) =>
buildPageWithDefaultTransition<void>(
context: context,
state: state,
child: BondPayoutInvoiceScreen(
orderId: state.pathParameters['orderId']!,
),
),
),
GoRoute(
path: '/add_invoice/:orderId',
pageBuilder: (context, state) =>
Expand Down
1 change: 1 addition & 0 deletions lib/data/models.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
122 changes: 122 additions & 0 deletions lib/data/models/bond_payout_request.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import 'package:mostro_mobile/data/models/enums/order_type.dart';
import 'package:mostro_mobile/data/models/payload.dart';

class BondPayoutRequest implements Payload {
final BondPayoutOrder order;
final int slashedAt;

const BondPayoutRequest({
required this.order,
required this.slashedAt,
});

@override
String get type => 'bond_payout_request';

@override
Map<String, dynamic> toJson() => {
type: {
'order': order.toJson(),
'slashed_at': slashedAt,
},
};

factory BondPayoutRequest.fromJson(Map<String, dynamic> json) {
final orderJson = json['order'];
if (orderJson is! Map<String, dynamic>) {
throw const FormatException(
'BondPayoutRequest: missing or invalid order field');
}
final slashedAt = json['slashed_at'];
if (slashedAt is! int) {
throw const FormatException(
'BondPayoutRequest: missing or invalid slashed_at field');
}
return BondPayoutRequest(
order: BondPayoutOrder.fromJson(orderJson),
slashedAt: slashedAt,
);
}
}

class BondPayoutOrder {
final String? id;
final OrderType kind;
final int amount;
final String fiatCode;
final int? minAmount;
final int? maxAmount;
final int fiatAmount;
final String paymentMethod;
final int premium;

const BondPayoutOrder({
this.id,
required this.kind,
required this.amount,
required this.fiatCode,
this.minAmount,
this.maxAmount,
required this.fiatAmount,
required this.paymentMethod,
this.premium = 0,
});

Map<String, dynamic> toJson() => {
'id': id,
'kind': kind.value,
'status': null,
'amount': amount,
'fiat_code': fiatCode,
'min_amount': minAmount,
'max_amount': maxAmount,
'fiat_amount': fiatAmount,
'payment_method': paymentMethod,
'premium': premium,
'created_at': null,
'expires_at': null,
};

factory BondPayoutOrder.fromJson(Map<String, dynamic> json) {
int parseInt(String field) {
final value = json[field];
if (value == null) {
throw FormatException('BondPayoutOrder: missing $field');
}
if (value is int) return value;
if (value is String) {
final parsed = int.tryParse(value);
if (parsed != null) return parsed;
}
throw FormatException('BondPayoutOrder: invalid $field: $value');
}

int? parseOptionalInt(String field) {
final value = json[field];
if (value == null) return null;
if (value is int) return value;
if (value is String) return int.tryParse(value);
return null;
}

String parseString(String field) {
final value = json[field];
if (value == null) {
throw FormatException('BondPayoutOrder: missing $field');
}
return value.toString();
}

return BondPayoutOrder(
id: json['id']?.toString(),
kind: OrderType.fromString(parseString('kind')),
amount: parseInt('amount'),
fiatCode: parseString('fiat_code'),
minAmount: parseOptionalInt('min_amount'),
maxAmount: parseOptionalInt('max_amount'),
fiatAmount: parseInt('fiat_amount'),
paymentMethod: parseString('payment_method'),
premium: parseOptionalInt('premium') ?? 0,
);
}
}
1 change: 1 addition & 0 deletions lib/data/models/enums/action.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ enum Action {
takeBuy('take-buy'),
payInvoice('pay-invoice'),
payBondInvoice('pay-bond-invoice'),
addBondInvoice('add-bond-invoice'),
fiatSent('fiat-sent'),
fiatSentOk('fiat-sent-ok'),
release('release'),
Expand Down
3 changes: 3 additions & 0 deletions lib/data/models/payload.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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')) {
Expand Down
26 changes: 25 additions & 1 deletion lib/features/mostro/mostro_instance.dart
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ class MostroInstance {
final String lndNodeUri;
final String fiatCurrenciesAccepted;
final int maxOrdersPerResponse;
final int bondPayoutClaimWindowDays;

MostroInstance(
this.pubKey,
Expand All @@ -45,6 +46,7 @@ class MostroInstance {
this.lndNodeUri,
this.fiatCurrenciesAccepted,
this.maxOrdersPerResponse,
this.bondPayoutClaimWindowDays,
);

factory MostroInstance.fromEvent(NostrEvent event) {
Expand All @@ -70,16 +72,29 @@ class MostroInstance {
event.lndNodeUri,
event.fiatCurrenciesAccepted,
event.maxOrdersPerResponse,
event.bondPayoutClaimWindowDays,
);
}
}

extension MostroInstanceExtensions on NostrEvent {
String _getTagValue(String key) {
final tag = tags?.firstWhere((t) => t[0] == key, orElse: () => []);
final tag = tags?.firstWhere(
(t) => t.isNotEmpty && t[0] == key,
orElse: () => const [],
);
return (tag != null && tag.length > 1) ? tag[1] : 'Tag: $key not found';
}

String? _getOptionalTagValue(String key) {
final tag = tags?.firstWhere(
(t) => t.isNotEmpty && t[0] == key,
orElse: () => const [],
);
if (tag == null || tag.length < 2) return null;
return tag[1];
}

String get pubKey => _getTagValue('d');
String get mostroVersion => _getTagValue('mostro_version');
String get commitHash => _getTagValue('mostro_commit_hash');
Expand All @@ -104,4 +119,13 @@ 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'));

/// Days from `slashed_at` to claim a slashed-bond share before forfeit.
int get bondPayoutClaimWindowDays {
final raw = _getOptionalTagValue('bond_payout_claim_window_days');
if (raw == null) return 15;
final parsed = int.tryParse(raw);
if (parsed == null || parsed <= 0) return 15;
return parsed;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,9 @@ class NotificationDataExtractor {
// This action doesn't generate notifications
return null;

case Action.addBondInvoice:
return null;

default:
// Unknown actions generate temporary notifications
isTemporary = true;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ class NotificationMessageMapper {
case mostro.Action.restore:
case mostro.Action.orders:
case mostro.Action.lastTradeIndex:
case mostro.Action.addBondInvoice:
return 'TODO: implement title key if needed';
Comment thread
Catrya marked this conversation as resolved.
}
}
Expand Down Expand Up @@ -197,8 +198,9 @@ class NotificationMessageMapper {
case mostro.Action.restore:
case mostro.Action.orders:
case mostro.Action.lastTradeIndex:
case mostro.Action.addBondInvoice:
return 'TODO: implement message key if needed';

}
}

Expand Down
1 change: 1 addition & 0 deletions lib/features/notifications/widgets/notification_item.dart
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ class NotificationItem extends ConsumerWidget {
case mostro_action.Action.restore:
case mostro_action.Action.orders:
case mostro_action.Action.lastTradeIndex:
case mostro_action.Action.addBondInvoice:
break;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}
Expand Down
1 change: 1 addition & 0 deletions lib/features/order/models/order_state.dart
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,7 @@ class OrderState {
case Action.sendDm:
case Action.tradePubkey:
case Action.adminAddSolver:
case Action.addBondInvoice:
return payloadStatus ?? status;

// For actions that include Order payload, use the payload status
Expand Down
12 changes: 12 additions & 0 deletions lib/features/order/notifiers/abstract_mostro_notifier.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:mostro_mobile/data/enums.dart';
import 'package:mostro_mobile/data/models.dart';
import 'package:mostro_mobile/features/mostro/mostro_instance.dart';
import 'package:mostro_mobile/features/order/models/order_state.dart';
import 'package:mostro_mobile/features/restore/restore_mode_provider.dart';
import 'package:mostro_mobile/shared/providers.dart';
Expand All @@ -12,6 +13,7 @@ import 'package:mostro_mobile/features/notifications/providers/notifications_pro
import 'package:mostro_mobile/features/notifications/utils/notification_data_extractor.dart';
import 'package:mostro_mobile/features/settings/settings_provider.dart';
import 'package:mostro_mobile/services/logger_service.dart';
import 'package:mostro_mobile/shared/utils/bond_payout_helpers.dart';

class AbstractMostroNotifier extends StateNotifier<OrderState> {
final String orderId;
Expand Down Expand Up @@ -265,6 +267,16 @@ class AbstractMostroNotifier extends StateNotifier<OrderState> {
await _handleAddInvoiceWithAutoLightningAddress(event);
break;

case Action.addBondInvoice:
final request = event.getPayload<BondPayoutRequest>();
if (request == null) break;
final instance = ref.read(orderRepositoryProvider).mostroInstance;
final claimWindowDays = instance?.bondPayoutClaimWindowDays ?? 15;
if (isBondClaimExpired(request.slashedAt, claimWindowDays)) break;
ref.read(sessionNotifierProvider.notifier).saveSession(session);
navProvider.go('/bond_payout/$orderId');
break;

case Action.holdInvoicePaymentAccepted:
final order = event.getPayload<Order>();
if (order == null) return;
Expand Down
15 changes: 15 additions & 0 deletions lib/features/order/notifiers/order_notifier.dart
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,21 @@ class OrderNotifier extends AbstractMostroNotifier {
);
}

Future<void> sendBondPayoutInvoice(String invoice) async {
await mostroService.sendBondPayoutInvoice(orderId, invoice);
final timestamp = DateTime.now().millisecondsSinceEpoch;
final outbound = MostroMessage(
action: Action.addBondInvoice,
id: orderId,
payload: PaymentRequest(lnInvoice: invoice),
timestamp: timestamp,
);
await ref.read(mostroStorageProvider).addMessage(
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Blocking: this still publishes the bond payout invoice first and then swallows any storage failure. If addMessage fails, the app has already told the user success and navigated away, but the local history does not contain the claim reply. Please make the send + persist step fail visibly to the caller, or otherwise guarantee durability before treating the action as complete.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was already addressed in commit f57c075 (currently on the
branch). The try/catch around addMessage was removed — the diff is
+4/-12, full removal of the swallow:

-    try {
-      await ref.read(mostroStorageProvider).addMessage(...);
-    } catch (e, stack) {
-      logger.e('Failed to persist outbound add-bond-invoice...', ...);
-    }
+    await ref.read(mostroStorageProvider).addMessage(...);

The current behavior is:

  1. order_notifier.dart:120-133 — sendBondPayoutInvoice awaits both
    the publish and the addMessage. No try/catch. A storage failure
    throws out of the method.
  2. bond_payout_invoice_screen.dart:209-228 — the caller awaits
    sendBondPayoutInvoice inside a try. context.go('/') is inside
    that try, after the await. If addMessage throws, the navigation
    does not run; the catch fires, the button is re-enabled, and
    SnackBarHelper shows addBondInvoiceFailedToSubmit.

So the user is not told "success" and not navigated away when
persistence fails — they stay on the form with a visible error.

Could you point at the specific line where you think the failure is
still being swallowed? My read is that this objection is matching
the pre-fix code; if I'm missing a real path where the persist
failure goes silent, I'd like to see it.

'outbound_addBondInvoice_${orderId}_$timestamp',
outbound,
);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

Future<void> cancelOrder() async {
await mostroService.cancelOrder(orderId);
}
Expand Down
Loading
Loading