Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
b294645
fix(orders): leave a never-active take for the daemon's Canceled to wipe
Catrya Sep 10, 2026
0bf95aa
fix(orders): hand a lost take's order back to the public book
Catrya Sep 10, 2026
392d7c6
fix(orders): a confirmed take replaces the order's earlier row
Catrya Sep 10, 2026
a6bf029
docs(orders): cancel/retake contract and the #375 leftovers
Catrya Sep 10, 2026
9a22893
chore(orders): log where a lost take's book entry lands
Catrya Sep 10, 2026
b06ad0a
fix(orders): a never-active trade is wiped by whichever cancel signal…
Catrya Sep 11, 2026
3ad3aa7
fix(orders): drop a wire note once nothing can read it (review round 1)
Catrya Sep 11, 2026
84fea17
test(orders): wait for the wipe before asserting it in the retake E2E
Catrya Sep 11, 2026
77c7872
fix(trades): leave the trade screen once the trade is no longer yours
Catrya Sep 12, 2026
ef2a061
fix(trades): the cancel dialog says what the cancel does
Catrya Sep 12, 2026
8ccbdaa
fix(orders): a d-tag task stops once its node is no longer the active…
Catrya Sep 12, 2026
46fa2a6
Merge origin/main into fix-retake-stale-session
Catrya Sep 12, 2026
d5b5198
fix(trades): decide the cancel on the live status, and wait for the book
Catrya Sep 12, 2026
44a236f
Merge origin/main into fix-retake-stale-session
Catrya Sep 12, 2026
774ce2a
fix(orders): a retake replaces the earlier take's d-tag task
Catrya Sep 12, 2026
4a13aa8
fix(orders): answer the strict review's #2, #3, #4 and #6
Catrya Sep 12, 2026
06e1be1
fix(orders): answer the strict review's #5 and #7, and pin the in-fli…
Catrya Sep 12, 2026
6c1ddfd
Merge origin/main into fix-retake-stale-session
Catrya Sep 12, 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
5 changes: 5 additions & 0 deletions lib/features/order/providers/trade_state_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,11 @@ final releaseOrderActionProvider = Provider<Future<void> Function(String)>(
(ref) => (orderId) => orders_api.releaseOrder(orderId: orderId),
);

/// Publishes a cancel for the order; the daemon's answer arrives later.
final cancelOrderActionProvider = Provider<Future<void> Function(String)>(
(ref) => (orderId) => orders_api.cancelOrder(orderId: orderId),
);

