Skip to content
Open
Show file tree
Hide file tree
Changes from 8 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
90 changes: 84 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,51 @@ 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.
///
/// Evidence is the tracked [Dispute] object and nothing else. In particular
/// [Status.dispute] does not qualify: any action mapping to that status sets
/// it without carrying a dispute, so accepting it would let a bare
/// `dispute` message stand in as the evidence for the `admin-*` message
/// right behind it.
///
/// The incoming payload does not count either — taking an attacker-supplied
/// Dispute as its own justification is the vector this guards against. An
/// already-resolved dispute is not re-resolved, which blocks replaying an
/// authentic resolution onto a later state.
bool get _acceptsAdminDisputeAction {
final localDispute = dispute;
if (localDispute == null) return false;

final localDisputeStatus = localDispute.status?.toLowerCase();
return localDisputeStatus == null ||
!_terminalDisputeStatuses.contains(localDisputeStatus);
}

/// 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 +146,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 +212,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 +404,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
36 changes: 36 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,25 @@ 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');
onAdminResolutionRejected(msg);
return;
}

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

/// Called when an admin resolution was dropped for lack of dispute evidence.
///
/// The rejection is decided from in-memory state, which during startup may
/// not yet hold the dispute this resolution belongs to. Subclasses that
/// hydrate from storage override this to replay the persisted history, where
/// the dispute and the resolution are applied in order. Replaying is safe:
/// the guard runs on every message of the replay, so a resolution with no
/// dispute ahead of it in the history is still rejected.
void onAdminResolutionRejected(MostroMessage message) {}

Future<void> handleEvent(MostroMessage event,
{bool bypassTimestampGate = false,
Status? previousStatus,
Expand Down Expand Up @@ -668,6 +694,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
48 changes: 46 additions & 2 deletions lib/features/order/notifiers/order_notifier.dart
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,20 @@ import 'package:mostro_mobile/shared/providers.dart';
import 'package:mostro_mobile/features/order/notifiers/abstract_mostro_notifier.dart';
import 'package:mostro_mobile/services/logger_service.dart';
import 'package:mostro_mobile/services/mostro_service.dart';
import 'package:mostro_mobile/shared/utils/order_sync_helpers.dart';

class OrderNotifier extends AbstractMostroNotifier {
late final MostroService mostroService;
ProviderSubscription<AsyncValue<List<NostrEvent>>>? _publicEventsSubscription;
bool _isSyncing = false; // Only for sync() method

bool _hydrated = false; // A sync() has read the history successfully
bool _resyncRequested = false; // A sync() was asked for while one was running
int _resyncAttempts = 0;

/// Bounds the replay chain so a stream of rejected resolutions during
/// startup cannot keep queueing full history reads.
static const _maxChainedResyncs = 3;

OrderNotifier(super.orderId, super.ref) {
mostroService = ref.read(mostroServiceProvider);
sync();
Expand All @@ -35,16 +43,33 @@ class OrderNotifier extends AbstractMostroNotifier {
wasUserInitiatedCancel: wasUserInitiatedCancel);
}

/// Replays the persisted history when a resolution was rejected only because
/// startup had not loaded its dispute yet. Once hydrated, a rejection is the
/// correct outcome and no replay is needed — which also keeps forged
/// resolutions from each costing a full storage read.
@override
void onAdminResolutionRejected(MostroMessage message) {
if (_hydrated) return;
logger.i(
'Re-syncing order $orderId: ${message.action} arrived before hydration completed');
sync();
Comment thread
AndreaDiazCorreia marked this conversation as resolved.
}

Future<void> sync() async {
if (_isSyncing) return;
if (_isSyncing) {
_resyncRequested = true;
return;
}

var succeeded = false;
try {
_isSyncing = true;

final storage = ref.read(mostroStorageProvider);
final messages = await storage.getAllMessagesForOrderId(orderId);
if (messages.isEmpty) {
logger.w('No messages found for order $orderId');
succeeded = true;
return;
}

Expand Down Expand Up @@ -73,6 +98,7 @@ class OrderNotifier extends AbstractMostroNotifier {
if (state.status == Status.canceled) {
await reconcileCanceledBondedSession();
}
succeeded = true;
} catch (e, stack) {
logger.e(
'Error syncing order state for $orderId',
Expand All @@ -81,6 +107,24 @@ class OrderNotifier extends AbstractMostroNotifier {
);
} finally {
_isSyncing = false;

final completion = resolveSyncCompletion(
succeeded: succeeded,
resyncRequested: _resyncRequested,
resyncAttempts: _resyncAttempts,
maxChainedResyncs: _maxChainedResyncs,
);
_resyncRequested = false;

switch (completion) {
case SyncCompletion.replay:
_resyncAttempts++;
sync();
case SyncCompletion.hydrated:
_hydrated = true;
case SyncCompletion.unhydrated:
break;
}
}
}

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
Loading
Loading