Skip to content
Open
Show file tree
Hide file tree
Changes from 6 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
5 changes: 4 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,10 @@ When implementing or debugging protocol-related features (order flows, actions,
### Nostr Integration
- **NostrService** (`services/nostr_service.dart`) manages relay connections and messaging
- All Nostr protocol interactions go through this service
- **MostroFSM** (`core/mostro_fsm.dart`) manages order state transitions
- **MostroFSM** (`core/mostro_fsm.dart`) defines a transition matrix but is **not wired in** — nothing imports it
- Order status is actually derived by `OrderState._getStatusFromAction` (`features/order/models/order_state.dart`), which maps actions to statuses without consulting the matrix
- Do not read `mostro_fsm.dart` as an active validation layer. Its role axis models who performs an action, not the local user's role in the trade (the app never assigns `Role.admin` to a session), so wiring it as-is would reject legitimate admin resolutions
- The only transition guard that runs today is the dispute-evidence check on `admin-*` actions in `OrderState.updateWith`

### Navigation and UI
- **GoRouter** for navigation (configured in `core/app_routes.dart`)
Expand Down
84 changes: 78 additions & 6 deletions lib/features/order/models/order_state.dart
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,45 @@ class OrderState {
);
}

/// Dispute statuses in which the dispute is over: a resolution has already
/// been applied and no further admin action is expected for it.
static const _terminalDisputeStatuses = {
'resolved',
'seller-refunded',
'closed',
};

static bool _isAdminDisputeAction(Action action) =>
action == Action.adminSettled ||
action == Action.adminSettle ||
action == Action.adminCanceled ||
action == Action.adminCancel ||
action == Action.adminTookDispute ||
action == Action.adminTakeDispute;

/// Whether local state corroborates that a dispute exists for this order.
///
/// The incoming payload deliberately does not count: taking an
/// attacker-supplied Dispute as its own justification is the vector this
/// guards against. An already-resolved dispute is not re-resolved either,
/// which blocks replaying an authentic resolution onto a later state.
bool get _acceptsAdminDisputeAction {
final localDisputeStatus = dispute?.status?.toLowerCase();
if (localDisputeStatus != null &&
_terminalDisputeStatuses.contains(localDisputeStatus)) {
return false;
}
return dispute != null || status == Status.dispute;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}

/// Whether [updateWith] would drop this action for lack of dispute evidence.
///
/// Callers use this to suppress the message's side effects too. A rejected
/// resolution that still raises a notification or navigates would hand the
/// attacker the user-visible half of the forgery.
bool rejectsAdminDisputeAction(Action action) =>
_isAdminDisputeAction(action) && !_acceptsAdminDisputeAction;

