Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
372e2be
feat: add pay-bond-invoice screen for taker anti-abuse bond payment
AndreaDiazCorreia Jul 23, 2026
315412b
feat: separate bond invoice from escrow hold invoice in trade flow
AndreaDiazCorreia Jul 23, 2026
45725ad
feat: prevent add-invoice from advancing settled trades awaiting payo…
AndreaDiazCorreia Jul 24, 2026
8c7ae90
refactor: clarify bond invoice routing in take_matching_add_invoice d…
AndreaDiazCorreia Jul 24, 2026
45df283
feat: add bond_amount_sats field to prevent bond amount from overwrit…
AndreaDiazCorreia Jul 24, 2026
f5f3a4e
feat: add waitingBond status to trade detail screen for taker bond pa…
AndreaDiazCorreia Jul 24, 2026
8575b41
docs: document WaitingTakerBond status and bond invoice fields in spec
AndreaDiazCorreia Jul 24, 2026
8bb24b5
feat: add instruction and timer label for waitingBond status in trade…
AndreaDiazCorreia Jul 24, 2026
0deff5c
feat: prevent public order book from downgrading fine-grained trade s…
AndreaDiazCorreia Jul 24, 2026
584d666
refactor: simplify bond invoice documentation and comments
AndreaDiazCorreia Jul 24, 2026
20ea565
test: verify bond status survives coarse public status updates and ad…
AndreaDiazCorreia Jul 24, 2026
331bde8
feat: add cancel button to bond payment screen to release unpaid bond…
AndreaDiazCorreia Jul 25, 2026
b770403
feat: distinguish bond back-out from cooperative cancel in UI messagi…
AndreaDiazCorreia Jul 25, 2026
9990f14
refactor: extract waiting indicator into reusable widget method in pa…
AndreaDiazCorreia Jul 29, 2026
6475278
refactor: extract cancel button into reusable widget method in pay_bo…
AndreaDiazCorreia Jul 29, 2026
cdd737f
refactor: add error handling to trade state provider polling loops
AndreaDiazCorreia Jul 29, 2026
63e4841
refactor: apply fine-grained trade status guard to order book cache i…
AndreaDiazCorreia Jul 30, 2026
3c4456d
refactor: classify cancel by ownership to preserve maker's order afte…
AndreaDiazCorreia Jul 30, 2026
70cde30
feat: add payment phase tracking and back-out guard to bond payment s…
AndreaDiazCorreia Jul 30, 2026
d13122b
refactor: resolve bond timeout vs listing expiry deadline based on tr…
AndreaDiazCorreia Jul 30, 2026
4ae9f55
refactor: clarify trade amount provider fallback logic and update can…
AndreaDiazCorreia Jul 30, 2026
6bb4ae7
refactor: extract cancel classification logic into dedicated helpers …
AndreaDiazCorreia Jul 30, 2026
d38228a
refactor: introduce bridge provider seam for Rust API calls to enable…
AndreaDiazCorreia Jul 30, 2026
d5eb268
refactor: replace snackbar with leave-only dialog when bond payment o…
AndreaDiazCorreia Jul 30, 2026
be75e6b
refactor: inject bridge providers into tradeStatusProvider and update…
AndreaDiazCorreia Jul 30, 2026
b7834f9
Merge remote-tracking branch 'origin/main' into feat/taker-pay-bond-i…
AndreaDiazCorreia Jul 30, 2026
d275030
fix(tests): add missing bond and rating fields to test fixtures
AndreaDiazCorreia Jul 30, 2026
c495a88
Merge remote-tracking branch 'origin/main' into feat/taker-pay-bond-i…
AndreaDiazCorreia Jul 30, 2026
d491693
fix(orders): let timed-out taker send the maker back to Pending
AndreaDiazCorreia Jul 31, 2026
2d734e4
fix(orders): apply the same sync policy to bulk-feed maker updates
AndreaDiazCorreia Aug 3, 2026
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
10 changes: 10 additions & 0 deletions lib/core/app_routes.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import 'package:mostro/features/home/screens/home_screen.dart';
import 'package:mostro/features/notifications/screens/notifications_screen.dart';
import 'package:mostro/features/order/screens/add_lightning_invoice_screen.dart';
import 'package:mostro/features/order/screens/add_order_screen.dart';
import 'package:mostro/features/order/screens/pay_bond_invoice_screen.dart';
import 'package:mostro/features/order/screens/pay_lightning_invoice_screen.dart';
import 'package:mostro/features/order/screens/my_order_screen.dart';
import 'package:mostro/features/order/screens/take_order_screen.dart';
Expand Down Expand Up @@ -35,6 +36,7 @@ abstract final class AppRoute {
static const takeSell = '/take_sell/:orderId';
static const takeBuy = '/take_buy/:orderId';
static const payInvoice = '/pay_invoice/:orderId';
static const payBondInvoice = '/pay_bond_invoice/:orderId';
static const addInvoice = '/add_invoice/:orderId';
static const tradeDetail = '/trade_detail/:orderId';
static const chatList = '/chat_list';
Expand All @@ -59,6 +61,8 @@ abstract final class AppRoute {
static String takeSellPath(String orderId) => '/take_sell/$orderId';
static String takeBuyPath(String orderId) => '/take_buy/$orderId';
static String payInvoicePath(String orderId) => '/pay_invoice/$orderId';
static String payBondInvoicePath(String orderId) =>
'/pay_bond_invoice/$orderId';
static String addInvoicePath(String orderId) => '/add_invoice/$orderId';
static String chatRoomPath(String orderId) => '/chat_room/$orderId';
static String rateUserPath(String orderId) => '/rate_user/$orderId';
Expand Down Expand Up @@ -148,6 +152,12 @@ final GoRouter appRouter = GoRouter(
orderId: state.pathParameters['orderId']!,
),
),
GoRoute(
path: AppRoute.payBondInvoice,
builder: (context, state) => PayBondInvoiceScreen(
orderId: state.pathParameters['orderId']!,
),
),
GoRoute(
path: AppRoute.addInvoice,
builder: (context, state) => AddLightningInvoiceScreen(
Expand Down
88 changes: 68 additions & 20 deletions lib/features/order/providers/trade_state_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,50 +3,78 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:mostro/src/rust/api/orders.dart' as orders_api;
import 'package:mostro/src/rust/api/types.dart';

/// Seam over the Rust bridge: the accessors below are read through providers so
/// tests can substitute them, since the bridge is uninitialised under
/// `flutter test`.
final bridgeListTradesProvider =
Provider<Future<List<TradeInfo>> Function()>((ref) => orders_api.listTrades);

final bridgeGetOrderProvider = Provider<Future<OrderInfo?> Function(String)>(
(ref) => (orderId) => orders_api.getOrder(orderId: orderId),
);

/// Maps `orderId` → whether the local user is the buyer in that trade.
///
/// Set this before navigating to [AddLightningInvoiceScreen] or
/// [TradeDetailScreen] so those screens know the user's role.
final tradeRoleProvider =
StateProvider<Map<String, bool>>((ref) => const {});

/// Poll `getOrder()` every 2 s until `amountSats` is non-null, then stop.
/// Poll every 2 s until the trade's `amountSats` is non-null, then stop.
///
/// Returns `null` while waiting. Useful for the add-invoice screen which
/// needs the sats amount before it can submit a Lightning invoice.
/// Sizes the buyer's add-invoice. A persisted trade row is authoritative: its
/// amount is the daemon's calculated per-role sats, so keep polling until it
/// arrives. The order book only answers when we follow no trade, since its
/// amount is the coarse public 38383 figure — emitting that would stop the
/// polling with the wrong invoice amount. See [tradeStatusProvider].
final tradeAmountProvider =
StreamProvider.family.autoDispose<BigInt?, String>((ref, orderId) async* {
final listTrades = ref.read(bridgeListTradesProvider);
final getOrder = ref.read(bridgeGetOrderProvider);
while (true) {
final info = await orders_api.getOrder(orderId: orderId);
final sats = info?.amountSats;
yield sats;
if (sats != null) return; // done — no need to keep polling
try {
final trades = await listTrades();
final trade = trades.where((t) => t.order.id == orderId).firstOrNull;
final sats =
trade != null ? trade.order.amountSats : (await getOrder(orderId))?.amountSats;
yield sats;
if (sats != null) return; // done — no need to keep polling
} catch (e, st) {
debugPrint('[tradeAmountProvider] poll failed: $e\n$st');
}
await Future.delayed(const Duration(seconds: 2));
}
});

/// Live order status for a single trade, polled from the order book every 2 s.
/// Live protocol status for a single tracked trade, polled every 2 s.
///
/// Starts with an immediate fetch (no initial delay) so the first emission
/// reflects the real relay status. When the order is no longer in the in-memory
/// order book (e.g. after cancellation), falls back to the persisted trade DB
/// so terminal statuses like Canceled are reflected in the UI.
/// Prefers the persisted trade row over the in-memory order book: the book
/// carries the daemon's coarse public NIP-69 status (Kind 38383), which
/// collapses fine-grained states (e.g. `WaitingTakerBond` → `pending`,
/// `WaitingBuyerInvoice`/`WaitingPayment` → `in-progress`), whereas the trade
/// row holds the authoritative gift-wrap status. Falls back to the book only
/// when no local trade row exists.
final tradeStatusProvider =
StreamProvider.family.autoDispose<OrderStatus, String>((ref, orderId) async* {
final listTrades = ref.read(bridgeListTradesProvider);
final getOrder = ref.read(bridgeGetOrderProvider);
while (true) {
final info = await orders_api.getOrder(orderId: orderId);
if (info != null) {
yield info.status;
if (_isTerminal(info.status)) return;
} else {
// Order removed from in-memory book — check the persisted trade DB.
final trades = await orders_api.listTrades();
try {
final trades = await listTrades();
final trade = trades.where((t) => t.order.id == orderId).firstOrNull;
if (trade != null) {
yield trade.order.status;
// Terminal status — no need to keep polling.
if (_isTerminal(trade.order.status)) return;
} else {
// No local trade row — fall back to the public order book.
final info = await getOrder(orderId);
if (info != null) {
yield info.status;
if (_isTerminal(info.status)) return;
}
}
} catch (e, st) {
debugPrint('[tradeStatusProvider] poll failed: $e\n$st');
}
await Future.delayed(const Duration(seconds: 2));
}
Expand Down Expand Up @@ -127,3 +155,23 @@ final tradeInfoStreamProvider =
await Future.delayed(const Duration(seconds: 1));
}
});

/// Poll `listTrades()` every 1 s until `bondInvoice` is non-null, then stop.
///
/// Used by [PayBondInvoiceScreen]; separate from [tradeInfoStreamProvider]
/// (which keys on `holdInvoice`) because a taken bond order carries its invoice
/// in `bondInvoice` and leaves `holdInvoice` null until it advances.
final tradeBondInfoProvider =
StreamProvider.family.autoDispose<TradeInfo?, String>((ref, orderId) async* {
while (true) {
try {
final trades = await orders_api.listTrades();
final trade = trades.where((t) => t.order.id == orderId).firstOrNull;
yield trade;
if (trade?.bondInvoice != null) return;
} catch (e, st) {
debugPrint('[tradeBondInfoProvider] listTrades failed: $e\n$st');
}
await Future.delayed(const Duration(seconds: 1));
}
});
Loading
Loading