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
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
49 changes: 47 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,21 @@ 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 chain of replays a single startup may schedule. Rejected
/// resolutions are hostile input, so the chain must not be paced by how fast
/// they arrive.
static const _maxChainedResyncs = 3;

OrderNotifier(super.orderId, super.ref) {
mostroService = ref.read(mostroServiceProvider);
sync();
Expand All @@ -35,16 +44,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 +99,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 +108,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