OrderState updateWith(MostroMessage message) {
logger.i('Updating OrderState with Action: ${message.action}');

Expand All @@ -101,6 +140,18 @@ class OrderState {
return copyWith(cantDo: message.getPayload<CantDo>());
}

// An admin resolution only means something as the outcome of a dispute that
// already exists. Applied unconditionally, a forged or replayed admin-*
// message flips a live trade to a terminal state and drives the resolution
// UI, so drop it unless local state corroborates the dispute.
if (_isAdminDisputeAction(message.action) && !_acceptsAdminDisputeAction) {
Comment thread
AndreaDiazCorreia marked this conversation as resolved.
logger.w(
'Ignoring ${message.action} for order ${message.id}: no dispute '
'evidence in local state (status: $status, dispute: ${dispute?.status ?? 'none'})',
);
return this;
}

// Track whether fiat was sent at any point in this order's lifecycle
final bool newFiatWasSent = fiatWasSent ||
message.action == Action.fiatSent ||
Expand Down Expand Up @@ -155,12 +206,28 @@ class OrderState {
newPeer = peer; // Preserve existing
}

// Handle dispute status updates based on action
Dispute? updatedDispute = message.getPayload<Dispute>() ?? dispute;

// Handle dispute status updates based on action.
// A payload dispute never re-points a tracked dispute at a different id:
// only the dispute this order already carries can be updated from the wire.
final localDispute = dispute;
final payloadDispute = message.getPayload<Dispute>();
final bool payloadDisputeAccepted = payloadDispute != null &&
(localDispute == null ||
payloadDispute.disputeId == localDispute.disputeId);

if (payloadDispute != null && !payloadDisputeAccepted) {
logger.w(
'Ignoring dispute payload ${payloadDispute.disputeId} for order '
'${message.id}: does not match tracked dispute ${localDispute!.disputeId}',
);
}

Dispute? updatedDispute =
payloadDisputeAccepted ? payloadDispute : localDispute;

// If we got a dispute from the message payload, ensure it has the message timestamp
// This is critical for correct sorting in the dispute list
if (updatedDispute != null && message.getPayload<Dispute>() != null) {
if (updatedDispute != null && payloadDisputeAccepted) {
// Use message timestamp if dispute doesn't have a createdAt or if message has a timestamp
// Note: Nostr timestamps are in seconds, so convert to milliseconds
if (message.timestamp != null) {
Expand Down Expand Up @@ -331,12 +398,17 @@ class OrderState {
// Actions that should set status to canceled
case Action.canceled:
case Action.cancel:
case Action.adminCanceled:
case Action.adminCancel:
case Action.cooperativeCancelAccepted:
case Action.holdInvoicePaymentCanceled:
return Status.canceled;

// A dispute resolved by cancelation is its own terminal state: it keeps
// the admin resolution visible to the user and out of the plain-cancel
// cleanup paths, which are built around Action.canceled.
case Action.adminCanceled:
case Action.adminCancel:
return Status.canceledByAdmin;

// Actions that should set status to cooperatively canceled (pending cancellation)
case Action.cooperativeCancelInitiatedByYou:
case Action.cooperativeCancelInitiatedByPeer:
Expand Down
25 changes: 25 additions & 0 deletions lib/features/order/notifiers/abstract_mostro_notifier.dart
Original file line number Diff line number Diff line change
Expand Up @@ -107,9 +107,24 @@ class AbstractMostroNotifier extends StateNotifier<OrderState> {
final wasUserInitiatedCancel = msg.action == Action.canceled &&
_userInitiatedCancels.remove(orderId);

// Evaluated before the state update, which is what consumes the
// dispute evidence this depends on.
final rejectedAdminAction =
state.rejectsAdminDisputeAction(msg.action);

if (mounted) {
state = state.updateWith(msg);
}

// The state change was dropped; its side effects must go with it.
// Otherwise a forged admin resolution still reaches the user as a
// notification and a jump to the trade detail.
if (rejectedAdminAction) {
logger.w(
'Dropping side effects for rejected ${msg.action} on order $orderId');
return;
}

if (msg.timestamp != null &&
msg.timestamp! >
DateTime.now()
Expand Down Expand Up @@ -668,6 +683,16 @@ class AbstractMostroNotifier extends StateNotifier<OrderState> {
}
break;

// Mirrors adminSettled: an admin resolution is terminal but keeps its
// session, so the user retains the record of how the dispute ended.
// Deliberately not routed through the Action.canceled cleanup, which
// deletes the session and would erase that record.
case Action.adminCanceled:
if (isRecent && !bypassTimestampGate) {
navProvider.go('/trade_detail/$orderId');
}
break;

case Action.cantDo:
final cantDo = event.getPayload<CantDo>();

Expand Down
16 changes: 14 additions & 2 deletions lib/features/trades/providers/trades_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,18 @@ import 'package:mostro_mobile/shared/providers/session_notifier_provider.dart';
// Status filter provider - holds the currently selected status filter
final statusFilterProvider = StateProvider<Status?>((ref) => null);

/// Whether an order's status belongs under the selected filter.
///
/// The picker offers no separate "canceled by admin" entry, so orders resolved
/// by an admin cancelation stay under "canceled" instead of dropping out of
/// every filter. The distinction is surfaced in the trade detail.
bool matchesStatusFilter(Status status, Status filter) {
if (filter == Status.canceled) {
return status == Status.canceled || status == Status.canceledByAdmin;
}
return status == filter;
}

// New provider that properly handles synthetic status filtering by checking OrderState
final filteredTradesWithOrderStateProvider =
Provider<AsyncValue<List<NostrEvent>>>((ref) {
Expand Down Expand Up @@ -59,10 +71,10 @@ final filteredTradesWithOrderStateProvider =

final orderState = orderStates[order.orderId!];
if (orderState != null) {
return orderState.status == selectedStatusFilter;
return matchesStatusFilter(orderState.status, selectedStatusFilter);
} else {
// Fallback to raw status comparison if OrderState not available
return order.status == selectedStatusFilter;
return matchesStatusFilter(order.status, selectedStatusFilter);
}
});
}
Expand Down
16 changes: 8 additions & 8 deletions lib/l10n/intl_de.arb
Original file line number Diff line number Diff line change
Expand Up @@ -841,23 +841,23 @@
"disputeClosedUserCompleted": "Streitfall geschlossen – die Order wurde von den Parteien erfolgreich abgeschlossen.",
"disputeClosedCooperativeCancel": "Streitfall geschlossen – die Order wurde von den Parteien kooperativ storniert.",
"disputeInProgress": "Dieser Streitfall wird derzeit bearbeitet. Ein Schlichter prüft deinen Fall.",
"disputeSellerRefunded": "Dieser Streitfall wurde gelöst; dem Verkäufer wurde der Betrag zurückerstattet.",
"disputeSellerRefunded": "Der Administrator hat diesen Streitfall zugunsten des Verkäufers entschieden.",
"disputeUnknownStatus": "Der Status dieses Streitfalls ist unbekannt.",
"disputeChatClosed": "Dieser Streitfall wurde gelöst. Der Chat ist nun geschlossen.",
"@_comment_dispute_descriptions": "Beschreibungen der Streitfall-Meldungen",
"disputeDescriptionInitiatedByUser": "Du hast diesen Streitfall eröffnet",
"disputeDescriptionInitiatedByPeer": "Ein Streitfall gegen dich wurde eröffnet",
"disputeDescriptionInitiatedPendingAdmin": "Ein Administrator wird diesen Streitfall bald übernehmen",
"disputeDescriptionInProgress": "Noch keine Nachrichten",
"disputeDescriptionResolved": "Order abgeschlossen – Käufer hat die Sats erhalten",
"disputeDescriptionSellerRefunded": "Order abgebrochen – Verkäufer erhielt Rückerstattung",
"disputeDescriptionResolved": "Vom Administrator zugunsten des Käufers abgeschlossen",
"disputeDescriptionSellerRefunded": "Vom Administrator zugunsten des Verkäufers abgebrochen",
"disputeDescriptionUnknown": "Unbekannter Status",
"disputeAdminSettledMessage": "Der Administrator hat die Order zugunsten einer Partei entschieden. Überprüfe deine Wallet auf Zahlungen.",
"disputeSellerRefundedMessage": "Der Administrator hat die Order abgebrochen und dem Verkäufer den Betrag erstattet. Der Streitfall ist nun geschlossen.",
"disputeSettledBuyerMessage": "Der Streitfall wurde zu deinen Gunsten gelöst. Die Order wurde erfolgreich abgeschlossen und du hast die Sats erhalten. Überprüfe deine Wallet.",
"disputeSettledSellerMessage": "Der Streitfall wurde gelöst. Die Order wurde erfolgreich abgeschlossen und der Käufer hat die Sats erhalten.",
"disputeCanceledBuyerMessage": "Der Administrator hat die Order abgebrochen. Dem Verkäufer wurde der Betrag erstattet und du hast keine Sats erhalten.",
"disputeCanceledSellerMessage": "Der Administrator hat die Order abgebrochen und dir den Betrag erstattet. Der Käufer hat keine Sats erhalten. Überprüfe deine Wallet auf die Rückerstattung.",
"disputeSellerRefundedMessage": "Der Administrator hat die Order abgebrochen und den Streitfall zugunsten des Verkäufers entschieden. Der Streitfall ist nun geschlossen.",
"disputeSettledBuyerMessage": "Der Administrator hat den Streitfall zu deinen Gunsten entschieden und die Order abgeschlossen. Überprüfe deine Wallet, um den Eingang der Sats zu bestätigen.",
"disputeSettledSellerMessage": "Der Administrator hat den Streitfall zugunsten des Käufers entschieden und die Order abgeschlossen.",
"disputeCanceledBuyerMessage": "Der Administrator hat die Order abgebrochen und den Streitfall zugunsten des Verkäufers entschieden. Diese Order wird nicht abgeschlossen.",
"disputeCanceledSellerMessage": "Der Administrator hat die Order abgebrochen und den Streitfall zu deinen Gunsten entschieden. Überprüfe deine Wallet, um den Eingang der Rückerstattung zu bestätigen.",
"disputeOpenedByYou": "Du hast diesen Streitfall gegen den Käufer {counterparty} eröffnet, bitte lies das Folgende sorgfältig durch:",
"@disputeOpenedByYou": {
"placeholders": {
Expand Down
16 changes: 8 additions & 8 deletions lib/l10n/intl_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -841,23 +841,23 @@
"disputeClosedUserCompleted": "Dispute closed — the order was successfully completed by the parties.",
"disputeClosedCooperativeCancel": "Dispute closed — the order was cooperatively canceled by the parties.",
"disputeInProgress": "This dispute is currently in progress. A solver is reviewing your case.",
"disputeSellerRefunded": "This dispute has been resolved with the seller being refunded.",
"disputeSellerRefunded": "The admin resolved this dispute in the seller's favor.",
"disputeUnknownStatus": "The status of this dispute is unknown.",
"disputeChatClosed": "This dispute has been resolved. The chat is now closed.",
"@_comment_dispute_descriptions": "Dispute Description Messages",
"disputeDescriptionInitiatedByUser": "You opened this dispute",
"disputeDescriptionInitiatedByPeer": "A dispute was opened against you",
"disputeDescriptionInitiatedPendingAdmin": "An admin will take this dispute soon",
"disputeDescriptionInProgress": "No messages yet",
"disputeDescriptionResolved": "Order was completed - buyer received the sats",
"disputeDescriptionSellerRefunded": "Order was canceled - seller refunded",
"disputeDescriptionResolved": "Settled by the admin in the buyer's favor",
"disputeDescriptionSellerRefunded": "Canceled by the admin in the seller's favor",
"disputeDescriptionUnknown": "Unknown status",
"disputeAdminSettledMessage": "The admin settled the order in favor of one party. Check your wallet for any payments.",
"disputeSellerRefundedMessage": "The admin canceled the order and refunded the seller. The dispute is now closed.",
"disputeSettledBuyerMessage": "The dispute was resolved in your favor. The order was completed successfully and you received the sats. Check your wallet.",
"disputeSettledSellerMessage": "The dispute was resolved. The order was completed successfully and the buyer received the sats.",
"disputeCanceledBuyerMessage": "The admin canceled the order. The seller was refunded and you did not receive the sats.",
"disputeCanceledSellerMessage": "The admin canceled the order and refunded you. The buyer did not receive the sats. Check your wallet for the refund.",
"disputeSellerRefundedMessage": "The admin canceled the order and resolved the dispute in the seller's favor. The dispute is now closed.",
"disputeSettledBuyerMessage": "The admin resolved the dispute in your favor and settled the order. Check your wallet to confirm the sats arrived.",
"disputeSettledSellerMessage": "The admin resolved the dispute in the buyer's favor and settled the order.",
"disputeCanceledBuyerMessage": "The admin canceled the order and resolved the dispute in the seller's favor. This order will not be completed.",
"disputeCanceledSellerMessage": "The admin canceled the order and resolved the dispute in your favor. Check your wallet to confirm the refund arrived.",
"disputeOpenedByYou": "You opened this dispute against the buyer {counterparty}, please read carefully below:",
"@disputeOpenedByYou": {
"placeholders": {
Expand Down
16 changes: 8 additions & 8 deletions lib/l10n/intl_es.arb
Original file line number Diff line number Diff line change
Expand Up @@ -839,23 +839,23 @@
"disputeClosedUserCompleted": "Disputa cerrada — la orden fue completada exitosamente por las partes.",
"disputeClosedCooperativeCancel": "Disputa cerrada — la orden fue cancelada cooperativamente por las partes.",
"disputeInProgress": "Esta disputa está actualmente en progreso. Un mediador está revisando tu caso.",
"disputeSellerRefunded": "Esta disputa se ha resuelto con el vendedor siendo reembolsado.",
"disputeSellerRefunded": "El administrador resolvió esta disputa a favor del vendedor.",
"disputeUnknownStatus": "El estado de esta disputa es desconocido.",
"disputeChatClosed": "Esta disputa ha sido resuelta. El chat está cerrado.",
"@_comment_dispute_descriptions": "Mensajes de Descripción de Disputas",
"disputeDescriptionInitiatedByUser": "Tú abriste esta disputa",
"disputeDescriptionInitiatedByPeer": "Se abrió una disputa en tu contra",
"disputeDescriptionInitiatedPendingAdmin": "Un administrador tomará esta disputa pronto",
"disputeDescriptionInProgress": "Aún no hay mensajes",
"disputeDescriptionResolved": "Orden completada - el comprador recibió los sats",
"disputeDescriptionSellerRefunded": "Orden cancelada - vendedor reembolsado",
"disputeDescriptionResolved": "Liquidada por el administrador a favor del comprador",
"disputeDescriptionSellerRefunded": "Cancelada por el administrador a favor del vendedor",
"disputeDescriptionUnknown": "Estado desconocido",
"disputeAdminSettledMessage": "El administrador resolvió la orden a favor de una de las partes. Revisa tu billetera para ver los pagos.",
"disputeSellerRefundedMessage": "El administrador canceló la orden y reembolsó al vendedor. La disputa está ahora cerrada.",
"disputeSettledBuyerMessage": "La disputa se resolvió a tu favor. La orden se completó exitosamente y recibiste los sats. Revisa tu billetera.",
"disputeSettledSellerMessage": "La disputa fue resuelta. La orden se completó exitosamente y el comprador recibió los sats.",
"disputeCanceledBuyerMessage": "El administrador canceló la orden. Se reembolsó al vendedor y no recibiste los sats.",
"disputeCanceledSellerMessage": "El administrador canceló la orden y te reembolsó. El comprador no recibió los sats. Revisa tu billetera para ver el reembolso.",
"disputeSellerRefundedMessage": "El administrador canceló la orden y resolvió la disputa a favor del vendedor. La disputa está ahora cerrada.",
"disputeSettledBuyerMessage": "El administrador resolvió la disputa a tu favor y liquidó la orden. Revisa tu billetera para confirmar que recibiste los sats.",
"disputeSettledSellerMessage": "El administrador resolvió la disputa a favor del comprador y liquidó la orden.",
"disputeCanceledBuyerMessage": "El administrador canceló la orden y resolvió la disputa a favor del vendedor. Esta orden no se completará.",
"disputeCanceledSellerMessage": "El administrador canceló la orden y resolvió la disputa a tu favor. Revisa tu billetera para confirmar que recibiste el reembolso.",
"disputeOpenedByYou": "Abriste esta disputa contra el comprador {counterparty}, lee atentamente a continuación:",
"@disputeOpenedByYou": {
"placeholders": {
Expand Down
Loading
Loading