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
4 changes: 4 additions & 0 deletions integration_test/test_helpers.dart
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,10 @@ class FakeMostroService implements MostroService {
@override
Future<void> sendInvoice(String orderId, String invoice, int? amount) async {}

@override
Future<void> sendBondInvoice(
String orderId, String invoice, int? amount) async {}

@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/add_bond_invoice_screen.dart';
import 'package:mostro_mobile/features/order/screens/pay_bond_invoice_screen.dart';
import 'package:mostro_mobile/features/order/screens/pay_lightning_invoice_screen.dart';
import 'package:mostro_mobile/features/order/screens/take_order_screen.dart';
Expand Down Expand Up @@ -309,6 +310,17 @@ GoRouter createRouter(WidgetRef ref) {
),
),
),
GoRoute(
path: '/add_bond_invoice/:orderId',
pageBuilder: (context, state) =>
buildPageWithDefaultTransition<void>(
context: context,
state: state,
child: AddBondInvoiceScreen(
orderId: state.pathParameters['orderId']!,
),
),
),
GoRoute(
path: '/notifications',
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
78 changes: 78 additions & 0 deletions lib/data/models/bond_payout_request.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import 'package:mostro_mobile/data/models/order.dart';
import 'package:mostro_mobile/data/models/payload.dart';

/// Payload carried by [Action.addBondInvoice]. mostrod asks the non-slashed
/// counterparty for a bolt11 of `order.amount` sats; `slashedAt` (Unix
/// seconds) plus `bond_payout_claim_window_days` from kind-38385 gives the
/// forfeit deadline.
class BondPayoutRequest implements Payload {
final Order order;
final int slashedAt;

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

@override
String get type => 'bond_payout_request';

@override
Map<String, dynamic> toJson() {
// Order.toJson() wraps the fields under an 'order' key (`{order: {...}}`).
// The mostrod wire ships the order's fields directly, so unwrap to keep
// toJson/fromJson symmetric.
final orderJson = order.toJson();
final innerOrder =
(orderJson['order'] as Map<String, dynamic>?) ?? orderJson;
return {
type: {
'order': innerOrder,
'slashed_at': slashedAt,
},
};
}

factory BondPayoutRequest.fromJson(Map<String, dynamic> json) {
try {
final orderJson = json['order'];
if (orderJson is! Map<String, dynamic>) {
throw FormatException(
'Invalid order type: ${orderJson.runtimeType}');
}

final slashedAtValue = json['slashed_at'];
final int slashedAt;
if (slashedAtValue is int) {
slashedAt = slashedAtValue;
} else if (slashedAtValue is String) {
slashedAt = int.tryParse(slashedAtValue) ??
(throw FormatException('Invalid slashed_at format: $slashedAtValue'));
} else {
throw FormatException(
'Invalid slashed_at type: ${slashedAtValue.runtimeType}');
}

return BondPayoutRequest(
order: Order.fromJson(orderJson),
slashedAt: slashedAt,
);
} catch (e) {
throw FormatException('Failed to parse BondPayoutRequest from JSON: $e');
}
}

@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is BondPayoutRequest &&
other.order == order &&
other.slashedAt == slashedAt;
}

@override
int get hashCode => Object.hash(order, slashedAt);

@override
String toString() => 'BondPayoutRequest(order: $order, slashedAt: $slashedAt)';
}
1 change: 1 addition & 0 deletions lib/data/models/enums/action.dart
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ enum Action {
waitingSellerToPay('waiting-seller-to-pay'),
waitingBuyerInvoice('waiting-buyer-invoice'),
addInvoice('add-invoice'),
addBondInvoice('add-bond-invoice'),
buyerTookOrder('buyer-took-order'),
rate('rate'),
rateUser('rate-user'),
Expand Down
9 changes: 6 additions & 3 deletions lib/data/models/order.dart
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,9 @@ class Order implements Payload {
}
}

// Validate required fields
['kind', 'status', 'fiat_code', 'fiat_amount', 'payment_method']
// `status` is intentionally NOT required: the add-bond-invoice payload
// nulls it out while still carrying the rest of the order context.
['kind', 'fiat_code', 'fiat_amount', 'payment_method']
.forEach(validateField);