/// Live order status for a single trade, polled from the order book every 2 s.
///
/// Starts with an immediate fetch (no initial delay) so the first emission
Expand Down
4 changes: 3 additions & 1 deletion lib/features/order/screens/add_lightning_invoice_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,9 @@ class _AddLightningInvoiceScreenState
builder:
(ctx) => AlertDialog(
title: Text(l10n.cancelTradeDialogTitle),
content: Text(l10n.cancelTradeDialogContent),
// This screen only exists before the trade goes active, where
// mostrod cancels at once — no cooperative request.
content: Text(l10n.cancelTradeDialogContentNotStarted),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
Expand Down
8 changes: 5 additions & 3 deletions lib/features/order/screens/my_order_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ import 'package:mostro/features/order/widgets/order_detail_cards.dart';
import 'package:mostro/features/trades/providers/trades_providers.dart';
import 'package:mostro/l10n/app_localizations.dart';
import 'package:mostro/shared/utils/fiat_currencies.dart';
import 'package:mostro/src/rust/api/orders.dart' as orders_api;

/// Detail screen for an order created by the current user (handoff 6a/6b).
///
Expand Down Expand Up @@ -54,8 +53,11 @@ class _MyOrderScreenState extends ConsumerState<MyOrderScreen> {

setState(() => _cancelling = true);
try {
await orders_api.cancelOrder(orderId: widget.orderId);
// Force the trades list to reload from DB so the Canceled status shows.
await ref.read(cancelOrderActionProvider)(widget.orderId);
// Reload the trades list now. A pending order never went active, so
// the row is not marked Canceled locally: the daemon's Canceled or its
// public `canceled`, whichever lands first, wipes it, and the
// TradeUpdate that follows reloads the list again.
ref.invalidate(rawTradesProvider);
if (!mounted) return;
showOrderDetailSnackBar(
Expand Down
4 changes: 3 additions & 1 deletion lib/features/order/screens/pay_lightning_invoice_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,9 @@ class _PayLightningInvoiceScreenState
builder:
(ctx) => AlertDialog(
title: Text(l10n.cancelTradeDialogTitle),
content: Text(l10n.cancelTradeDialogContent),
// This screen only exists before the trade goes active, where
// mostrod cancels at once — no cooperative request.
content: Text(l10n.cancelTradeDialogContentNotStarted),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
Expand Down
105 changes: 100 additions & 5 deletions lib/features/trades/screens/trade_detail_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -138,14 +138,79 @@ class _TradeDetailScreenState extends ConsumerState<TradeDetailScreen> {

// ── Actions ──────────────────────────────────────────────────────────────

Future<void> _cancelOrder() async {
/// Set once the screen has decided to leave, so no rebuild in between
/// navigates twice.
bool _leaving = false;

/// Back to home with [message], at most once.
void _leave(String message) {
if (_leaving || !mounted) return;
_leaving = true;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
context.go(AppRoute.home);
}

/// Whether a cancel in [status] ends the trade outright. Mirrors Rust's
/// `cancellation_wipes_history`: before `active` mostrod cancels at once —
/// a take hands the order back to the book, a maker's order dies — and the
/// trade row is wiped. From `active` on it is a cooperative request, and
/// `inProgress` may be either. Kept equal to the Rust predicate by
/// `the_trade_screen_copy_of_cancellation_wipes_history_matches`
/// (`rust/src/mostro/status.rs`), which reads this set from source: keep
/// the `=> const {…}.contains(status)` shape.
static bool _cancelEndsTrade(TradeStatus status) => const {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

MEDIUM — a cross-language invariant with nothing holding the two halves together.

_cancelEndsTrade is a hand-copy of Rust's cancellation_wipes_history (rust/src/mostro/status.rs:137), and the doc comment says so. The comment is the only link: add a status to the Rust predicate and this set silently stops matching, at which point the dialog tells the user their cancel is immediate when it is a cooperative request, or the screen stays open on a trade that was wiped. Both are user-visible and neither fails a test.

CLAUDE.md's golden rule points the same way — this is protocol logic, not UI state.

Options, cheapest first:

  • Export the predicate over the bridge (rust/src/api/) and have the UI ask, which removes the duplicate outright;
  • or, if a bridge call per dialog is unwanted, add a test that pins the Dart set against the statuses the Rust side treats as never-active, so a divergence fails CI rather than a trade.

TradeStatus.pending,
TradeStatus.waitingInvoice,
TradeStatus.waitingPayment,
}.contains(status);

/// What a cancel in [status] does, as the confirmation dialog tells it.
/// Before `active` mostrod cancels at once; from `active` on it is a
/// cooperative request; `inProgress` only says the order was taken, so it
/// may be either (#203).
static String _cancelDialogContent(
AppLocalizations l10n,
TradeStatus status,
) {
if (_cancelEndsTrade(status)) {
return l10n.cancelTradeDialogContentNotStarted;
}
if (status == TradeStatus.inProgress) {
return l10n.cancelTradeDialogContentMaybeStarted;
}
return l10n.cancelTradeDialogContent;
}

/// The trade's status now, from the live provider; [fallback] while it has
/// no value yet. The status a callback was built with goes stale across an
/// await: the seller's payment can land while the cancel dialog is open.
TradeStatus _liveStatus(TradeStatus fallback) {
final live = ref.read(tradeStatusProvider(widget.orderId)).valueOrNull;
return live == null ? fallback : tradeStatusFromOrderStatus(live);
}

Future<void> _cancelOrder(TradeStatus status) async {
final l10n = AppLocalizations.of(context);
final confirmed = await showDialog<bool>(
context: context,
builder:
(ctx) => AlertDialog(
title: Text(l10n.cancelTradeDialogTitle),
content: Text(l10n.cancelTradeDialogContent),
// Follows the live status, so the copy the user confirms is the
// cancel the daemon will apply.
content: Consumer(
builder: (context, dialogRef, child) {
final live =
dialogRef
.watch(tradeStatusProvider(widget.orderId))
.valueOrNull;
final now =
live == null ? status : tradeStatusFromOrderStatus(live);
return Text(_cancelDialogContent(l10n, now));
},
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
Expand All @@ -163,9 +228,17 @@ class _TradeDetailScreenState extends ConsumerState<TradeDetailScreen> {
throw const MostroActionAborted();
}
try {
await orders_api.cancelOrder(orderId: widget.orderId);
await ref.read(cancelOrderActionProvider)(widget.orderId);
ref.invalidate(rawTradesProvider);
if (!mounted) return;
// Decided on the status the cancel was sent in, not the one the button
// was built with: a trade that went active meanwhile is a cooperative
// request and stays open.
if (_cancelEndsTrade(_liveStatus(status))) {
// Nothing is left to follow here: leave, as the invoice screens do.
_leave(l10n.cancelRequestSent);
return;
}
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(l10n.cancelRequestSent)));
Expand Down Expand Up @@ -394,14 +467,36 @@ class _TradeDetailScreenState extends ConsumerState<TradeDetailScreen> {
// Counterpart reputation snapshot persisted from the daemon's follow-up
// Peer DM (#305), via tradeInfoProvider: it refreshes on the TradeUpdate
// the Rust side emits after persisting the snapshot.
final trade = ref.watch(tradeInfoProvider(widget.orderId)).valueOrNull;
final tradeAsync = ref.watch(tradeInfoProvider(widget.orderId));
final trade = tradeAsync.valueOrNull;
final peerRating = trade?.peerRating;
final room =
ref
.watch(chatRoomsNotifierProvider)
.where((r) => r.orderId == widget.orderId)
.firstOrNull;

// No trade row and not the maker: this is no longer a trade of this
// user's. A take lost before going active (its own cancel, a waiting
// timeout, the maker cancelling) is wiped in Rust, and the order is
// handed back to the public book, where it reads `pending` — which this
// screen would render as the user's own published order, cancel button
// included. Leave instead, as the invoice screens do; this also covers
// arriving later from a notification or the chat header. Only on settled
// reads of both the trades list and the order book: no answer yet is
// neither an absent row nor a stranger's order (a cold start can resolve
// the trades before the book's first emission).
if (!_leaving &&
ref.watch(orderBookProvider).hasValue &&
!tradeAsync.isLoading &&
tradeAsync.hasValue &&
trade == null &&
order?.isMine != true) {
Comment thread
Catrya marked this conversation as resolved.
WidgetsBinding.instance.addPostFrameCallback(
(_) => _leave(l10n.tradeNoLongerYours),
);
}

return Scaffold(
backgroundColor: book.bg,
appBar: AppBar(
Expand Down Expand Up @@ -829,7 +924,7 @@ class _TradeDetailScreenState extends ConsumerState<TradeDetailScreen> {
label:
view.cancelIsFullWidth ? l10n.cancelTradeButton : l10n.cancel,
automationId: AutomationIds.tradeCancel,
onPressed: _cancelOrder,
onPressed: () => _cancelOrder(status),
isDestructive: true,
),
TradeSecondaryAction.dispute => TradeSecondarySpec(
Expand Down
3 changes: 3 additions & 0 deletions lib/l10n/app_de.arb
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,8 @@
"backupRitualSecondFailureMessage": "Das war erneut falsch. Bitte überprüfe und sichere deine geheimen Wörter und verifiziere dann von vorne.",
"cancelTradeDialogTitle": "Handel abbrechen?",
"cancelTradeDialogContent": "Kooperativen Abbruch angefragt. Die andere Partei muss ebenfalls zustimmen, damit der Handel vollständig abgebrochen wird.",
"cancelTradeDialogContentNotStarted": "Der Handel hat noch nicht begonnen und wird daher sofort abgebrochen. Die andere Partei muss nicht zustimmen.",
"cancelTradeDialogContentMaybeStarted": "Hat der Handel noch nicht begonnen, wird er sofort abgebrochen. Hat er bereits begonnen, muss die andere Partei ebenfalls zustimmen.",
"noButtonLabel": "Nein",
"yesButtonLabel": "Ja",
"yesCancelButtonLabel": "Ja, abbrechen",
Expand Down Expand Up @@ -377,6 +379,7 @@
"payWithLightningWallet": "Mit Lightning-Wallet bezahlen",
"noLightningWalletFound": "Keine Lightning-Wallet auf diesem Gerät gefunden",
"orderNoLongerActive": "Diese Bestellung ist nicht mehr aktiv",
"tradeNoLongerYours": "Du bist nicht mehr an diesem Handel beteiligt",
"sessionTimeoutMessage": "Keine Antwort erhalten; prüfe deine Verbindung und versuche es später erneut",
"noIdentityFoundMessage": "Keine Identität gefunden — versuche, die App neu zu starten.",
"failedToLoadSecretWordsMessage": "Geheime Wörter konnten nicht geladen werden. Bitte versuche es erneut.",
Expand Down
16 changes: 14 additions & 2 deletions lib/l10n/app_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -1055,7 +1055,15 @@
},
"cancelTradeDialogContent": "Requesting a cooperative cancel. The other party must also agree for the trade to be fully cancelled.",
"@cancelTradeDialogContent": {
"description": "Body text for the cancel-trade confirmation dialog"
"description": "Body text for the cancel-trade confirmation dialog once the trade is active: the cancel is a cooperative request"
},
"cancelTradeDialogContentNotStarted": "The trade has not started yet, so it is cancelled right away. The other party does not need to agree.",
"@cancelTradeDialogContentNotStarted": {
"description": "Body text for the cancel-trade confirmation dialog before the trade is active (pending, waiting for the invoice or the hold-invoice payment): the daemon cancels at once"
},
"cancelTradeDialogContentMaybeStarted": "If the trade has not started yet, it is cancelled right away. If it has, the other party must also agree.",
"@cancelTradeDialogContentMaybeStarted": {
"description": "Body text for the cancel-trade confirmation dialog while the order is only known to be taken (in progress): the trade may or may not be active yet"
},
"noButtonLabel": "No",
"yesButtonLabel": "Yes",
Expand Down Expand Up @@ -1620,7 +1628,11 @@
},
"orderNoLongerActive": "This order is no longer active",
"@orderNoLongerActive": {
"description": "Neutral notice shown when the order reaches a terminal state (canceled, cooperatively canceled, canceled by admin, or expired) while the user is on the pay invoice screen"
"description": "Neutral notice shown when the order reaches a terminal state (canceled, cooperatively canceled, canceled by admin, or expired) while the user is on the add-invoice or pay-invoice screen"
},
"tradeNoLongerYours": "You're no longer part of this trade",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Must before merge — this region conflicts with main.

main reformatted @orderNoLongerActive (and @cancelTradeDialogContent, further up) into multi-line objects, so both hunks this branch edits now conflict; GitHub reports the PR as not mergeable. One more merge of origin/main resolves it — keep your one-line style for the new @tradeNoLongerYours or match main's, either is fine.

"@tradeNoLongerYours": {
"description": "Notice when the trade screen closes because the user no longer has a trade on this order: a take lost before it went active (their own cancel, a waiting timeout, the maker cancelling), or a later visit to such a trade from a notification. Neutral on purpose: the order may be back in the book or gone for good"
},
"sessionTimeoutMessage": "No response received, check your connection and try again later",
"@sessionTimeoutMessage": {
Expand Down
3 changes: 3 additions & 0 deletions lib/l10n/app_es.arb
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,8 @@
"backupRitualSecondFailureMessage": "Eso fue incorrecto de nuevo. Por favor revisa y respalda tus palabras secretas, luego verifica desde el principio.",
"cancelTradeDialogTitle": "¿Cancelar intercambio?",
"cancelTradeDialogContent": "Se solicita una cancelación cooperativa. La otra parte también debe aceptar para que el intercambio quede cancelado.",
"cancelTradeDialogContentNotStarted": "El intercambio aún no ha comenzado, así que se cancela de inmediato. No hace falta que la otra parte lo acepte.",
"cancelTradeDialogContentMaybeStarted": "Si el intercambio aún no ha comenzado, se cancela de inmediato. Si ya comenzó, la otra parte también debe aceptar.",
"noButtonLabel": "No",
"yesButtonLabel": "Sí",
"yesCancelButtonLabel": "Sí, cancelar",
Expand Down Expand Up @@ -377,6 +379,7 @@
"payWithLightningWallet": "Pagar con wallet Lightning",
"noLightningWalletFound": "No se encontró una wallet Lightning en este dispositivo",
"orderNoLongerActive": "Esta orden ya no está activa",
"tradeNoLongerYours": "Ya no participas en este intercambio",
"sessionTimeoutMessage": "No hubo respuesta, verifica tu conexión e inténtalo más tarde",
"noIdentityFoundMessage": "No se encontró ninguna identidad — intenta reiniciar la app.",
"failedToLoadSecretWordsMessage": "No se pudieron cargar las palabras secretas. Inténtalo de nuevo.",
Expand Down
3 changes: 3 additions & 0 deletions lib/l10n/app_fr.arb
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,8 @@
"backupRitualSecondFailureMessage": "C'est encore incorrect. Veuillez vérifier et sauvegarder vos mots secrets, puis recommencer la vérification depuis le début.",
"cancelTradeDialogTitle": "Annuler l'échange ?",
"cancelTradeDialogContent": "Annulation coopérative demandée. L'autre partie doit également accepter pour que l'échange soit entièrement annulé.",
"cancelTradeDialogContentNotStarted": "L'échange n'a pas encore commencé, il est donc annulé immédiatement. L'autre partie n'a pas besoin d'accepter.",
"cancelTradeDialogContentMaybeStarted": "Si l'échange n'a pas encore commencé, il est annulé immédiatement. S'il a déjà commencé, l'autre partie doit également accepter.",
"noButtonLabel": "Non",
"yesButtonLabel": "Oui",
"yesCancelButtonLabel": "Oui, annuler",
Expand Down Expand Up @@ -377,6 +379,7 @@
"payWithLightningWallet": "Payer avec un portefeuille Lightning",
"noLightningWalletFound": "Aucun portefeuille Lightning trouvé sur cet appareil",
"orderNoLongerActive": "Cet ordre n'est plus actif",
"tradeNoLongerYours": "Vous ne participez plus à cet échange",
"sessionTimeoutMessage": "Aucune réponse reçue, vérifiez votre connexion et réessayez plus tard",
"noIdentityFoundMessage": "Aucune identité trouvée — essayez de redémarrer l'application.",
"failedToLoadSecretWordsMessage": "Échec du chargement des mots secrets. Veuillez réessayer.",
Expand Down
3 changes: 3 additions & 0 deletions lib/l10n/app_it.arb
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,8 @@
"backupRitualSecondFailureMessage": "Di nuovo errato. Per favore controlla e salva le tue parole segrete, poi verifica dall'inizio.",
"cancelTradeDialogTitle": "Annullare lo scambio?",
"cancelTradeDialogContent": "Annullamento cooperativo richiesto. Anche l'altra parte deve accettare affinché lo scambio venga annullato.",
"cancelTradeDialogContentNotStarted": "Lo scambio non è ancora iniziato, quindi viene annullato subito. Non serve che l'altra parte accetti.",
"cancelTradeDialogContentMaybeStarted": "Se lo scambio non è ancora iniziato, viene annullato subito. Se è già iniziato, anche l'altra parte deve accettare.",
"noButtonLabel": "No",
"yesButtonLabel": "Sì",
"yesCancelButtonLabel": "Sì, annulla",
Expand Down Expand Up @@ -377,6 +379,7 @@
"payWithLightningWallet": "Paga con wallet Lightning",
"noLightningWalletFound": "Nessun wallet Lightning trovato su questo dispositivo",
"orderNoLongerActive": "Questo ordine non è più attivo",
"tradeNoLongerYours": "Non partecipi più a questo scambio",
"sessionTimeoutMessage": "Nessuna risposta ricevuta, verifica la tua connessione e riprova più tardi",
"noIdentityFoundMessage": "Nessuna identità trovata — prova a riavviare l'app.",
"failedToLoadSecretWordsMessage": "Impossibile caricare le parole segrete. Riprova.",
Expand Down
Loading
Loading