// Parse and validate integer fields with type safety
Expand Down Expand Up @@ -160,7 +161,9 @@ class Order implements Payload {
return Order(
id: parseOptionalStringField('id'),
kind: OrderType.fromString(parseStringField('kind')),
status: Status.fromString(parseStringField('status')),
status: json['status'] == null
? Status.pending
: Status.fromString(parseStringField('status')),
amount: amount,
fiatCode: parseStringField('fiat_code'),
minAmount: minAmount,
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
16 changes: 16 additions & 0 deletions lib/features/mostro/mostro_instance.dart
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ class MostroInstance {
final String fiatCurrenciesAccepted;
final int maxOrdersPerResponse;

/// Forfeit window for slashed-bond payouts, in days. Null when the
/// daemon doesn't emit `bond_payout_claim_window_days`.
final int? bondPayoutClaimWindowDays;

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

factory MostroInstance.fromEvent(NostrEvent event) {
Expand All @@ -70,6 +75,7 @@ class MostroInstance {
event.lndNodeUri,
event.fiatCurrenciesAccepted,
event.maxOrdersPerResponse,
event.bondPayoutClaimWindowDays,
);
}
}
Expand All @@ -80,6 +86,11 @@ extension MostroInstanceExtensions on NostrEvent {
return (tag != null && tag.length > 1) ? tag[1] : 'Tag: $key not found';
}

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

String get pubKey => _getTagValue('d');
String get mostroVersion => _getTagValue('mostro_version');
String get commitHash => _getTagValue('mostro_commit_hash');
Expand All @@ -104,4 +115,9 @@ extension MostroInstanceExtensions on NostrEvent {
String get lndNodeUri => _getTagValue('lnd_uris');
String get fiatCurrenciesAccepted => _getTagValue('fiat_currencies_accepted');
int get maxOrdersPerResponse => int.parse(_getTagValue('max_orders_per_response'));

int? get bondPayoutClaimWindowDays {
final raw = _getOptionalTagValue('bond_payout_claim_window_days');
return raw == null ? null : int.tryParse(raw);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ class NotificationMessageMapper {
case mostro.Action.waitingBuyerInvoice:
return 'notification_waiting_buyer_invoice_title';
case mostro.Action.addInvoice:
case mostro.Action.addBondInvoice:
return 'notification_add_invoice_title';
case mostro.Action.buyerTookOrder:
return 'notification_buyer_took_order_title';
Expand Down Expand Up @@ -145,6 +146,7 @@ class NotificationMessageMapper {
case mostro.Action.waitingBuyerInvoice:
return 'notification_waiting_buyer_invoice_message';
case mostro.Action.addInvoice:
case mostro.Action.addBondInvoice:
return 'notification_add_invoice_message';
case mostro.Action.buyerTookOrder:
return 'notification_buyer_took_order_message';
Expand Down
3 changes: 3 additions & 0 deletions lib/features/notifications/widgets/notification_item.dart
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,9 @@ class NotificationItem extends ConsumerWidget {
case mostro_action.Action.addInvoice:
context.push('/add_invoice/${notification.orderId}');
break;
case mostro_action.Action.addBondInvoice:
context.push('/add_bond_invoice/${notification.orderId}');
break;
case mostro_action.Action.payBondInvoice:
context.push('/pay_bond/${notification.orderId}');
break;
Expand Down
21 changes: 21 additions & 0 deletions lib/features/order/models/order_state.dart
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,11 @@ class OrderState {
case Action.paymentFailed:
return Status.paymentFailed;

// Bond payout is orthogonal to the trade FSM. Payload status is null
// and would otherwise wipe the real trade status.
case Action.addBondInvoice:
return status;

// Informational actions that should preserve current status
case Action.rateUser:
case Action.invoiceUpdated:
Expand Down Expand Up @@ -481,6 +486,14 @@ class OrderState {
Action.canceled: [],
Action.adminCanceled: [],
Action.cooperativeCancelAccepted: [],
Action.addBondInvoice: [
Action.addBondInvoice,
],
},
Status.settledByAdmin: {
Action.addBondInvoice: [
Action.addBondInvoice,
],
},
Status.cooperativelyCanceled: {
// From active: no fiat sent, so no release button for seller
Expand Down Expand Up @@ -623,6 +636,14 @@ class OrderState {
Action.canceled: [],
Action.adminCanceled: [],
Action.cooperativeCancelAccepted: [],
Action.addBondInvoice: [
Action.addBondInvoice,
],
},
Status.settledByAdmin: {
Action.addBondInvoice: [
Action.addBondInvoice,
],
},
Status.cooperativelyCanceled: {
// From active: no fiat sent, buyer can still send fiat to complete trade
Expand Down
7 changes: 7 additions & 0 deletions lib/features/order/notifiers/abstract_mostro_notifier.dart
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,13 @@ class AbstractMostroNotifier extends StateNotifier<OrderState> {
await _handleAddInvoiceWithAutoLightningAddress(event);
break;

case Action.addBondInvoice:
if (event.payload is BondPayoutRequest) {
navProvider.go('/add_bond_invoice/${event.id!}');
}
ref.read(sessionNotifierProvider.notifier).saveSession(session);
break;

case Action.holdInvoicePaymentAccepted:
final order = event.getPayload<Order>();
if (order == null) return;
Expand Down
12 changes: 12 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,18 @@ class OrderNotifier extends AbstractMostroNotifier {
);
}

Future<void> sendBondInvoice(
String orderId,
String invoice,
int? amount,
) async {
await mostroService.sendBondInvoice(
orderId,
invoice,
amount,
);
}

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